A machine learning API accepts a .pkl file and calls pickle.loads(). A Java REST endpoint accepts application/x-java-serialized-object. A configuration service parses YAML with yaml.load(). Three formats, three RCE paths, one architectural mistake.
Every insecure deserialization vulnerability shares one root cause: the API reconstructs executable object graphs from client-supplied bytes before validating what those objects should contain. The format, whether pickle, Java serialization, YAML, PHP unserialize, or JSON class hinting, determines the exploitation path. The fix is the same across all formats: never deserialize untrusted input into executable object graphs.
The Root Cause Is Not the Format: It Is the Trust Boundary
Every deserialization vulnerability is a trust boundary violation. Code execution triggers before any application-level validation runs. The format's own instruction set becomes the attacker's weapon.
Object graph reconstruction triggers constructors, magic methods, and callbacks before the application inspects field values. Input filtering fails because by the time validation runs, the side effect has already occurred. The object graph is already executing.
CWE-502 appears in OWASP A08:2021 precisely because the anti-pattern is language-agnostic. The gadget chain changes with every library update. The trust boundary mistake does not.
Python Pickle: __reduce__ Is a Callback Into the OS
Python's pickle format embeds executable callbacks in serialized data. Any API that calls pickle.loads() on untrusted bytes runs arbitrary code by design, not by bug. That makes ML model-serving endpoints the new high-value attack surface.
pickle.__reduce__ is called automatically during deserialization. Returning (os.system, ('id',)) executes a shell command in three lines of valid pickle. No sandboxing, no type validation, no warning.
CVE-2025-32444 (vLLM Mooncake, CVSS 10.0) exposed this attack surface at real scale. recv_pyobj() called pickle.loads() over ZeroMQ sockets bound to 0.0.0.0 with no authentication. A pickle payload sent as "model weights" executed on the GPU worker with full privileges.
CVE-2025-1716 proved that static analysis does not solve the problem. Researchers bypassed Picklescan via a blocklist gap — pip.main() was absent from its restricted-globals list, making it an undetected callable for arbitrary shell execution. Picklescan 0.0.21 added pip to the blocklist. Hugging Face-hosted models were demonstrated as a direct attack vector.
The fix: HMAC-sign payloads server-side and verify the signature before calling pickle.loads(). For model weights, use SafeTensors. That format has no code execution path.
Java Serialization: Your Classpath Is the Attack Surface
Java deserialization RCE is not a single vulnerability. It is an infinite class of vulnerabilities whose exploitability depends entirely on which libraries exist on the classpath. Patching one gadget chain does not close the vulnerability.
ObjectInputStream.readObject() instantiates any class reachable on the classpath. The attacker does not exploit a bug in your code. They exploit classes you never intended to expose. ysoserial ships 33+ gadget chains: CommonsCollections1-6, Spring, Hibernate. All of them chain innocent PriorityQueue.readObject() calls into Runtime.exec().
CVE-2016-4437 (Apache Shiro, CVSS 9.8) demonstrated the problem at its worst. The rememberMe cookie was decrypted with a hardcoded AES key and passed directly to ObjectInputStream. The attacker controlled both the key and the payload. CommonsCollections on the classpath was sufficient for unauthenticated RCE.
Apache Struts, WebLogic, and Jenkins all fell to ObjectInputStream RCE between 2015 and 2019. All used different gadget chains against the same CommonsCollections dependency. HackerOne #1529790 (Kafka Connect) showed the vector does not require an explicit deserialization endpoint. Attacker-controlled SASL JAAS config via API triggered an LDAP lookup whose response was deserialized via ObjectInputStream.
The fix: ObjectInputFilter via JEP 290 with a deny-by-default allowlist. Never deserialize from ObjectInputStream without class filtering applied before the first byte is read.
YAML: yaml.load() Is eval() with YAML Syntax
Python's yaml.load() and Java's SnakeYAML Constructor both instantiate arbitrary classes from document tags. The difference between parsing configuration and executing code is a single function name.
yaml.load('!!python/object/apply:os.system [id]', Loader=yaml.Loader) executes a shell command. yaml.safe_load() rejects all class tags by design. The distinction is in the API, not the format.
CVE-2022-1471 (SnakeYAML < 2.0, CVSS 9.8) hit dozens of projects as a transitive dependency. The !!javax.script.ScriptEngineManager tag combined with a ClassPathXmlApplicationContext gadget loaded an attacker-controlled Spring XML config from a remote URL. Atlassian, Google Cloud Dataflow, Amazon SageMaker, and LinkedIn Dagli were all affected.
The fix in Python is yaml.safe_load(), always. In Java, use SafeConstructor in SnakeYAML 2.0. The API signature communicates the security contract explicitly.
PHP unserialize(): Magic Methods Are the Gadget Entry Points
PHP's unserialize() is not dangerous because it is broken. It is dangerous because it guarantees execution of __wakeup() and __destruct() on every instantiated object. Any library with those methods is a potential gadget regardless of its original purpose.
The Laravel RCE10 POP chain (PHPGGC) demonstrates the cascade: __destruct in Illuminate\Broadcasting\PendingBroadcast calls __toString on a chained object, propagating through Mockery to file_put_contents(). A webshell written from a single unserialize() call. PHPGGC lists 10 Laravel RCE chains covering versions 5.4 through 9.x.
Mirasvit Full Page Cache Warmer for Magento 2 (before version 1.11.12) allowed unauthenticated PHP object injection via a crafted serialized parameter. The native Magento gadget chain was sufficient for code execution.
The fix: replace unserialize() with json_decode(). Where replacement is impossible, pass allowed_classes => false to prevent object instantiation entirely.
JSON Class Hinting: When the Type Field Becomes the Exploit
Jackson, Fastjson, and Gson support polymorphic deserialization via type metadata fields such as @class and @type. The feature was designed for API convenience. It hands the attacker a class loader.
CVE-2026-16723 (Fastjson 1.x, CVSS 9.0) does not require AutoType to be enabled. A crafted @type value redirects Fastjson's class-loader resolution into a jar:http:// URL fetch. In a Spring Boot fat-JAR environment, this loads attacker-controlled bytecode and runs its static initializer. No JNDI, no AutoType flag required. No patch exists for the 1.x branch. Migration to fastjson2 is required. Active exploitation against US enterprise targets has been confirmed.
CVE-2026-54512 (Jackson-databind, June 2026) bypassed PolymorphicTypeValidator. The validator checked the container class name but not nested type arguments. An array of a disallowed element type reopens the gadget-instantiation path. Affected versions run from 2.10.0 through 2.18.7, fixed in 2.18.8 and 2.21.4. The 3.x series (3.0.0 through 3.1.3) is also affected, fixed in 3.1.4.
The denylist approach fails systematically. Fastjson's AutoType mechanism was patched in 2019, 2022, and 2026. Each patch closed one resolution path while the underlying architecture, trusting @type as a class name, remained unchanged.
The fix: mapper.deactivateDefaultTyping() in Jackson. For Fastjson, migrate from 1.x to fastjson2, which uses an allowlist-first model. Never use @type or @class with externally supplied values.
The Fix Is the Same Across Every Format: Authenticate Before You Deserialize
Every format-specific defense is a variation of one pattern: authenticate and verify integrity before handing bytes to the deserializer. The alternative is replacing the deserializer with one that cannot execute code.
The concrete fixes: pickle, HMAC-sign and verify before pickle.loads(); Java, ObjectInputFilter deny-by-default before ObjectInputStream.readObject(); YAML, safe_load() or SafeConstructor. For PHP, use json_decode() with allowed_classes => false; for JSON polymorphic, deactivate default typing and use a validated enum as discriminator.
The mago.team tool identifies deserialization endpoints in API surfaces. It scans for application/x-java-serialized-object content-type headers and class-hinting fields in JSON request bodies.
The invariant across all formats: verify who sent the data and that it has not been tampered with before the deserializer touches the first byte. The gadget chain changes with every library update. The trust boundary mistake does not.
Top comments (0)