Skip to content

v1.3.2 — Payloads can't hide behind a filename

Latest

Choose a tag to compare

@lab700xdev lab700xdev released this 19 Aug 03:18

Heads up

Your SBOM may gain components. Five serialization extensions that were previously skipped are now scanned, and files carrying no recognized extension are now opened when their bytes are a pickle. A repo containing either will produce more components than before and may newly exit 2.

Nothing that was already scanned changes verdict: the old-vs-new differential across the bypass corpus and 134 fixture verdicts is empty in both modes.

Security — a payload can no longer hide behind its filename

Discovery decided what to open from a file's suffix alone. That is the whole of CVE-2025-1889: a pickle named config.p — or weights.dat, or a file with no extension at all — was never opened, so the scan reported No AI models found and exited 0. A clean bill of health on a directory carrying a reverse shell.

Files that nothing claims by name are now identified by content.

Adding .p to the extension list was deliberately rejected. The technique is any unexpected suffix — the attacker picks the name — so that would have flipped our published scorecard case while protecting nobody.

Two details worth stating, because they are where this kind of check usually goes wrong:

  • It validates the pickle stack, not just the opcode syntax. . is the STOP opcode, so a weaker "does it reach STOP" rule treats every stylesheet opening with a class selector as a one-opcode pickle. Measured against a real node_modules of 5,123 files, that rule claimed 11: seven JavaScript files, a TypeScript declaration, a stylesheet and two man pages. Those shapes are now regression tests.
  • Validation runs on the prefix ending at the first STOP, not the whole buffer — so a payload followed by a corrupt tail (the nullifAI shape) is still caught rather than thrown out along with its own garbage.

Reads are bounded: 64KB per unclaimed file, escalating only for a stream that parses cleanly and has not yet ended, capped at the same budget the inspector itself uses. A walk over that tree takes 1.52s and claims nothing.

Known limit, stated rather than hidden: a pickle whose first opcode carries an argument larger than that entire budget is not discovered by content.

Bypass scorecard: 8/11 → 9/11

cve-2025-1889-nonstandard-extension moves from missed to detected in both scan modes, and the regression floor is raised to lock it in. Run it yourself:

aisbom bypass-scorecard

The two remaining ⚠️ partials are documented, not accidental: a 7z-repacked model is refused by container rather than by payload (unpacking it would put a native dependency in every install), and the ShadowPickle allowlist-abuse case is indistinguishable from an ordinary OrderedDict checkpoint by static analysis.

New formats scanned

  • joblib, dill, NumPy object arrays and bare pickles.pkl, .pickle, .joblib, .dill, .npy, .npz. Every one is a pickle stream underneath, carrying exactly the arbitrary-code-execution risk of a .pt. .pkl is the sharpest case: the README has always documented aisbom scan model.pkl --strict, and that command scanned nothing.
  • joblib's compression is opened, whichever codec it chose — zlib, gzip, bz2, lzma/xz and the legacy ZF container, all via the standard library. lz4 and zstd are named but not opened (MEDIUM (Unscanned Container: lz4)), on the same reasoning as 7z. No runtime dependency is added: joblib, dill and numpy are not imported by the scanner.
  • dill's code-reconstruction globals are flagged_create_function, _create_code, _import_module, _get_attr are CRITICAL. dill's type and array helpers are deliberately not flagged, so a .dill holding only data still scans clean.

Pickle detection

  • A raw array block no longer ends the scan. joblib splices raw buffers mid-pickle, which stopped disassembly ~226 bytes into a 552-byte file while the payload sat at byte 516. Unreachable bytes now get a second pass that recovers globals directly.
  • A .npy header no longer decides whether to look. descr is attacker-supplied, so a pickle behind a header claiming '<f8' would have been a one-line evasion.
  • STACK_GLOBAL operands arriving via the pickle memo resolve correctly — reading only literals resolved numpy.dtype as dtype.dtype, a strict-mode false positive on every ordinary joblib model.
  • .npz members are read even when the archive fights back — a tampered CRC or header name goes through the same raw local-header read the PyTorch path uses.
  • No scan limit ends in a clean verdict. Every bound that leaves bytes unexamined now reports MEDIUM (Pickle Scan Incomplete) rather than passing a prefix off as the whole file.

Fixed

  • hf:// scans resolve the new formats. The resolver's extension list is now derived from the scanner's own dispatch sets rather than restated, so the two cannot drift again.
  • The decompression budget follows the read budget. It came from a default argument, which binds once at import — so a remote scan that fetched 2MB could expand 16MB.

What's not changing

Exit codes, output formats, and the CycloneDX/SPDX schemas are identical to v1.3.1. No new runtime dependencies.