PoC: fickling 0.1.12 HuggingFace scanner β pickle+EOCD polyglot routes malicious pickle to the zip-member scanner β reported CLEAN while it executes
Target tool: trailofbits/fickling β pickle static analyzer / scanner
Affected version tested: fickling 0.1.12 (latest on PyPI), Python 3.12, installed into a fresh venv
Affected surface: fickling --huggingface REPO / fickling.cli._scan_huggingface (the Hub repo scanner), driven by fickling.polyglot.find_file_properties
Vulnerability class: CWE-436 (Interpretation Conflict) / container-format-detection confusion β scanner evasion β arbitrary code execution on load
Impact: A raw malicious pickle with a 22-byte empty-ZIP End-Of-Central-Directory record appended is reported by fickling's HF scanner as No obvious safety issues detected (EXIT_CLEAN, exit 0), while pickle.load / torch.load (legacy) still execute the attacker's os.system(...).
This is the SAME structural container-confusion class that defeats picklescan's zip EOCD trailer routing, generalized to a different scanner (fickling). It is distinct from the other filed fickling finding (the aliased _os.popen allowlist bypass of check_safety) β that bug is in pickle-opcode analysis; this one never reaches pickle analysis at all because the file is misrouted to the ZIP member scanner.
Root cause
fickling/polyglot.py:201 computes the routing property with the standard library helper:
is_standard_zip = zipfile.is_zipfile(file) # True if an EOCD signature appears ANYWHERE in the file
zipfile.is_zipfile() scans for the 4-byte EOCD signature PK\x05\x06 anywhere in the file, so appending an empty-zip EOCD (22 bytes) to any file makes is_standard_zip == True, even though the file is a raw pickle with no ZIP local headers / central directory entries.
fickling/cli.py::_scan_huggingface then routes on that property, checking zip before pickle:
props = find_file_properties(local_path)
is_zip = props["is_torch_zip"] or props["is_standard_zip"] or ext in HF_ZIP_PICKLE_EXTENSIONS
if is_zip:
member_results = scan_zip_archive(local_path, ...) # <-- WINS for the polyglot
elif props["is_valid_pickle"] or ext in HF_RAW_PICKLE_EXTENSIONS:
file_results = [scan_file(local_path, ...)] # <-- the raw-pickle analyzer, never reached
scan_zip_archive (in fickling/loader.py) opens the file with RelaxedZipFile and only analyzes archive members whose extension is .pkl/.pickle/.bin. For the polyglot, the appended empty-EOCD central directory has zero members, so infolist() is empty β nothing is scanned β the scanner returns an empty result set β overall_safe stays True.
The malicious pickle bytes sit in the file before the EOCD, are never inside a real ZIP member, and are therefore never handed to scan_file / check_safety.
Note: is_standard_zip is checked before is_valid_pickle. fickling's own identify_pytorch_file_format() checks is_valid_pickle first and correctly labels the same polyglot as legacy PyTorch/pickle β so the defect is specifically the routing precedence in _scan_huggingface combined with the EOCD-anywhere zipfile.is_zipfile.
Why the real loader still executes
The pickle machine stops at the STOP (.) opcode; any trailing bytes (the appended EOCD) are ignored. So:
pickle.load(open("mal_poly.pkl","rb"))β executesos.system("touch /tmp/...").- For a legacy (non-zip)
torch.savefile + EOCD,torch.load(..., weights_only=False)takes its legacy pickle path (first 4 bytes are the pickle magic, notPK\x03\x04, so torch's own strict_is_zipfileis False) and executes the payload.
Verified differential (all on current released versions)
| File | Real loader | fickling --hf |
modelscan 0.8.8 | picklescan 1.0.5 |
|---|---|---|---|---|
mal.pkl (plain) |
pickle.load RCE |
UNSAFE (exit 1) | CRITICAL | INFECTED |
mal_poly.pkl (pickle+EOCD) |
pickle.load RCE |
CLEAN (exit 0) β | CRITICAL (robust) | CLEAN (picklescan's own EOCD bug) |
mal_torch.bin (legacy torch) |
torch.load RCE |
UNSAFE (exit 1) | clean (unrelated legacy-multipickle miss) | INFECTED |
mal_torch_poly.bin (legacy+EOCD) |
torch.load RCE |
CLEAN (exit 0) β | clean | INFECTED (robust) |
The two β rows are the bypass. Crucially the bypass defeats a scanner that the OTHER tools catch: on mal_poly.pkl, fickling passes it while modelscan flags it CRITICAL; on mal_torch_poly.bin, fickling passes it while picklescan flags it INFECTED. So this is a genuine fickling-specific defense-evasion primitive, not a shared blind spot.
modelscan is robust to this trailer trick (it routes by file extension and scans .pkl bytes directly; its PyTorch path uses a strict first-4-bytes _is_zipfile), so the honest scope of this finding is fickling's HuggingFace scanner, not modelscan.
Reproduce
python -m venv venv && . venv/bin/activate
pip install "fickling[huggingface]"==0.1.12 # pulls torch + huggingface_hub
python build_poc.py # writes mal.pkl / mal_poly.pkl (pickle + empty-EOCD)
python build_torch_poly.py # writes mal_torch.bin / mal_torch_poly.bin (legacy torch + EOCD)
python verify_fickling.py # shows find_file_properties + routing verdict (CLEAN on polyglot)
python e2e_hf.py # drives the SHIPPED fickling.cli._scan_huggingface end-to-end
e2e_hf.py monkeypatches only the network layer (HfApi.repo_info / hf_hub_download) to serve the local file; every routing/scanning decision is fickling's real released code. Observed output:
CONTROL (plain malicious legacy-torch pickle): exit 1 "Potentially unsafe content detected!"
ATTACK (same payload + empty-zip EOCD trailer): exit 0 "No obvious safety issues detected"
The os.system payload here only runs touch /tmp/FICKLING_*_BYPASS_PROOF β a benign marker. Run in a throwaway sandbox.
Remediation
- In
_scan_huggingface, checkis_valid_picklebefore the zip branch, or additionally runscan_filewheneveris_valid_pickleis true even ifis_standard_zipis true (a file can be both). - Do not use
zipfile.is_zipfile()(EOCD-anywhere) as a container discriminator for untrusted input; require the ZIP local-file-header magic at offset 0 (as torch's strict_is_zipfiledoes), and/or reject files that are simultaneously a valid pickle and claim to be a zip. - When
scan_zip_archivefinds zero analyzable members in a file that also parses as a pickle, treat it as SUSPICIOUS rather than clean.
Dup-check
Distinct from: picklescan's zipfile.is_zipfile EOCD trailer bypass (different tool); the aliased _os.popen / stdlib-reexport allowlist bypass of fickling.check_safety (different mechanism β opcode analysis, not container routing); and fickling's local --check-safety path (which uses StackedPickle.load directly and is NOT affected). This report is specifically the --huggingface scanner's is_standard_zip-before-is_valid_pickle routing precedence combined with EOCD-anywhere detection.