GitHub Security Advisory: GHSA-q339-8rmv-2mhv
Package: erb (RUBYGEMS)
Severity: HIGH (CVSS 8.1)
CWE: CWE-693: Protection Mechanism Failure
Vulnerable versions: < 4.0.3.1
Patched in: 4.0.3.1

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

Details:
## Summary

Ruby 2.7.0 (before ERB 2.2.0 was published on rubygems.org) introduced an `@_init` instance variable guard in `ERB#result` and `ERB#run` to prevent code execution when an ERB object is reconstructed via `Marshal.load` (deserialization). However, three other public methods that also evaluate `@src` via `eval()` were not given the same guard:

- `ERB#def_method`
- `ERB#def_module`
- `ERB#def_class`

An attacker who can trigger `Marshal.load` on untrusted data in a Ruby application that has `erb` loaded can use `ERB#def_module` (zero-arg, default parameters) as a code execution sink, bypassing the `@_init` protection entirely.

<details>

## The @_init Guard

In `ERB#initialize`, the guard is set:

```ruby
# erb.rb line 838
@_init = self.class.singleton_class
```

In `ERB#result` and `ERB#run`, the guard is checked before `eval(@src)`:

```ruby
# erb.rb line 1008-1012
def result(b=new_toplevel)
  unless @_init.equal?(self.class.singleton_class)
    raise ArgumentError, "not initialized"
  end
  eval(@src, b, (@filename || '(erb)'), @lineno)
end
```

When an ERB object is reconstructed via `Marshal.load`, `@_init` is either `nil` (not set during marshal reconstruction) or an attacker-controlled value. Since `ERB.singleton_class` cannot be marshaled, the attacker cannot set `@_init` to the correct value, and `result`/`run` correctly refuse to execute.

## The Bypass

`ERB#def_method`, `ERB#def_module`, and `ERB#def_class` all reach `eval(@src)` without checking `@_init`:

```ruby
# erb.rb line 1088-1093
def def_method(mod, methodname, fname='(ERB)')
  src = self.src.sub(/^(?!#|$)/) {"def #{methodname}\n"} << "\nend\n"
  mod.module_eval do
    eval(src, binding, fname, -1)      # <-- no @_init check
  end
end

# erb.rb line 1113-1117
def def_module(methodname='erb')       # <-- zero-arg call possible
  mod = Module.new
  def_method(mod, methodname, @filename || '(ERB)')
  mod
end

# erb.rb line 1170-1174
def def_class(superklass=Object, methodname='result')  # <-- zero-arg call possible
  cls = Class.new(superklass)
  def_method(cls, methodname, @filename || '(ERB)')
  cls
end
```

`def_module` and `def_class` accept zero arguments (all parameters have defaults), making them callable through deserialization gadget chains that can only invoke zero-arg methods.

### Method wrapper breakout

`def_method` wraps `@src` in a method definition: `"def erb\n" + @src + "\nend\n"`. Code inside a method body only executes when the method is called, not when it's defined. However, by setting `@src` to begin with `end\n`, the attacker closes the method definition early. Code after the first `end` executes immediately at `module_eval` time:

```ruby
# Attacker sets @src = "end\nsystem('id')\ndef x"
# After def_method transformation, module_eval receives:
#
#   def erb
#   end
#   system('id')    <- executes at eval time
#   def x
#   end
```

---

## Proof of Concept

### Minimal (ERB only)

```ruby
require 'erb'

erb = ERB.allocate
erb.instance_variable_