Skip to main content
Insecure Deserialization: Risks and How to Prevent It

Insecure Deserialization: Risks and How to Prevent It

KU
• 6 min read•17 views

Insecure deserialization lets attackers turn untrusted data into arbitrary code execution, and here's how it happens and how to stop it.

Insecure deserialization is what happens when an application rebuilds objects from data it did not verify, letting an attacker's crafted byte stream trigger code execution during the rebuild itself. The bug lives in the gap between "receive bytes" and "inspect the result": native deserializers in Java, Python, PHP, and .NET run constructors, setters, and magic methods before your application logic ever sees the object, and an attacker who controls the input controls what runs.

What are serialization and deserialization?

Serialization turns an in-memory object, a class instance with fields, methods, and internal state, into a flat sequence of bytes so it can be stored on disk, cached, or sent across a network. Deserialization is the reverse: take that byte stream and reconstruct the original object graph, with its type and internal state intact.

This pattern is everywhere. Session tokens, cached objects, queue payloads, and inter-service RPC calls all lean on serialization formats to move structured data around. Java has ObjectInputStream and Serializable. Python has pickle. PHP has serialize()/unserialize(). .NET has BinaryFormatter. Each one promises the same convenience: hand it an object, get bytes back, and later get the same object back from those bytes.

The convenience is also the problem. Object serialization formats do not just carry data, they carry type information and instructions for rebuilding that type. When the bytes come from a source you do not control, that source decides which classes get instantiated and which code paths run.

Why is deserializing untrusted data dangerous?

A JSON parser reading {"name": "attacker"} can only produce strings, numbers, booleans, arrays, and objects made of those primitives. It has no concept of "now call this method." A native object deserializer is a different animal. To rebuild a Serializable object it has to invoke constructors, setters, and lifecycle hooks (Java's readObject(), Python's __reduce__, PHP's __wakeup() and __destruct()) as part of the reconstruction process, not after it.

A malicious payload does not need to exploit a bug in your business logic at all. It only needs to describe an object whose normal, intended behavior does something dangerous when constructed. Chain a few such objects together (a gadget chain) and the side effects compound into arbitrary command execution, all before your code gets a chance to check whether the deserialized value looks sane. That is why insecure deserialization routinely leads straight to remote code execution rather than something milder like corrupted data.

What does insecure deserialization look like in each language?

  • Java: ObjectInputStream.readObject() on untrusted bytes is the classic entry point. Attackers rarely need a bug in your code; they need one exploitable class already sitting on the classpath (Apache Commons Collections and Spring have both supplied historical gadget chains) and can wire it into a working exploit with public tooling.
  • Python: pickle.loads() on attacker-supplied data is explicitly unsafe. Python's pickle format can embed a __reduce__ call that runs any importable function, including os.system, at load time.
  • PHP: unserialize() on user input has produced years of PHP object injection (POI) bugs, typically abusing __wakeup() or __destruct() magic methods to trigger file writes, SQL injection, or code execution.
  • Node.js: the node-serialize package's unserialize() was shown to execute embedded JavaScript via a crafted _$$ND_FUNC$$_ prefix in the serialized string, turning a cookie value into remote code execution.
  • .NET: BinaryFormatter has the same class of risk, and Microsoft's own guidance now recommends removing it from applications entirely rather than trying to sandbox its use, in favor of safer serializers.

How do attackers exploit deserialization?

The reconnaissance is often boring. Attackers look for base64-looking blobs sitting in cookies, hidden form fields, or URL parameters, then decode them offline to check for the telltale headers of a serialized object (Java's aced0005 magic bytes, PHP's O:8:"classname" pattern, a pickled opcode stream). Once they confirm the format, they rarely need to hand-craft an exploit from scratch. Tools like ysoserial for Java generate ready-made gadget-chain payloads for dozens of known-vulnerable library combinations, and equivalent tooling exists for PHP and .NET. Insecure deserialization had its own entry in the OWASP Top 10 2017 as A8, and in the 2021 edition it was folded into the broader "Software and Data Integrity Failures" category, reflecting that the underlying problem (trusting bytes to describe their own safe reconstruction) applies beyond just object graphs.

How do you prevent insecure deserialization?

The first fix is architectural: never feed data from an untrusted source into a native, unrestricted deserializer. If a value crosses a trust boundary, a user's browser, an external API, another team's queue, treat it as hostile input.

Prefer data-only formats for anything crossing that boundary. JSON, once parsed with a standard library parser, produces plain data structures with no notion of arbitrary type instantiation or method invocation. That single property closes off the whole class of gadget-chain attacks, which is why most modern APIs standardized on JSON over the wire instead of native object serialization.

python.python
# Unsafe: pickle reconstructs arbitrary Python objects,
# running __reduce__ during the load itself.
import pickle

def handle_request(request_data):
    obj = pickle.loads(request_data)  # attacker-controlled code path
    return obj

# Safer: JSON only ever produces plain data structures.
import json

def handle_request(request_data):
    obj = json.loads(request_data)  # dict/list/str/int/bool/None only
    return obj

If you genuinely need to move rich objects across a trust boundary, sign the payload and verify that signature before deserialization runs, not after. An HMAC or similar integrity check means a tampered payload gets rejected at the door, before any bytes reach the deserializer. Keep whatever deserialization library you use patched, since most gadget chains depend on a specific vulnerable class being reachable. And run deserializing processes with least privilege, so a gadget chain that slips through lands in a low-permission process instead of one that can read secrets or reach production systems.

None of this is exotic. It is the same trust-boundary discipline behind the rest of application security best practices: validate at the edge, prefer the least powerful format for the job, and assume anything you did not generate yourself is hostile. For the broader landscape this bug sits in, see the OWASP Top 10 explained.

The call we'd make

Default to JSON or another data-only format for anything crossing a trust boundary, full stop. If a native deserializer has to touch external input, put a signature check in front of it and keep the process it runs in locked down, because gadget chains are a solved problem for attackers and an ongoing one for everyone still calling pickle.loads() or unserialize() on data they did not generate themselves.

Explore topics:DevOps
React
Share this post
KU

Kiril Urbonas

AI Engineer

553 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.