PoC: dual central-directory container desync β a valid PyTorch checkpoint whose data.pkl is read from a DIFFERENT central directory by miniz (torch) than by stdlib zipfile (every scanner), bypassing picklescan 1.0.5, modelscan 0.8.8 and fickling 0.1.12 while torch.load executes it
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 central-directory location divergence β scanner evasion β RCE on model load
Impact: A .pt/.bin checkpoint that all three scanners certify CLEAN runs attacker os.system(...) the moment a victim runs an ordinary torch.load(weights_only=False).
Root cause β miniz anchors the central directory at cd_ofs (forward); stdlib zipfile anchors it at EOCD β size_cd (backward)
The PoC is a valid ZIP that contains two physically-separate central directories of equal byte-size S:
[ local file headers + member data ]
[ MAL cd ] @ file offset cd_ofs <- malicious data.pkl record (+ aux records)
[ BEN cd ] @ file offset (EOCD_pos β S) <- benign data.pkl record (+ aux records)
[ EOCD ] cd_ofs = MAL cd , size_cd = S , entries = 6
- PyTorch / miniz (
mz_zip_reader_read_central_dir) parses exactlyentriescentral records starting at the EOCDcd_ofsfield, and only validatescd_ofs + size_cd <= filesize. It does not require the central directory to end at the EOCD. β it parses MAL cd βmz_zip_reader_locate_file("archive/data.pkl")β malicious pickle βos.systemruns. - CPython stdlib
zipfile(_RealGetContents) computesconcat = EOCD_pos β size_cd β cd_ofsand seeks tostart_dir = cd_ofs + concat = EOCD_pos β size_cd, i.e. it anchors the directory to thesize_cdbytes immediately before the EOCD (its self-extracting-archive / "prepended junk" support). β it parses BEN cd β benign pickle.
picklescan (RelaxedZipFile), modelscan and fickling all enumerate members with stdlib
zipfile, so all three scan BEN cd and never see the malicious data.pkl that miniz loads.
Structural proof (from verify.py)
[struct] entries=6 size_cd=403 cd_ofs=814 eocd_pos=1620
[struct] miniz reads CD at cd_ofs=814; stdlib reads CD at eocd-size_cd=1217 <-- different directories
[stdlib] data.pkl -> BENIGN
[torch.load] RCE marker present: True
Verified differential (real released tools)
| File | torch.load(weights_only=False) |
picklescan 1.0.5 | modelscan 0.8.8 | fickling 0.1.12 |
|---|---|---|---|---|
dualcd_mal.pt (mal in miniz cd, benign in stdlib cd) |
RCE β marker written | Dangerous globals: 0, exit 0 CLEAN |
No issues found π, exit 0 |
LIKELY_SAFE |
control_mal.pt (single malicious data.pkl) |
RCE | Infected: 1 DETECTED |
CRITICAL: 1 (posix.system) |
LIKELY_OVERTLY_MALICIOUS |
reverse.pt (benign in miniz cd, malicious in stdlib cd) |
benign β no marker | Infected: 1 DETECTED |
DETECTED | LIKELY_OVERTLY_MALICIOUS |
The negative control proves the payload is trivially detectable in isolation. The reverse
control swaps which directory holds the malicious record and flips every verdict β direct proof
that miniz resolves data.pkl via cd_ofs while every scanner resolves it via EOCD β size_cd.
Reproduce
# loader side needs torch; scanner side needs picklescan==1.0.5 modelscan==0.8.8 fickling==0.1.12 (Python 3.10-3.12)
python build_poc.py # writes dualcd_mal.pt / .bin, control_mal.pt, reverse.pt (self-contained)
python verify.py # structural proof + torch RCE + all three scanners clean
python -m picklescan -p dualcd_mal.pt # Dangerous globals: 0 -> exit 0 (CLEAN)
modelscan -p dualcd_mal.pt # No issues found π
python -c "from fickling.loader import scan_zip_archive as s; print(s('dualcd_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 / distinctness vs prior container-desync findings
This is a distinct fourth mechanism, not a variant of the three previously filed:
- EOCD-trailer / PK\x05\x06-prefix bypasses defeat the front/back-blind
zipfile.is_zipfilerouter on a file that is not really a torch zip. This PoC is a fully valid torch zip every router agrees on; the divergence is inside central-directory parsing. - duplicate-
data.pkl(dupcd) uses one central directory containing two records of the same name and exploits first-match (miniz) vs last-wins (NameToInfo) resolution. This PoC uses two separate central directories and needs no duplicate name within a directory; the lever is the anchor point of the whole directory βcd_ofs(miniz, forward, count-based) vsEOCD β size_cd(stdlib, backward, concat-based). A scanner hardened to reject duplicate member names (the dupcd fix) is still fully bypassed here. - pt-zip-differential (a modelscan-only note that forged the EOCD entry-count to stop miniz early)
is superseded: here the entry-count and
size_cdare honest for the miniz interpretation, the split comes purely from the two anchor points, and all three scanners are bypassed end-to-end.
Fix
Require the central directory to end at the EOCD (cd_ofs + size_cd == EOCD_position) and reject
archives containing more than one central directory / any bytes between the directory and the EOCD, in
both the scanners and PyTorchStreamReader. Equivalently, have scanners resolve members with the same
cd_ofs-anchored walk miniz uses so the scanned bytes equal the loaded bytes.