CVEReports
CVEReports

Automated vulnerability intelligence platform. Comprehensive reports for high-severity CVEs generated by AI.

Product

  • Home
  • Sitemap
  • RSS Feed

Company

  • About
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 CVEReports. All rights reserved.

Made with love by Amit Schendel & Alon Barad



GHSA-MXHJ-88FX-4PCV
9.3

The Invisible Object: Ghosting Fickling's Safety Check

Amit Schendel
Amit Schendel
Senior Security Researcher

Feb 24, 2026·7 min read·6 visits

PoC Available

Executive Summary (TL;DR)

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 Hook: The False Prophet of Pickle Safety

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.

The Flaw: The Vanishing Act

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.

The Code: Anatomy of a Ghost

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.

The Exploit: Peek-a-Boo, I See You (Not)

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:

  1. Preparation: We set up the stack with the target class (socketserver.TCPServer) and its arguments (IP, Port, Handler).
  2. The Trigger: We issue the OBJ opcode. The Python VM executes TCPServer(...). The server starts listening. Side effect achieved.
  3. The Cloak: We immediately issue the POP opcode. The return value (the server object) is discarded from the stack.
  4. The Result: 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.

The Impact: Why This Matters

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 Fix: Mitigation & Remediation

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.

Official Patches

Trail of BitsCommit fixing the AST generation for popped objects

Fix Analysis (1)

Technical Appendix

CVSS Score
9.3/ 10
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

Affected Systems

fickling <= 0.1.7CI/CD pipelines using fickling for model scanningML Ops platforms relying on fickling for safety checks

Affected Versions Detail

Product
Affected Versions
Fixed Version
fickling
Trail of Bits
<= 0.1.70.1.8
AttributeDetail
CWECWE-693
CVSS Score9.3 (Critical)
Attack VectorNetwork (Malicious File)
ImpactSecurity Bypass / RCE
Affected OpcodesOBJ (0x6f), POP (0x30)
Exploit StatusWeaponized PoC Available

MITRE ATT&CK Mapping

T1562.001Impair Defenses: Disable or Modify Tools
Defense Evasion
T1204.002User Execution: Malicious File
Execution
T1059.006Command and Scripting Interpreter: Python
Execution
CWE-693
Protection Mechanism Failure

Protection Mechanism Failure

Known Exploits & Detection

GitHub AdvisoryAdvisory containing Proof of Concept code for bypassing safety checks

Vulnerability Timeline

Vulnerability identified
2026-02-19
Fix committed to repository
2026-02-20
Advisory published and v0.1.8 released
2026-02-24

References & Sources

  • [1]GHSA-mxhj-88fx-4pcv Advisory
  • [2]Fickling PyPI

Attack Flow Diagram

Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.
Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.