PoC: duplicate data.pkl central-directory record — an HONEST-ZIP container desync that bypasses picklescan 1.0.5, modelscan 0.8.8 AND fickling 0.1.12 while torch.load executes
Targets (all current PyPI releases, verified 2026-07-20):
picklescan 1.0.5, modelscan 0.8.8, fickling 0.1.12
Real loader: torch.load(..., weights_only=False) — PyTorch 2.13.0+cpu (caffe2::serialize::PyTorchStreamReader / miniz)
Class: CWE-436 Interpretation Conflict — ZIP container member-name resolution divergence → scanner evasion → RCE on model load
Impact: A .pt/.bin PyTorch checkpoint that all three scanners certify CLEAN runs attacker os.system(...) the moment a victim runs the ordinary torch.load(weights_only=False).
Root cause — first-match (miniz) vs last-wins (CPython zipfile) on a duplicate member name
A new-format PyTorch checkpoint is a ZIP whose pickle lives in the record archive/data.pkl.
The PoC ZIP contains two central-directory records both named archive/data.pkl:
index 0 archive/data.pkl <- MALICIOUS pickle (__reduce__ -> os.system)
index 1 archive/.format_version
...
index 6 archive/data.pkl <- BENIGN pickle
EOCD: total-entries = 7 (matches the 7 physical central-dir records — see below)
- PyTorch (miniz) resolves
data.pklviamz_zip_reader_locate_file, which returns the FIRST matching record → reads index 0 (MALICIOUS) → executes it. - CPython stdlib
zipfilebuilds aNameToInfodict by iterating every central-directory record with unconditionalNameToInfo[name] = info, so a duplicate name is last-wins →zip.open("archive/data.pkl")returns index 6 (BENIGN).
All three scanners walk ZIP members with stdlib zipfile (picklescan/relaxed_zipfile.py,
modelscan.py:_iterate_models, fickling.loader.scan_zip_archive), so every one of them scans
the benign member and never sees the malicious data.pkl that miniz loads.
This ZIP is 100% standards-conformant — no forged fields
The EOCD "total entries" field equals the number of physical central-directory records (7 == 7),
no offsets are patched, no CRCs are faked, and every record parses cleanly. It is produced by two
plain ZipFile.writestr("archive/data.pkl", ...) calls. A scanner that validates the EOCD
entry-count, CRCs, or offsets still accepts it and is still bypassed — the desync is purely in how
a duplicate member name is resolved.
Verified differential (real released tools, one command each)
| File | torch.load(weights_only=False) |
picklescan 1.0.5 | modelscan 0.8.8 | fickling 0.1.12 |
|---|---|---|---|---|
dupcd_mal.pt (two data.pkl, mal-first) |
RCE — marker written | Infected: 0 CLEAN (exit 0) |
No issues found 🎉 (exit 0) |
LIKELY_SAFE |
control_mal.pt (single mal data.pkl) |
RCE | Infected: 1 DETECTED |
CRITICAL: 1 (exit 1) |
LIKELY_OVERTLY_MALICIOUS |
The control proves the payload is trivially detectable by every scanner in isolation — only the
duplicate-record ordering hides it. A reversed build (benign-first / mal-last) flips every verdict
(scanners flag it, torch.load runs the benign member), proving first-vs-last resolution is the
exact lever.
Reproduce
# loader side (needs torch)
python build_poc.py # writes dupcd_mal.pt / dupcd_mal.bin / control_mal.pt
python verify.py # honest-count check + zipfile-vs-torch.load divergence + RCE marker
# scanner side (picklescan==1.0.5 fickling==0.1.12 modelscan==0.8.8, Python 3.10-3.12)
python -m picklescan -p dupcd_mal.pt # Infected files: 0 (CLEAN)
modelscan -p dupcd_mal.pt # No issues found! 🎉
python -c "from fickling.loader import scan_zip_archive as s; print(s('dupcd_mal.pt'))" # LIKELY_SAFE
Captured runs are in scan_results.txt.
Honest precondition
weights_only=False (PyTorch >= 2.6 defaults to True, an orthogonal mitigation). This is the same
precondition the entire picklescan/modelscan/fickling threat model assumes, and it remains pervasive
in training-resume, from_pretrained, and legacy checkpoint call sites.
Dedup / relationship to prior work
- Distinct from the filed EOCD-trailer and PK\x05\x06-prefix container-routing bypasses:
those defeat the front-blind
zipfile.is_zipfilerouter; this one is a member-resolution divergence inside a fully valid ZIP that every router agrees is a torch zip. - Related to an internal backlog note on a modelscan-only
.ptzipfile-vs-miniz differential (pt-zip-differential). This finding is materially stronger and corrects it:- Honest EOCD entry-count — the prior modelscan variant forged the EOCD "total entries" to
stop miniz early and asserted "honest count → both benign / no divergence". That is false:
with a correctly-counted, standards-conformant ZIP, miniz still first-wins and
zipfilestill last-wins, so the divergence persists without any forgery. This defeats count-validating scanners the forged variant would not. - Three scanners, not one — the prior scoped the differential to modelscan and claimed it
did not generalize; picklescan 1.0.5 and fickling 0.1.12 use stdlib
zipfiletoo and are equally bypassed, demonstrated here end-to-end.
- Honest EOCD entry-count — the prior modelscan variant forged the EOCD "total entries" to
stop miniz early and asserted "honest count → both benign / no divergence". That is false:
with a correctly-counted, standards-conformant ZIP, miniz still first-wins and
Fix
Reject any archive containing duplicate member names (both scanners and torch.load should refuse
a checkpoint with two data.pkl records), or resolve member names with the same first-match rule
miniz uses so the scanned bytes equal the loaded bytes.