Skip to main content

Command Palette

Search for a command to run...

The RCE that hides inside one innocent-looking `load()` call

A line like `pickle.loads(request.body)` reads like plumbing, but when the bytes come from a stranger it is executing their code, not decoding data. This is CWE-502, and it wears the same disguise in five languages. Here is why one `load` can hand over your server, and thawguard, a zero-config static gate that catches it before the build ships.

Updated
9 min readView as Markdown
The RCE that hides inside one innocent-looking `load()` call
J
Cloud & Platform Engineer focused on building systems that scale and endure. I explore how infrastructure, automation, and engineering practices come together to support modern software teams.

Picture the most ordinary code review you will do this week. A pull request adds a caching layer, or a job queue consumer, or a session store. Somewhere in the diff there is a call that turns a stored byte string back into a real object, and the variable name next to it is something calm like payload or data. You read "deserialize the request", the tests are green, and you approve it.

You just approved remote code execution. Not a bug that could become RCE under some future refactor. RCE today, exploitable by anyone who can influence those bytes.

This is the strange thing about insecure deserialization: the dangerous version and the harmless version are visually almost identical. The whole difference is where the bytes came from, and that fact is usually a few lines away from the call itself. This post is about why that gap is so easy to miss, and a small tool I wrote called thawguard that reads exactly that gap.

Why deserialization is code execution, not parsing

When you call json.loads, the parser knows the target shape ahead of time: strings, numbers, arrays, objects. There is no instruction in the format that says "now construct an object of this arbitrary class and call this method on it." That safety is not politeness, it is a property of the format.

Native serializers make the opposite promise. Python's pickle, PHP's unserialize, Ruby's Marshal, Java's ObjectInputStream, .NET's BinaryFormatter were all built to reconstruct any object graph faithfully, which means the byte stream is allowed to name classes, set fields, and trigger the hooks that fire during reconstruction. An attacker does not need a memory-corruption exploit. They just write a valid serialized object whose reconstruction does something useful for them, and the deserializer, doing its documented job, builds it.

In Java and .NET this became an industry: pre-built "gadget chains" stitched from ordinary library classes that, when deserialized together, run a command. In PHP it is object injection. In Python it is a two-line pickle. Same root cause everywhere: a format powerful enough to rebuild arbitrary objects was handed bytes it should never have trusted.

The tell is the data source, and it is never on the same line

Here is what makes this class so slippery to catch by eye. The sink and the danger are separated. The call itself, pickle.loads(blob), is neutral. Whether it is a critical vulnerability or a perfectly fine internal optimisation depends entirely on where blob originated, and that assignment lives a few lines up, or in another function, or behind a framework accessor.

A reviewer's attention is on the line in the diff. The proof of danger is out of frame. So the reviewer sees a load, pattern-matches it to "normal serialization code," and moves on. This is not carelessness. It is the natural failure mode of reviewing changes one hunk at a time.

A tool can afford to look wider than a single line, and that is the whole idea behind thawguard.

One binary, five ecosystems

thawguard is a single Go binary, standard library only, that scans your source tree for the specific calls that reconstruct arbitrary objects from bytes. It is deliberately narrow: not a grep for every load or parse, only the handful of sinks that actually execute code during reconstruction.

  • Python - pickle.loads / cPickle, and yaml.load without a safe Loader.

  • PHP - unserialize().

  • Ruby - Marshal.load / Marshal.restore, and YAML.load / Psych.load in their non-safe forms.

  • .NET / C# - BinaryFormatter, LosFormatter, NetDataContractSerializer, SoapFormatter, ObjectStateFormatter.

  • Java - ObjectInputStream.readObject() and XMLDecoder.

No code is executed and no network call is made. thawguard reads files, never runs them, which is the only responsible way to audit code whose entire risk is that it runs things.

 Upload image here: cover.svg

The low-false-positive trick

If a tool flagged every deserialization call, it would be useless, because plenty of them are fine: an internal cache you control end to end, a signed blob you verified before decoding. The signal that separates a vulnerability from a non-issue is trust in the input, so that is exactly what thawguard reads.

For each sink it finds, thawguard reads a small context window around the call, a few lines before and one after, and decides among three outcomes.

  • An untrusted source is nearby -> it escalates to a blocker. An HTTP request handle, a PHP superglobal like $_POST / $_GET / $_COOKIE, a socket, a message-queue consumer, a file upload, a cookie or header, or a variable literally named untrusted / user_input / tainted.

  • A sink but no visible source -> it warns instead. The call is still dangerous, but thawguard cannot see where the bytes come from, so it reports lower confidence rather than crying wolf.

  • A safe marker is present -> it says nothing. yaml.safe_load, Loader=SafeLoader, PHP ['allowed_classes' => false], Ruby permitted_classes:, Java ValidatingObjectInputStream / an ObjectInputFilter allow-list, or a weights_only flag all short-circuit the finding entirely.

