Skip to content

fix(scanner): discover pickle payloads by content, not file extension - #92

Merged
lab700xdev merged 1 commit into
mainfrom
slice-110a-content-discovery
Aug 19, 2026
Merged

fix(scanner): discover pickle payloads by content, not file extension#92
lab700xdev merged 1 commit into
mainfrom
slice-110a-content-discovery

Conversation

@lab700xdev

Copy link
Copy Markdown
Contributor

The gap

Discovery decided what to open from a file's suffix alone. That is the whole of CVE-2025-1889: name the payload config.p and nothing ever opens it, so the scan reports No AI models found and exits 0 — a clean bill of health on a directory carrying a reverse shell.

The inspector was never the problem. _inspect_pickle_variant already decides what a file is from its bytes. Only discovery still trusted the name.

Why not just add .p

Because it protects nobody. The technique is any unexpected suffix — the attacker picks the name — so a one-line extension addition would flip the published corpus case while leaving weights.dat wide open. That is exactly the laundering tests/corpus/floor.json exists to prevent.

What landed

Unclaimed files are sniffed by content, and the bar is deliberately high in both directions. A missed payload is the bug being closed; claiming ordinary repository files as models would be worse, filling every SBOM with phantom components until findings stop meaning anything.

Parsing as opcodes proved far too weak a test. . is the STOP opcode, so on a "reaches STOP" rule every stylesheet opening with a class selector is a one-opcode pickle. Measured against a real 5,101-file node_modules, that first rule claimed 11 files — 5 JavaScript/TypeScript files, a stylesheet, a man page. The sniff now validates the pickle stack via pickletools.dis, and those six filenames are regression tests in tests/test_content_discovery.py.

Validation runs on the prefix ending at the first STOP rather than the whole buffer, so a payload followed by a corrupt tail — the nullifAI shape — is still recognized instead of being discarded along with its own garbage.

Reads are bounded: 64KB per unclaimed file, escalating only when opcodes parsed cleanly and no STOP appeared, capped at the variant inspector's own budget so discovery is exactly as far-sighted as the inspection that follows it.

Known limit, stated rather than hidden: a pickle whose first opcode carries an argument longer than the entire sniff budget parses nothing here and is not discovered by content. Reaching that needs a single 16MB-plus literal ahead of the payload.

Scorecard

8/11 → 9/11. cve-2025-1889-nonstandard-extension moves from missed to detected in both modes; the floor is raised accordingly, locking it in.

Verification

  • poetry run pytest824 passed, 91.38% coverage (gate 85%).
  • aisbom bypass-scorecard --check — gate green at 9/11.
  • False positives, 7 real source trees (site, SPA, shared, package, scripts, docs, product): 0 artifacts.
  • False positives, 5,101-file node_modules: 11 → 0.
  • Cost: that same walk takes 1.52s and claims nothing — no regression against the pre-change 1.72s.
  • End to end: a directory of config.p, payload.dat, weights.model and blob reports all four as CRITICAL (RCE Detected: os.system) and exits 2; README.md and settings.json beside them stay unclaimed.

Discovery decided what to open from a file's suffix alone, which is the whole
of CVE-2025-1889: name the payload `config.p` and nothing ever opens it, so the
scan reports "No AI models found" and exits 0 — a clean bill of health on a
directory carrying a reverse shell. The inspector was never the problem; it
already decides what a file is from its bytes. Only discovery still trusted
the name.

Adding `.p` to the extension list was rejected. It would have flipped the
published corpus case while protecting nobody, because the technique is *any*
unexpected suffix, not that one — the attacker picks the name. That is exactly
the laundering the scorecard floor exists to prevent.

Unclaimed files are now sniffed by content. The bar is deliberately high in
both directions: a missed payload is the bug being closed, but claiming
ordinary repository files as models would be worse, filling every SBOM with
phantom components until findings stop meaning anything.

