GHSA-q339-8rmv-2mhv ERB has an @_init deserialization guard bypass via def_module / def_method / def_class
SummaryRuby 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.The @_init GuardIn `ERB#initialize`, the guard is set:In `ERB#result` and `ERB#run`, the guard is checked before `eval(@src)`: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`:`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:Proof of ConceptMinimal (ERB only)Marshal deserialization (ERB + ActiveSupport)When combined with `ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy` as a method dispatch gadget, this achieves RCE via `Marshal.load`:**Chain:**`Marshal.load` reconstructs a Hash with a `DeprecatedInstanceVariableProxy` as keyHash key insertion calls `.hash` on the proxy`.hash` is undefined -> `method_missing(:hash)` -> dispatches to `ERB#def_module``def_module` -> `def_method` -> `module_eval(eval(src))` -> breakout -> `system('id')`**Verified on:** Ruby 3.3.8 / RubyGems 3.6.7 / ActiveSupport 7.2.3 / ERB 6.0.1ImpactScopeAny Ruby application that calls `Marshal.load` on untrusted data AND has both `erb` and `activesupport` loaded is vulnerable to arbitrary code execution. This includes:**Ruby on Rails applications that import untrusted serialized data** -- any Rails app (every Rails app loads both ActiveSupport and ERB) using Marshal.load for caching, data import, or IPC**Ruby tools that import untrusted serialized data** -- any tool using `Marshal.load` for caching, data import, or IPC**Legacy Rails apps** (pre-7.0) that still use Marshal for cookie session serializationSeverity justificationThe `@_init` guard was the recognized last line of defense against ERB being used as a deserialization gadget. Prior gadget chain research -- including Luke Jahnke's November 2024 Ruby 3.4 chain (nastystereo.com) and vakzz's 2021 Universal Deserialization Gadget -- pursued entirely different approaches (Gem::SpecFetcher, UncaughtThrowError, TarReader+WriteAdapter) without exploring the ERB def_method/def_module path. The `def_module` bypass is simpler and more direct than all previous chains, and was not addressed by the subsequent patches to Ruby 3.4 or RubyGems 3.6.This bypass renders the @_init mitigation ineffective across all ERB versions from 2.2.0 through 6.0.3 (latest as of April 2026). Combined with the DeprecatedInstanceVariableProxy gadget (present in all ActiveSupport versions through 7.2.3), this constitutes a universal RCE gadget chain for Ruby 3.2+ applications using Rails.Gadget chain historySix generations of Ruby Marshal gadget chains have been discovered (2018-2026). Each bypassed the previous round of mitigations:| Year | Chain | Mitigated in |
|------|-------|-------------|
| 2018 | Gem::Requirement (Luke Jahnke) | RubyGems 3.0 |
| 2021 | UDG -- TarReader+WriteAdapter (vakzz) | RubyGems 3.1 |
| 2022 | Gem::Specification._load (vakzz) | RubyGems 3.6 |
| 2024 | UncaughtThrowError (Luke Jahnke) | Ruby 3.4 patches |
| 2024 | Gem::Source::Git#rev_parse | RubyGems 3.6 |
| **2026** | **ERB#def_module @_init bypass** | **ERB 6.0.4** |PatchesThe problem has been patched at the following ERB versions. Please upgrade your erb.gem to any one of them.ERB 4.0.3.1, 4.0.4.1, 6.0.1.1, and 6.0.4Add the `@_init` check to `def_method`. Since `def_module` and `def_class` both delegate to `def_method`, this single change covers all three bypass paths:
CVSS v3.1 score: 8.1