Insecure Deserialization
Updated August 24, 2026
4 min read
Insecure deserialization happens when an application rebuilds objects from attacker-controlled bytes. Reconstruction runs real code — constructors, magic methods, custom readObject handlers — so it escalates to remote code execution more often than most injection classes. We rate it high, critical once a working gadget chain is proven: the outcome is code execution, not disclosure.
What is insecure deserialization?
Serialization flattens an object graph into bytes; deserialization rebuilds it. Native formats carry type information, so the bytes decide which classes the runtime constructs — control them and you control part of what runs.
The dangerous sinks:
- Java —
ObjectInputStream.readObject(), plus anything wrapping it (RMI, JMX, JSF view state). - PHP —
unserialize(), which fires magic methods such as__wakeup(),__destruct()and__toString(). - Python —
pickle.loads(); the docs warn that pickle is not secure against maliciously constructed data. - Ruby —
Marshal.load(). - .NET —
BinaryFormatter: obsoleted in .NET 5 (SYSLIB0011), disabled by default in .NET 8, removed in .NET 9 — the in-box implementation now throws.
JSON parsing is not this bug. JSON.parse(), json.loads() and System.Text.Json on defaults produce data, not types. The exception is polymorphic type resolution — Jackson default typing, Newtonsoft's TypeNameHandling.All — which lets the document name the class to instantiate.
It sits under OWASP Software or Data Integrity Failures (A08:2025, formerly A08:2021), having been its own category in the 2017 OWASP Top 10.
Why it's a risk / how it's exploited
The attacker supplies no code. They supply an object graph whose methods, as the runtime unwinds it, chain into something dangerous — a gadget chain built from classes already on your classpath. ysoserial (Java) and phpggc (PHP) catalogue those chains for Commons Collections, Spring, Laravel, Symfony and Monolog.
A realistic path: an app keeps session state in a cookie as a base64 ObjectInputStream blob. The attacker generates a CommonsCollections6 payload — a LazyMap/InvokerTransformer chain ending in Runtime.exec() — and swaps the cookie. It is deserialized in the servlet filter chain, above the authentication check, so the command runs before login state is evaluated.
Preconditions, plainly: you need a reachable sink fed by attacker-controlled bytes and a usable gadget in the installed dependencies. Neither is guaranteed, which is why the baseline is high rather than automatically critical — but when both line up you get code execution, far above informational findings like server version disclosure.
How to detect it
Hard to spot from outside: no header to check, no error to grep. Black-box detection means finding serialization markers in cookies, hidden fields and parameters.
# Java streams start AC ED 00 05 — base64 prefix "rO0AB"
curl -sI https://target.example/app | grep -i set-cookie | grep -oE 'rO0AB[A-Za-z0-9+/=]*'
# PHP serialized objects reflected in a response
curl -s https://target.example/page | grep -oE 'O:[0-9]+:"[A-Za-z0-9_\\]+"'
Worth investigating: aced 0005 at the head of a base64-decoded cookie, or O:8:"stdClass":1:{...} in a parameter. Not this bug: a JWT or an opaque session ID.
The dependable signals are internal, making this a SAST and dependency-scanning problem as much as a DAST one: grep for readObject, unserialize(, pickle.loads and Marshal.load, and audit dependencies for gadget libraries.
How to fix it
1. Don't deserialize untrusted input. Keep state server-side behind an opaque ID, or move to a data-only format with no type metadata.
2. If a blob must round-trip through the client, authenticate it before parsing:
import hmac, hashlib, json
mac = hmac.new(key, blob, hashlib.sha256).hexdigest()
if not hmac.compare_digest(mac, sig):
raise ValueError("tampered")
data = json.loads(blob) # data only — never pickle.loads
3. Allowlist classes. Java 9+ (backported to 8u121) ships ObjectInputFilter:
ObjectInputFilter f = ObjectInputFilter.Config.createFilter(
"com.example.dto.*;java.base/*;!*"); // allow these, reject the rest
ois.setObjectInputFilter(f);
Patterns are semicolon-separated, a module/ prefix scopes one to a single module, and the trailing !* rejects the rest; -Djdk.serialFilter=... sets a JVM-wide default. In PHP 7.0+, unserialize($data, ['allowed_classes' => false]) returns inert __PHP_Incomplete_Class stubs, not live objects.
4. Kill polymorphic typing. Jackson's enableDefaultTyping() has been deprecated since 2.10 and is gone in 3.x; for genuine subtypes use @JsonTypeInfo with an explicit @JsonSubTypes list, or activateDefaultTyping() with a BasicPolymorphicTypeValidator allowlist. Leave Newtonsoft at TypeNameHandling.None.
Caveat: there is no edge configuration for this. A WAF rule matching rO0AB stops only the laziest payloads — blobs can be gzipped or hit a non-obvious sink — and a filter as broad as java.* still admits gadgets. It is code work, confirmed by web application security testing.
FAQ
Is JSON safe from insecure deserialization? Plain JSON parsing is safe: it yields maps, lists, strings and numbers, nothing else. It turns unsafe only when polymorphic type resolution is on, because the document then names the class to instantiate.
Can a scanner find insecure deserialization? Partly. Scanning reliably surfaces the signals — serialization markers in cookies and parameters, gadget-bearing library versions. Proving exploitability needs a working gadget chain against your dependencies — agent-driven testing, not signature matching.
Deserialization sinks hide in cookies and hidden fields no header check reveals. Run a free website vulnerability scan and let Exploita's AI pentest agent hunt serialized blobs across your attack surface, with a verified proof of concept.