Parsing as opcodes turned out to be far too weak a test. `.` is the STOP
opcode, so on a "reaches STOP" rule every stylesheet opening with a class
selector is a one-opcode pickle; measured against a real node_modules, that
rule claimed 5 JavaScript/TypeScript files, a stylesheet and a man page. The
sniff therefore validates the pickle stack via pickletools.dis, and those six
filenames are now regression tests.

Validation runs on the prefix ending at the first STOP rather than the whole
buffer, so a payload followed by a corrupt tail — the nullifAI shape — is
still recognized instead of being discarded along with its own garbage.

Reads are bounded: 64KB per unclaimed file, escalating only when opcodes
parsed cleanly and no STOP appeared (a real pickle larger than the window),
capped at the variant inspector's own budget so discovery is exactly as
far-sighted as the inspection that follows it. A walk over 5,101 files takes
1.52s and claims nothing.

Known limit, stated rather than hidden: a pickle whose first opcode carries an
argument longer than the entire sniff budget parses nothing here and is not
discovered by content. Reaching that needs a single 16MB-plus literal ahead of
the payload.

Scorecard: 8/11 -> 9/11. cve-2025-1889-nonstandard-extension moves from missed
to detected in both modes and the floor is raised accordingly, locking it in.
@lab700xdev
lab700xdev merged commit 9693002 into main Aug 19, 2026
2 checks passed
@lab700xdev
lab700xdev deleted the slice-110a-content-discovery branch August 19, 2026 03:13

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b0b631b8f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread aisbom/scanner.py
Comment on lines +343 to +349
elif self._sniff_is_pickle(full_path):
# Nothing claimed this file by name, but its bytes read as a
# pickle. This is the CVE-2025-1889 class: `config.p`, `weights.dat`,
# a file with no extension at all. The inspector below already
# decides what a file *is* from its contents rather than its
# suffix — only discovery was still trusting the name.
self.artifacts.append(self._inspect_pickle_variant(full_path))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply content discovery to remote targets

This fallback is only called from the local-file dispatcher, so the same bare pickle remains invisible when scanning remotely. In the scan() remote branch, direct HTTP targets are still dispatched solely by suffix, while resolve_huggingface_repo() filters unsupported suffixes before returning URLs; consequently, an hf:// repository or direct URL containing the CVE's config.p payload produces no artifact or error. Remote targets need an equivalent bounded content-sniffing path.

Useful? React with 👍 / 👎.

Comment thread aisbom/safety.py
Comment on lines +1194 to +1199
try:
for opcode, _arg, pos in pickletools.genops(io.BytesIO(data)):
parsed += 1
if opcode.name == "STOP":
stop_at = pos + 1
break

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recognize wrapped pickles before rejecting renamed files

When an attacker renames a supported wrapped format, such as a zlib-compressed joblib model or an object-array .npy, this opcode walk starts at the wrapper header and rejects the file even though _inspect_pickle_variant() can identify and scan those formats by content. For example, changing a malicious model.joblib to model.dat still leaves it undiscovered, preserving the same extension-confusion bypass this change is intended to close. The sniff should recognize supported container magics and route them to the variant inspector as well.

Useful? React with 👍 / 👎.

Comment thread aisbom/safety.py
Comment on lines +1203 to +1206
if stop_at is None:
# No complete pickle in view. Worth a bigger read only if something
# actually parsed; a file that yielded nothing is simply not a pickle.
return (False, parsed > 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat executable incomplete pickle streams as matches

Requiring a STOP opcode makes content discovery reject malformed streams that execute before failing. For example, the repository's nullifAI shape removes STOP after an os.system REDUCE and appends an invalid opcode; scan_pickle_stream() deliberately salvages and flags that payload, but head_looks_like_pickle() returns only (False, True), and _sniff_is_pickle() ultimately rejects it after reading the whole file. Naming that payload with an unlisted suffix therefore combines the two documented evasions and restores a zero-artifact scan; dangerous globals recovered from an incomplete stream should be enough to claim the file.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant