Model scanners publish what they catch against malicious pickles. We wanted to know what ours misses. That's an awkward thing to publish, and it's also a number worth having — because the techniques are all public, written up in CVEs and conference talks that any attacker can read.

So we built the corpus and published the whole thing, including the parts that don't flatter us. Eleven documented pickle-scanner evasions, reproduced as inert artifacts, scanned with AIsbom's own engine. Here is what happens.

9 Detected
2 Partial
0 Missed

Two of those eleven are ⚠️ partial, which is the verdict we found hardest to write and care about most. It means AIsbom refused to call the file safe but never disassembled the payload — so you are correctly warned off the file, for the wrong reason. A scanner that counted those as wins would be lying by rounding.

You don't have to take our word for any of it:

pip install aisbom-cli py7zr
aisbom bypass-scorecard

That command regenerates every artifact from source, scans them in both modes, and prints the table below on your machine. The corpus ships inside the package — not in a private test directory — precisely so that "run it yourself" is a real answer and not an invitation to trust us. py7zr is needed only to build the 7z case; the scanner itself never unpacks 7z, which is exactly why that case scores a partial below.

The pickle scanner bypass scorecard

Blocklist is the default mode: flag known-dangerous globals. Strict is --strict, an allowlist where anything unrecognized is flagged. A technique counts as caught only if a scan actually surfaces it.

Evasion Class Blocklist Strict
Model packed with 7z instead of ZIP
nullifai-7z-container
container-format ⚠️ partial ⚠️ partial
Broken pickle stream, payload first
nullifai-broken-stream
broken-stream ✅ detected ✅ detected
Code execution via pip.main()
cve-2025-1716
unlisted-global ✅ detected ✅ detected
Payload behind a non-standard extension
cve-2025-1889
file-extension ✅ detected ✅ detected
ZIP local header disagrees with the directory
cve-2025-1944
zip-tampering ✅ detected ✅ detected
ZIP general-purpose flag bits modified
cve-2025-1945
zip-tampering ✅ detected ✅ detected
Bare pickle wearing a PyTorch extension
cve-2025-10155
file-extension ✅ detected ✅ detected
Corrupted CRC-32 in the ZIP archive
cve-2025-10156
zip-tampering ✅ detected ✅ detected
Dangerous import via an asyncio submodule
cve-2025-10157
gadget-import ✅ detected ✅ detected
Indirect execution via bdb.Bdb.run
checkmarx-bdb-gadget
gadget-import ✅ detected ✅ detected
Allowlisted builtin reached via STACK_GLOBAL
shadowpickle-allowlist-overwrite
allowlist-abuse ⚠️ partial ⚠️ partial

Two controls sit alongside these and are just as load-bearing. A plain os.system in an untampered PyTorch ZIP must always be caught — if that one ever fails, the harness is broken and the rest of the table is noise. And an ordinary model with only allowlisted globals must scan clean, because a scanner that flags everything is as useless as one that flags nothing.

The two pickle evasions we don't fully catch

These are the honest part of the table. Neither is an oversight we haven't got to; both are decisions, and we'd rather defend them in public than quietly count them as detections.

7z containers: named, not unpacked

nullifAI repacked a model with 7z instead of ZIP. PyTorch still loaded it; picklescan never opened it.

AIsbom reports CRITICAL (Non-Standard Container: 7z) — the right severity, earned from the container rather than the payload. The archive is named but never unpacked, so the os.system call inside is never disassembled and the reason we give you is not the real one.

Why we're leaving it. Unpacking 7z means shipping a compression stack and its native dependencies in every install and every standalone binary, to cover one evasion class where the container choice is itself the finding. A user acting on this verdict is correctly warned off the file. We think that's the right trade, and the scorecard says ⚠️ partial rather than claiming a detection we haven't made.

ShadowPickle: the ceiling on static allowlists

ShadowPickle overwrites collections.OrderedDict — a name on essentially every scanner's allowlist — and resolves it via STACK_GLOBAL rather than an inline argument, so string-matching on the opcode argument sees nothing.

AIsbom does resolve STACK_GLOBAL and reads the pair off the stack, so it sees collections.OrderedDict. And that name is legitimately allowlisted, because real state_dicts are OrderedDicts. Both modes therefore return only MEDIUM (Pickle Present) — the baseline every pickle gets — rather than anything specific to this file.

This one is a genuine limit, and we proved it rather than assuming it. An ordinary OrderedDict state_dict pickled at protocol ≥ 4 produces the same observable opcode signature as the attack: STACK_GLOBAL on collections.OrderedDict followed by REDUCE. Any rule keyed on that shape would flag essentially every real PyTorch checkpoint. There's a test in the suite that asserts the equivalence, so if someone later "fixes" this case, that test tells them what they've actually done. Closing it needs evidence beyond the resolved name — argument shape, or provenance — not a new blocklist entry.

The evasion we missed: CVE-2025-1889

Until this week the table had a third entry in the not-caught column, and it was worse than either partial: an outright ❌ missed.

CVE-2025-1889 is almost insultingly simple. Name the payload config.p. Discovery decided what to open from a file's suffix alone, .p wasn't on the list, and so the file was never opened. The scan reported No AI models found and exited 0.

A clean bill of health on a directory carrying a reverse shell. That is the worst failure mode a scanner has, because the user is not merely unprotected — they've been told they're fine.

The obvious fix is to add .p to the extension list. We didn't, because it protects nobody: the technique is any unexpected suffix, and the attacker picks the name. Adding .p would have flipped this row on our own published scorecard while leaving weights.dat wide open. That is exactly the kind of laundering the corpus exists to prevent.

So v1.3.2 identifies unclaimed files by content instead. Any file no extension claims gets its bytes checked, and if they're a pickle, it gets scanned.

What that cost us to get right

The first version of this check was wrong in a way worth publishing, because it's the trap anyone building the same feature will hit.

The natural rule is "disassemble the head; if it parses as pickle opcodes and reaches a STOP, it's a pickle." That rule is badly broken. . is the STOP opcode — so every stylesheet that opens with a class selector is a valid one-opcode pickle. Run against a real node_modules of 5,123 files, that rule claimed eleven of them: seven JavaScript files, a TypeScript declaration, a stylesheet and two man pages.

False positives in a security tool aren't cosmetic. Every phantom component lands in your AI Bill of Materials and trains you to skim past findings, which is how a real one gets missed. So the check now validates the pickle stack — a stream that pops from an empty stack or ends holding anything other than one object is rejected — and those eleven filenames are regression tests.

One more detail, because it's where the two evasion classes meet: validation runs on the prefix ending at the first STOP, not the whole buffer. A payload followed by a corrupt tail — the nullifAI shape — is still caught rather than thrown out along with its own garbage. Truncating a stream is otherwise a way to opt out of being scanned.

And the limit we're still carrying. Discovery reads a 64KB head and only re-reads with a bigger budget when that head looks like an unfinished pickle. Past 16MB it stops looking, so a payload behind a literal larger than that isn't discovered by content. Verified rather than asserted: caught at 16,773,120 bytes, not discovered at 16,781,312.

Why the misses can't quietly disappear

A scorecard you regenerate yourself is worth nothing if "the scorecard is failing" can be fixed by regenerating the scorecard. So there are two files, doing two different jobs.

baseline.json describes current detection and fails the build on drift in either direction. floor.json is a promise: the best verdict every case has ever reached. It only ever ratchets upward. Lowering it requires a hand edit to a committed file — which is the audit trail.

The gate is per-case and per-mode, not an aggregate. That distinction is not theoretical: when we deliberately broke detection to test the ratchet, the headline number didn't move at all, because strict mode still caught what blocklist mode had stopped catching. A count-based gate would have waved it through. The per-case gate named all three regressions and exited non-zero.

When CVE-2025-1889 flipped to detected this week, the floor rose with it. It cannot silently regress now without failing the build on every release.

Sources: the picklescan CVEs behind each case

Every case cites the research it reproduces. We verified each of these against the primary source before building the corpus rather than working from memory, because this page publishes them:

These techniques were found and disclosed by other people's research, and several were fixed in picklescan before we ever wrote a corpus. This scorecard measures our tool against their work.

Run it against your own models

The corpus is synthetic and inert — every artifact is generated on your machine, and nothing in it is ever executed, only disassembled. The same engine scans real files:

# the published scorecard, on your machine (py7zr builds the 7z case)
pip install aisbom-cli py7zr
aisbom bypass-scorecard

# your models
aisbom scan ./models --strict

The full generated scorecard, including per-case detail, lives in docs/bypass-scorecard.md. If you find a technique we've missed — open an issue. A case we can't catch yet is worth more to us on this page than off it.

And the honest meta-point: every technique on this page exists because pickle is a programming language pretending to be a data format. Scanning it well is a rearguard action. If you control the format your models ship in, safetensors removes this entire class of problem — no opcodes, no gadgets, nothing to disassemble. The scorecard above is for everyone who doesn't get that choice.