A couple of details keep the noise down further. Comments are stripped before the source and safe-marker checks, so a // from request note never reads as a real source and a commented-out safe_load never clears a live sink. Import and using lines name a type without calling it, so they are skipped, and a Serializable class's own readObject definition is a declaration, not a dotted call, so it is left alone.

The two rules

Everything collapses into two rules:

  • TG001 (blocker) - a deserialization sink fed clearly attacker-controlled data. This is the real thing: a request body, a superglobal, a socket, or a queue message flowing straight into a code-executing deserializer. It fails the build.

  • TG002 (warning) - a sink is present but the input source is uncertain. Still worth your eyes, but it never fails the build unless you pass --strict.

Before and after

Here is a session handler that reads a cookie and thaws it back into an object. It compiles, it works, and it is a textbook TG001.

# api/session.py
import pickle

def load_session(request):
    raw = request.cookies.get("session")
    return pickle.loads(raw)        # <- bytes straight from the client

Run thawguard against the tree:

$ thawguard .

● 1 attacker-controlled deserialization (blocker):

  api/session.py:6:12   pickle.load(s)() on attacker-controlled data
     ↳ untrusted source in context: request.cookies (client-controlled)
     ↳ crafted input can execute arbitrary code during unpickling
     ↳ fix: use json.loads, or verify an HMAC before decoding internal blobs
     [TG001 · blocker · CWE-502: Deserialization of Untrusted Data]

1 blocker · 0 warnings

It found the sink, named the untrusted source that makes it a blocker, and pointed at CWE-502. The fix is not to make pickle safer, because you cannot. The fix is to stop feeding a code-executing deserializer bytes you did not author. For a session cookie, that means a format that only carries data:

# api/session.py
import json

def load_session(request):
    raw = request.cookies.get("session")
    return json.loads(raw)          # data only: no class construction, no code

Re-run, and the gate is clear:

$ thawguard .
✓ No insecure deserialization of untrusted data.
0 blockers · 0 warnings

Where you genuinely need structured data, the same shape applies in each language: yaml.safe_load for Python config, json_decode or unserialize($x, ['allowed_classes' => false]) in PHP, YAML.safe_load(..., permitted_classes: [...]) in Ruby, System.Text.Json in .NET, and a look-ahead allow-list in Java.

How it differs from trustload

If you have seen my earlier tool trustload, the two can look adjacent, and it is worth being precise about the split. trustload is ML-artifact-only: it targets the model-supply-chain case, a torch.load, joblib, or pickle of a model weight file pulled from somewhere you do not fully control.

thawguard is the general app-layer case: request bodies, form fields, cookies, sockets, and queue messages flowing into a language's native deserializer, across five languages. Different sink surface, different data source. They do not overlap, and they complement each other cleanly. If you deal with both untrusted model files and untrusted request data, run both.

Try it

thawguard installs as a single binary and runs against a path:

go install github.com/jay-tank/thawguard@latest

thawguard                 # scan the current directory
thawguard ./src           # scan a path
thawguard --json          # machine-readable output
thawguard --strict        # treat warnings (TG002) as failures too

Exit 0 when clean, 1 on a blocker (or any finding under --strict), 2 on a usage error, so it drops straight into a pre-commit hook or CI job. A reviewed, provably-trusted call can be silenced with an inline thawguard:ignore comment or a path listed in a .thawguardignore file. It's MIT-licensed and on GitHub: https://github.com/jay-tank/thawguard

The honest limits

thawguard is a line and context heuristic, not a full parser or a taint tracker, and I would rather name its edges than oversell it.

Source detection is proximity-based. If an untrusted value reaches the sink through several hops or another module, thawguard sees a bare sink and emits a TG002 warning rather than a TG001 blocker. That is a deliberate trade: it keeps blockers trustworthy at the cost of occasionally under-classifying a real one to a warning. Running with --strict in CI closes that gap by treating warnings as failures too.

Likewise, a safe allow-list configured far from the call may fall outside the context window, and names inside string literals are not distinguished from code, so an exotic construction can be mis-read. Those are the cases the suppression mechanisms exist for.

What thawguard does do is answer one question well, across five languages, in the fraction of a second a pre-commit hook can spare: is a code-executing deserializer being handed untrusted bytes? That single question still catches the most dangerous and most overlooked shape of remote code execution in modern applications, the one that hides in plain sight inside a call that just says load.

More from this blog

J

Jay Tank's Engineering Blog

50 posts

Deep dives on running fintech & Web3 infrastructure at scale - AWS, Kubernetes, CI/CD, edge security, observability, and Bitcoin Lightning. Practical architecture breakdowns and open-source DevOps tools from a senior platform engineer.