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

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 ](https://cdn.hashnode.com/uploads/covers/6a548baa47c6120a79511a69/3e88a772-bd55-4bb5-aafa-9dcdf5d3efcc.svg align="center")

### 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.

```python
# 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:

```text
$ 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:

```python
# 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:

```text
$ 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:

```bash
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**](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`.
