Views
No views yet
.tar deserialization RCE bypasses all three model scanners (modelscan / picklescan / fickling)pytorch_archive_tar (PyTorch legacy .tar serialization)
Vulnerability class: unsafe deserialization (pickle) RCE + model-scanner bypass
Loader: torch.load(path, weights_only=False) → torch._legacy_load → legacy_load (tar path)
Affected/verified scanner versions: modelscan 0.8.8, picklescan 1.0.5, fickling 0.1.12
Verified with: python 3.12.13, torch 2.13.0+cpu, numpy 2.5.1torch.load(..., weights_only=False) — and the malicious file is reported
clean by all three of the model scanners the ecosystem relies on
(modelscan, picklescan, fickling).torch.load dispatches a non-zip file to _legacy_load, which first calls
legacy_load(f). legacy_load opens the file with
tarfile.open(fileobj=f, mode="r:") and, if it is a valid POSIX tar, unpickles
the inner member named pickle with an unrestricted unpickler (an
UnpicklerWrapper subclass of the normal pickle.Unpickler; weights_only=True
is explicitly rejected for this format, so no safe path exists). Any
__reduce__ in that member runs at load time → RCE.pickle member:| Scanner | Version | Behavior on this file | Verdict |
|---|---|---|---|
| modelscan | 0.8.8 | unknown/invalid PyTorch magic → skips the file | No issues found |
| picklescan | 1.0.5 | tar handling is an unimplemented TODO → reads a flat pickle from offset 0 | Infected files: 0 / Dangerous globals: 0 |
| fickling | 0.1.12 | disassembles a pickle from offset 0 | safe (result0 = {}) |
}.. A tar member header begins with a 100-byte name field, so the first
bytes of the entire file become:7d 2e 00 ...0x7d is the pickle opcode EMPTY_DICT (}) and 0x2e is STOP (.). A
scanner disassembling a pickle from offset 0 therefore sees a complete,
harmless pickle that builds {} and halts — fickling prints result0 = {}
and reports safe; picklescan finds no dangerous globals. The decoy member is
ignored by legacy_load, which looks its members up by name
(storages, tensors, pickle).}. (0 bytes) decoy; its NAME field = pickle EMPTY_DICT+STOP at offset 0
storages (11 bytes) pickle(num_storages=0) + pickle(storage_views=[])
tensors (5 bytes) pickle(num_tensors=0)
pickle (86 bytes) attacker payload: __reduce__ -> exec(<marker write>)legacy_load reads storages and tensors (both benign, zero-count) and then
unpickles pickle with the unrestricted unpickler.1python build_poc.py # writes model.pt (10240 bytes)
2# scanners — all clean:
3modelscan -p model.pt
4picklescan -p model.pt
5fickling --check-safety model.pt
6# load — code executes:
7python -c "import torch; torch.load('model.pt', weights_only=False)"
8cat /tmp/TAR_BYPASS_POC.txt # -> pwned__reduce__ runs
exec("open('/tmp/TAR_BYPASS_POC.txt','w').write('pwned')") — it only writes a
marker file so execution can be proven. A real attacker substitutes any code.scan_results.txt)marker before torch.load : False
torch.load(...) returned : None
marker after torch.load : True
marker contents : 'pwned'modelscan 0.8.8 -> No issues found! (exit 3)
picklescan 1.0.5 -> Infected files: 0 / Dangerous globals: 0 (exit 0)
fickling 0.1.12 -> --check-safety exit 0 (safe); disassembly = "result0 = {}"fickling --check-safety <plain os.system pickle> -> exit 1 (flagged)
picklescan <plain os.system pickle> -> Infected files: 1 / Dangerous globals: 1.pt/.pth with
torch.load(..., weights_only=False) — the default for a very large amount of
existing loader code and tutorials — when the file is in the legacy tar format.
Because the file passes modelscan, picklescan, and fickling as clean, the usual
model-supply-chain scanning gates (Hugging Face / CI model scanners, pre-load
validation) provide no protection. Full RCE: data theft, credential/secret
exfiltration, lateral movement, cryptomining, model/weight tampering.pytorch_archive_zip scanner
desyncs (dual-central-directory 648d1f35, dup-central-directory / data.pkl
desync e892a7d5). Those target the ZIP container (the modern
_use_new_zipfile_serialization=True format) and exploit ZIP central-directory
parsing differences. This finding targets the legacy TAR serialization
(legacy_load's tarfile path) and a different scanner-blind mechanism (the
decoy }. tar-member-name pickle at offset 0 + unimplemented tar parsing in the
scanners). Different container, different loader path, different bypass.