Ruby marshal deserialization is the Ruby version of a bug that shows up in every language with a native object format. The phrase ruby marshal deserialization points at one method, Marshal.load, and one mistake: calling it on bytes that came from outside your program. When you do, Ruby rebuilds whatever objects those bytes describe, and that is the opening a gadget chain needs.
If the idea of rebuilding objects from a byte stream is new, read the insecure deserialization primer first. To see how single objects get chained into code execution, the deserialization gadget chain overview is the hub for this whole topic.
What ruby marshal deserialization does
Marshal is Ruby’s built in binary format for objects. Marshal.dump turns an object into a byte string, and Marshal.load turns it back. It preserves the class, the instance variables, and the structure, which is the point: it is meant to round trip real Ruby objects, not plain data.
data = Marshal.dump({ user: "alice", role: "member" })
obj = Marshal.load(data) # fine when data is yours
The trouble starts when data comes from a client. Marshal does not only rebuild hashes and strings. It rebuilds objects of any class loaded in the process, setting their instance variables to whatever the byte stream says. The attacker, not your code, decides which classes appear and what they hold.
Why rebuilt objects lead to code execution
Rebuilding an object is not harmless, because Ruby objects carry behavior and some of that behavior runs at predictable moments. A gadget chain strings together methods that already exist in Rails, in a gem, or in the standard library. The attacker supplies the instance variables that decide which objects sit at each link, and one of the final links reaches a call like system or an eval buried in a library method.
The structure is the same as in other languages. Ruby Marshal plays the role that pickle plays in Python, and the comparison is worth reading: see python pickle rce for the same bug with a different native format. The work of building the chain itself is covered in finding deserialization gadget chains.
Marshal was built to move your own objects between processes. The bug is handing it bytes that someone else chose.
Where the untrusted bytes get in
Marshal looks safe in a code review because the dangerous call is short and the data source is often a few layers away. Common paths:
- A cookie or session store that keeps marshalled objects and loads them on each request.
- A cache layer, such as a file or memory store, that an attacker can write to.
- A background job queue whose payloads are marshalled and later loaded by a worker.
- An API endpoint that accepts a marshalled blob for convenience.
Consider a cache wrapper in an invented app called Acme Notes:
def fetch(key)
raw = @store.get(key)
raw ? Marshal.load(raw) : nil
end
If an attacker can influence what lands in @store under that key, for example through a second bug that writes cache entries, then this innocent looking fetch becomes the sink. The Marshal.load rebuilds their objects and the chain fires.
How to spot it in a code review
The call itself is easy to grep for, but a raw search for Marshal.load tells you nothing about whether the bytes are trusted. What matters is the path the data took to get there. Two questions decide the risk:
- Where did these bytes come from? Trace the argument backward. If it started as a cookie, a request body, a cache entry, or a queue payload, it is attacker reachable and the call is a real sink. If it only ever holds bytes your own process dumped a moment ago, it is fine.
- Can an attacker write to the store in between? A value that looks internal, like a cache key, becomes untrusted the moment a second bug lets someone write to that store. The loader does not change, but the trust of its input does.
That second case is why a simple allowlist of safe call sites ages badly. A Marshal.load that was safe last year turns dangerous when a new feature starts writing user data into the same cache. The answer is to follow the data, not the method name.
How to fix ruby marshal deserialization
There is no safe mode flag for Marshal. Unlike some formats, it has no option to forbid arbitrary classes, so you cannot make Marshal.load safe on untrusted input. The fix is to not use it there.
- Never call
Marshal.loadon data a user can influence. This is the rule. Treat cookies, request bodies, cache entries an attacker can write, and queue payloads as untrusted. - Use a data only format instead. For structured data, parse JSON with
JSON.parse. It returns plain hashes, arrays, strings, and numbers, with no way to instantiate an arbitrary class, so there is no object to start a chain.require "json" obj = JSON.parse(raw) # plain data, no class rebuilding - Stop marshalling session data. Configure the session store to serialize as JSON rather than Marshal, so a tampered cookie cannot smuggle objects.
- Sign any blob that must round trip. If a value has to leave and return, attach an HMAC and verify it before you touch the bytes. If the signature fails, the data never reaches a loader.
YAML in Ruby has the same trap with a friendlier face, and the default loader there builds arbitrary objects too. That bug, and its safe loader, are covered in ruby yaml deserialization.
For more bugs where input handling turns against the app, browse the injection and input category.
This flaw hides because the risky call is one method and the gadgets live in gems you did not write. UnboundCompute reasons about whether untrusted bytes can actually reach a Marshal.load and drive a chain of object behavior, rather than flagging every call it sees. Read how that assumption testing works on our about page.
Frequently asked questions
Why is Marshal.load on untrusted data dangerous?
Marshal is Ruby’s native object format, so Marshal.load does not just rebuild hashes and strings. It rebuilds objects of any class loaded in the process and sets their instance variables to whatever the byte stream says. An attacker who controls those bytes chooses which objects appear, which is the opening a gadget chain needs.
Can I make Marshal.load safe with an option?
No. Marshal has no mode that forbids arbitrary classes, so there is no safe way to run Marshal.load on data a user can influence. The only fix is to not use Marshal for untrusted input and to parse a data only format such as JSON instead.
Where does untrusted data reach Marshal.load in real apps?
Common paths are session stores and cookies that keep marshalled objects, cache layers an attacker can write to, background job queues whose payloads are marshalled, and API endpoints that accept a marshalled blob. The load call often sits a few layers away from where the bytes first entered.
How is Ruby Marshal related to Python pickle?
They are the same class of bug in different languages. Both are native object formats that rebuild arbitrary objects from a byte stream, so calling the loader on attacker data gives a gadget chain a place to start. The defense is the same: use a data only format and never load native serialized data you did not produce.
Put an autonomous researcher on your own systems
UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.
