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.
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 = {} |
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 |
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::DeprecationProxyActiveSupport::Deprecation::DeprecatedConstantProxyActiveSupport::OptionMerger
Only three classes across Rails and all its dependencies.
DeprecationProxy is the base class for DeprecatedInstanceVariableProxy:
class DeprecationProxy |
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 |
Both @instance and @method are ivars set during deserialization.
So when Array#hash calls .hash on this proxy:
.hashis undefined →method_missing(:hash)fires@deprecator.warn(...)runs, but@deprecatoris anActiveSupport::Deprecationwith@silenced = true, so it returns immediatelytarget.__send__(:hash)runs, buttarget()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 |
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
deserialize → Hash key insertion → Array#hash |
The proxy dispatches directly to the sink, so there's no bridge gadget needed.
Here's the Oj payload as an example:
{"^#1": [[ |
$ docker run oj-puma-poc |
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 download
require "oj" |
Dockerfile download
FROM ruby:3.3 |
docker build -t oj-puma-poc . |
Mitigations
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.