Feb 24, 2026·7 min read·6 visits
Fickling versions <= 0.1.7 fail to track object instantiations (like opening a socket) if the result is immediately popped from the stack. Attackers can craft malicious pickles that execute high-impact code (RCE) but are reported as 'Safe' by the analyzer.
For years, security engineers have screamed 'Don't unpickle untrusted data!' into the void, only to be ignored by ML engineers who just want to load their weights. Enter `fickling`, a tool designed to analyze pickle bytecode and tell you if it's safe. It was the chosen one. Unfortunately, `fickling` had a blind spot. By abusing the stack discipline of the pickle machine—specifically how it handles object instantiation followed by immediate disposal—attackers could execute arbitrary code that `fickling` simply refused to see. This wasn't just an evasion; it was a total invisibility cloak for RCE, allowing backdoored models to pass safety checks with flying colors while opening reverse shells in the background.
The Python pickle module is essentially a stack-based virtual machine that is Turing-complete enough to ruin your day. Because pickle.load() executes code during deserialization, it has become the standard vector for supply chain attacks in the Machine Learning ecosystem. You download a cool Hugging Face model, load it, and boom—your AWS keys are on a server in a non-extradition country.
To combat this, tools like fickling were created. fickling decompiles the pickle bytecode into a Python Abstract Syntax Tree (AST). The idea is brilliant: instead of executing the pickle, we statically analyze what it would do. If the AST contains import os; os.system('rm -rf /'), fickling flags it. It acts as a static analysis security guard for dynamic serialization.
But here is the problem with static analysis of a stack machine: you have to perfectly emulate the state. If your emulator desynchronizes from the actual Python VM implementation, you enter the 'Uncanny Valley of Exploitation'. GHSA-mxhj-88fx-4pcv is exactly that—a desync where fickling decides an operation didn't happen, while the Python interpreter is busy executing it.
To understand this bug, you have to think like a stack machine. In the pickle protocol, opcodes manipulate a stack. When fickling sees an opcode like OBJ (which instantiates a class using arguments from the stack), it creates an ast.Call node representing that constructor call. It then pushes this AST node onto its own internal simulation stack.
The logic flaw was subtle but devastating. fickling generates its final safety report based on a generated module_body—a list of statements. An AST node sitting on the stack is not yet a statement. It only becomes a statement if it is assigned to a variable or explicitly used. If the pickle stream contains a POP opcode immediately after the OBJ opcode, the Python VM discards the top item of the stack.
When fickling processed that POP, it dutifully popped the ast.Call node from its simulation stack and... threw it away. Because the node was never assigned to a variable or appended to the module_body, it effectively ceased to exist in the eyes of the analyzer. However, in the real world, the side effects of that constructor (like connecting to a C2 server) happen during instantiation. fickling assumed that if a return value is unused, the function call is irrelevant. In Python, that is a fatal assumption.
Let's look at the actual code drift. The vulnerability existed in how fickling handled the OBJ, NEWOBJ, and NEWOBJ_EX opcodes. These opcodes are responsible for creating new class instances.
The Vulnerable Code (Conceptual):
# Inside the interpreter loop
if opcode.name == 'OBJ':
cls = stack[-1]
args = stack_slice
# Create a Call node
call_node = ast.Call(func=cls, args=args)
# Push it to the stack
self.stack.append(call_node)
# ... later ...
if opcode.name == 'POP':
self.stack.pop() # The Call node is gone forever!Because the Call node lived only on the stack, the POP instruction sent it to the void. The fix, introduced in version 0.1.8, forces the interpreter to acknowledge the existence of the call by anchoring it to a temporary variable immediately.
The Fix (Commit ff423d):
# Inside the fixed interpreter
if opcode.name == 'OBJ':
# ... setup cls and args ...
call_node = ast.Call(func=cls, args=args)
# CRITICAL CHANGE: Assign to a synthetic variable immediately
var_name = interpreter.new_variable(call_node)
# Push the VARIABLE, not the call, to the stack
self.stack.append(ast.Name(id=var_name, ctx=ast.Load()))By wrapping the call in interpreter.new_variable(call_node), fickling generates code like _var1 = socket.socket(...). Even if _var1 is subsequently popped and unused, the assignment statement remains in the AST, allowing the safety analysis to see the socket call.
Exploiting this is trivially elegant. We don't need memory corruption; we just need to speak the language of Pickle slightly better than fickling does. We want to execute socketserver.TCPServer(('0.0.0.0', 9999), Handler) to open a backdoor, but hide it from the scanner.
The Attack Chain:
socketserver.TCPServer) and its arguments (IP, Port, Handler).OBJ opcode. The Python VM executes TCPServer(...). The server starts listening. Side effect achieved.POP opcode. The return value (the server object) is discarded from the stack.fickling sees the OBJ, creates the AST node, sees the POP, deletes the AST node. The final decompiled code looks empty or innocuous.PoC Construction:
import pickle
import struct
import io
# We want to construct this sequence:
# OBJ (instantiate TCPServer) -> POP (discard result)
payload = io.BytesIO()
payload.write(b"\x80\x04") # PROTO 4
# ... setup stack with socketserver.TCPServer args ...
payload.write(b"o") # OBJ opcode (Builds object)
payload.write(b"0") # POP opcode (The magic eraser)
payload.write(b".") # STOP
malicious_pickle = payload.getvalue()If you feed this to fickling, it reports LIKELY_SAFE. If you feed it to pickle.load(), it opens a port on your machine. This technique is particularly dangerous because it works with any callable triggered via OBJ, including os.system wrappers or subprocess.Popen if structured as a class instantiation.
This vulnerability breaks the core promise of the tool. Security teams deploy fickling in CI/CD pipelines to scan incoming ML models or serialized data. They trust the output. If fickling says "Green," the pipeline proceeds to deployment.
By using this bypass, an attacker can embed a fully functional C2 agent inside a PyTorch model or a scikit-learn classifier. When the data scientist loads the model to run inference, the backdoor triggers. Because the object is popped immediately, it leaves no trace in the Python variable space—garbage collection might eventually clean it up, but if the constructor spawned a thread or a detached process (common in persistence mechanisms), the malware runs indefinitely.
This is a Class A example of CWE-693: Protection Mechanism Failure. The lock on the door (fickling) works fine, but we just realized the wall next to it is made of holograms.
The remediation is straightforward: Update fickling to version 0.1.8 or higher.
If you are unable to update immediately, you are essentially flying blind. There is no configuration change that fixes this; the logic error is deep in the interpreter's core loop. As a temporary stopgap, you could attempt to detect the OBJ + POP sequence (opcodes o and 0 or \x30) using YARA rules on raw pickle files, but raw pickle parsing via regex/YARA is prone to its own set of bypasses (since pickle isn't a regular language).
Developer Takeaway: When building static analysis tools for dynamic languages or bytecode, you cannot optimize away "dead code" based on return values. In a language like Python, everything has side effects. __init__ is code execution. __new__ is code execution. Getters and Setters are code execution. Assume everything is dangerous until proven otherwise.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:P| Product | Affected Versions | Fixed Version |
|---|---|---|
fickling Trail of Bits | <= 0.1.7 | 0.1.8 |
| Attribute | Detail |
|---|---|
| CWE | CWE-693 |
| CVSS Score | 9.3 (Critical) |
| Attack Vector | Network (Malicious File) |
| Impact | Security Bypass / RCE |
| Affected Opcodes | OBJ (0x6f), POP (0x30) |
| Exploit Status | Weaponized PoC Available |
Protection Mechanism Failure