Turning Data Back Into Code#

Serialization turns an in-memory object into a storable, transmittable form: bytes, JSON, XML or a language-specific binary blob. Deserialization reverses it, rebuilding the object. The danger is that many serialization formats encode not just data but the object's type and behaviour. When an application deserializes input from an untrusted source using such a format, an attacker can control which classes get instantiated and which of their methods run, and in the worst case that means arbitrary code execution without ever sending a line of source code.

Why It Is So Dangerous#

Native serialization formats store the class of an object, not only its fields. If the deserializer will instantiate any class the input names, and some available class does something dangerous in a constructor, destructor or special reconstruction method, the attacker chains those side effects into a gadget chain. The application never calls the malicious code directly; the act of deserializing does.

Two levels of impact are worth separating:

  • Object injection: tampering with field values to change program state (flipping role from user to admin, altering a price, extending a session). No code execution, but often a critical authorisation bypass on its own.
  • Remote code execution (RCE): chaining gadget classes until something like Runtime.exec, os.system or a file write happens.

Python: pickle#

Python's pickle runs code during unpickling through the __reduce__ protocol, which tells pickle how to reconstruct an object by naming a callable and its arguments:

import pickle, os

class Exploit:
    def __reduce__(self):
        # pickle will call os.system("id") when this object is unpickled
        return (os.system, ("id",))

payload = pickle.dumps(Exploit())
# If a server anywhere does pickle.loads(untrusted_bytes), this runs `id`:
pickle.loads(payload)

Running it prints the output of the id command. This is why the Python docs state plainly that you must never unpickle data from an untrusted or unauthenticated source. The same risk lives in anything built on pickle: joblib and dill model files, cached objects, and yaml.load without a safe loader. A cached machine-learning model file downloaded from the internet is executable code, not data.

The safe alternatives cannot express behaviour at all:

import json
data = json.loads(untrusted)   # can only produce dict/list/str/int/float/bool/None

json.loads has no mechanism to instantiate arbitrary classes or call functions, which is exactly why it is safe for untrusted input.

Java, PHP and .NET#

  • Java: ObjectInputStream.readObject() on untrusted bytes is the classic sink. Gadget chains assembled from libraries commonly on the classpath (Apache Commons Collections and others) have produced some of the most severe RCEs on record. The ysoserial tool generates these payloads for testing.
  • PHP: unserialize() on user input, combined with magic methods like __wakeup and __destruct, enables object injection and POP (property-oriented programming) chains, often escalated to file writes or RCE.
  • .NET: BinaryFormatter, LosFormatter and SoapFormatter are unsafe by design; BinaryFormatter is deprecated and disabled by default in recent .NET for this reason.

The pattern recurs across languages: a rich, behaviour-carrying format plus attacker-controlled input equals a code-loading primitive.

A Realistic Attack Chain#

Trace how object injection becomes RCE in a common setup. A Java service stores session state in a cookie as a base64-encoded serialized object:

  1. The attacker copies the cookie and base64-decodes it to raw serialized bytes.
  2. Instead of a session object, they insert a serialized object from a gadget library on the classpath whose readObject performs dangerous operations when reconstructed.
  3. They re-encode and send the cookie.
  4. The server calls readObject on the cookie, the gadget chain fires, and it ends in Runtime.exec with attacker-chosen arguments. The attacker never sent a "command", only a data structure.

The same shape appears in PHP (a __destruct that writes a web shell to disk) and Python (a __reduce__ calling os.system). Defence in depth means removing the sink, adding integrity where the sink is unavoidable, patching to break known gadgets, and running the process with minimal privilege so a successful chain reaches little.

Signing When You Cannot Avoid a Blob#

Sometimes you legitimately need to round-trip a blob through an untrusted place (a cookie, a hidden field, a message queue) but you control both ends. Then attach a MAC so tampered blobs are rejected before deserialization, and prefer a data-only format inside:

import hmac, hashlib, json, base64

SECRET = b"key-from-your-secrets-manager"

def sign(obj: dict) -> str:
    body = base64.urlsafe_b64encode(json.dumps(obj, separators=(",", ":")).encode())
    tag = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    return f"{body.decode()}.{tag}"

def verify(token: str) -> dict:
    body, tag = token.rsplit(".", 1)
    expected = hmac.new(SECRET, body.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, tag):   # constant-time compare
        raise ValueError("bad signature: tampered or wrong key")
    return json.loads(base64.urlsafe_b64decode(body))

token = sign({"user": "dana", "role": "user"})
print(verify(token))
# Flipping any byte of the body makes verify() raise before json.loads runs.

Two things make this safe: the payload is JSON (no behaviour to trigger even if verification were skipped), and hmac.compare_digest avoids the timing side channel of ==. This is what signed cookie and token libraries do internally; see digital signatures and hashing. Verification only proves the blob is untampered; it does not make a native format like pickle safe, because a legitimate holder of the signing key (or a key leak) could still smuggle a gadget.

Defences, in Priority Order#

  1. Do not deserialize untrusted data with a behaviour-carrying format. Use JSON, Protocol Buffers or MessagePack, which encode data only. This removes the vulnerability rather than mitigating it.
  2. If exchange of a blob is unavoidable, sign it with a secret-keyed MAC and verify in constant time before deserializing, as above.
  3. Allowlist types where a framework insists on deserializing objects. Restrict ObjectInputStream (via a filter) or the equivalent to an explicit set of expected classes, so unknown gadget classes are refused.
  4. Patch dependencies. Many gadget chains depend on specific vulnerable library versions; keeping current breaks them. See supply-chain security.
  5. Least privilege and isolation. A service that must deserialize risky input should run with minimal permissions and network egress, so a successful chain has little to reach. This lands the topic squarely in the OWASP Top 10.

Finding It#

  • Recognise the sinks: pickle.loads, yaml.load (unsafe loader), ObjectInputStream.readObject, unserialize, BinaryFormatter.Deserialize. Grep the codebase for them and trace whether any receives external input.
  • Spot serialized data on the wire: PHP serialized strings (O:8:"stdClass"...), base64 blobs in cookies that decode to \xac\xed (the Java stream header) or to pickle opcodes.
  • Test safely on your own systems or labs: PortSwigger's Web Security Academy has insecure-deserialization labs, and ysoserial generates Java payloads for authorised testing.

Practice#

Reproduce the pickle example in an isolated environment, then rewrite the round-trip to use the signed-JSON pattern and confirm that flipping a byte raises before any object is built. Next, grep a codebase you work on for the sink functions above and check, for each hit, whether the input could ever come from a user, another service or a file an attacker might influence.