Your fine-tuned weights are a 140 GB binary that someone downloaded from the internet, trained on data from three vendors, and mounted into a pod with a cloud credential attached. Almost every other artifact in your stack — containers, Python wheels, npm packages — has a scanning, signing and provenance story. Model checkpoints usually have none.
That gap is now the interesting one for attackers. Malicious checkpoints have been found on public model hubs; pickle-based formats execute arbitrary code at load time by design; and a "just pull the latest community fine-tune" habit puts unreviewed code inside your training cluster. This tutorial is the practical version of fixing that: how PyTorch checkpoint formats actually behave, how to load untrusted weights safely, how to scan and pin what you pull, and what an auditable model supply chain looks like in a regulated enterprise.
Why a checkpoint is executable code
torch.save historically wrote a ZIP archive containing a Python pickle stream. Unpickling is not parsing — it is running a tiny stack machine that can call __reduce__ on arbitrary objects, which is enough to spawn a shell.
The one-line demo that makes security review land:
import os, pickle, torch
class Evil:
def __reduce__(self):
return (os.system, ("echo pwned; env | grep -i token",))
torch.save({"state_dict": {}, "meta": Evil()}, "evil.pt")
# The "innocent" consumer:
torch.load("evil.pt", weights_only=False) # -> executes os.system at load
Nothing about that file looks unusual. It loads a state dict, it has plausible metadata, and it runs a command with whatever environment your training job has — which in most clusters means cloud credentials, an S3 bucket and a registry token.
Since PyTorch 2.6, torch.load defaults to weights_only=True, which uses a restricted unpickler that only reconstructs tensors and simple containers. That default fixed the most common footgun, but it does not fix everything:
- Plenty of internal code, older notebooks, and third-party libraries still pass
weights_only=Falseexplicitly, because a checkpoint contains an optimizer state, a config object or ann.Modulepickled whole. - Pinned older PyTorch versions in long-lived training images still default to the unsafe behaviour.
weights_only=Trueblocks code execution, not malicious content: a backdoored set of weights loads perfectly safely and misbehaves only on a trigger input.
Rule 1: prefer safetensors, and treat .pt/.bin as suspect
safetensors is a flat format — a JSON header of tensor names, dtypes and shapes, followed by raw bytes. There is no code path that can execute anything, and it memory-maps, so it loads faster too.
from safetensors.torch import save_file, load_file
save_file(model.state_dict(), "model.safetensors")
state = load_file("model.safetensors") # no pickle, no exec
model.load_state_dict(state)
Converting a legacy checkpoint you already trust:
import torch
from safetensors.torch import save_file
sd = torch.load("legacy.pt", map_location="cpu", weights_only=True)
sd = sd.get("state_dict", sd)
# safetensors rejects shared storage; clone to break aliasing
sd = {k: v.clone().contiguous() for k, v in sd.items()}
save_file(sd, "model.safetensors", metadata={"format": "pt", "source": "legacy.pt"})
Policy worth adopting verbatim: inference artifacts are safetensors only. Training checkpoints that must carry optimizer state stay in torch.load format but never cross a trust boundary — they are written and read by the same pipeline, in your own bucket, and never fetched from a hub.
For distributed training state, torch.distributed.checkpoint (DCP) writes sharded checkpoints that you should treat with the same rule: internal-only, never ingested from an outside party.
Rule 2: if you must unpickle, sandbox and allow-list
When a vendor ships a .pt you genuinely need, load it in a throwaway process with no credentials and no network, then re-emit safetensors:
# convert.py — run inside a locked-down container
import torch, sys
from safetensors.torch import save_file
sd = torch.load(sys.argv[1], map_location="cpu", weights_only=True)
save_file({k: v.clone().contiguous() for k, v in sd.items()}, sys.argv[2])
docker run --rm --network none \
--read-only --tmpfs /tmp \
--cap-drop ALL --security-opt no-new-privileges \
-v "$PWD/in:/in:ro" -v "$PWD/out:/out" \
-e AWS_ACCESS_KEY_ID= -e AWS_SECRET_ACCESS_KEY= \
ml-convert:2.9 python /convert.py /in/vendor.pt /out/vendor.safetensors
--network none is the important flag: even a successful exploit has nowhere to send what it finds. If a checkpoint really requires weights_only=False, narrow it with an explicit allow-list rather than turning the guard off globally:
import torch
from omegaconf.dictconfig import DictConfig
with torch.serialization.safe_globals([DictConfig]):
ckpt = torch.load("vendor.pt", map_location="cpu", weights_only=True)
That permits exactly the config class the checkpoint needs and nothing else, and it fails loudly when a new release smuggles in a different type.
Rule 3: scan and inspect before loading anything
A cheap pre-flight check catches the obvious cases. You can read a pickle's opcodes without executing them:
import pickletools, zipfile, io
SUSPECT = {b"posix", b"nt", b"os", b"subprocess", b"builtins", b"runpy", b"sys"}
def audit_pt(path):
findings = []
with zipfile.ZipFile(path) as z:
for name in z.namelist():
if not name.endswith(".pkl"):
continue
data = z.read(name)
for op, arg, _ in pickletools.genops(io.BytesIO(data)):
if op.name in ("GLOBAL", "STACK_GLOBAL", "REDUCE", "INST", "OBJ"):
findings.append((name, op.name, arg))
return [f for f in findings
if f[2] is None or any(s in str(f[2]).encode() for s in SUSPECT)]
print(audit_pt("suspect.pt"))
And for safetensors, verify the header describes what you expect before trusting the file's name:
import json, struct
def safetensors_header(path):
with open(path, "rb") as f:
n = struct.unpack("<Q", f.read(8))[0]
return json.loads(f.read(n))
hdr = safetensors_header("model.safetensors")
print(len(hdr), "entries")
print({k: v["shape"] for k, v in list(hdr.items())[:5] if k != "__metadata__"})
In CI, pair this with an off-the-shelf scanner (picklescan, ModelScan, or your hub's built-in scanning) as a blocking gate. Home-rolled opcode checks are a filter, not a proof — treat them as defence in depth alongside sandboxed conversion.
Rule 4: pin, hash and sign
"We fine-tuned Llama-3.1-8B-Instruct" is not a provenance record. A hub revision can be force-pushed; main is a moving target.
from huggingface_hub import snapshot_download
path = snapshot_download(
"meta-llama/Llama-3.1-8B-Instruct",
revision="0e9e39f249a16976918f6564b8830bc894c89659", # immutable commit SHA
allow_patterns=["*.safetensors", "*.json", "tokenizer*"],
)
Then record what you actually got, and verify it on every load:
import hashlib, json, pathlib
def digest(p, chunk=1 << 22):
h = hashlib.sha256()
with open(p, "rb") as f:
for b in iter(lambda: f.read(chunk), b""):
h.update(b)
return h.hexdigest()
manifest = {p.name: digest(p) for p in sorted(pathlib.Path(path).glob("*.safetensors"))}
pathlib.Path("model.manifest.json").write_text(json.dumps(manifest, indent=2))
Mirror the pinned revision into your own artifact store or registry, and have training and serving read from the mirror, never from the public hub. That kills three problems at once: a hub outage stops being a production incident, a silent upstream re-upload cannot change your weights, and you finally have a byte-exact answer to "what is running?"
For the signing layer, treat the model like a container image: sign the artifact and its manifest with Sigstore/cosign, store the signature next to it, and verify at deploy time. OCI registries will happily hold model artifacts, which means you can reuse the admission-control machinery you already have for images.
Rule 5: an SBOM for the model, not just the code
Ship a small, boring metadata file alongside every model you produce. Enterprise reviewers ask these questions eventually, and answering them from memory six months later is impossible:
model: intellisensei/claims-extractor
version: 2026.03.1
base_model:
id: meta-llama/Llama-3.1-8B-Instruct
revision: 0e9e39f249a16976918f6564b8830bc894c89659
license: llama3.1-community
format: safetensors
artifacts:
- name: model.safetensors
sha256: 7f3c...
training_data:
- source: internal/claims-2024-2025
records: 184203
pii_review: redacted-2026-02-11
- source: vendor/synthetic-augmentation-v3
license: cc-by-4.0
frameworks:
torch: 2.9.1
transformers: 4.57.0
evals:
field_f1: 0.947
refusal_rate: 0.004
harness_commit: 1c9ab42
approved_by: ml-platform@, security@
Generate it in the training job, not by hand. If your eval harness already emits metrics (and it should), this file is mostly assembly.
Rule 6: the risks weights_only does not cover
Format safety is table stakes. The remaining exposure lives in behaviour:
- Backdoored weights. A model can be trained to behave normally except on a trigger phrase. Mitigation is behavioural, not structural: run your own eval suite on every third-party checkpoint, including adversarial and trigger-probing prompts, and prefer base models from a small set of sources you would defend in an audit.
- Poisoned fine-tuning data. Scraped or vendor-supplied data is an injection vector into weights. Track dataset provenance with the same rigour as model provenance, and hash dataset snapshots.
- Custom code in the loader.
trust_remote_code=Trueexecutes Python from the model repo. Vendor that code into your own repo, review it, pin it — do not let a hub revision change what runs in your cluster. - Adapters are code-adjacent too. LoRA adapters are small and get traded casually; the same format, pinning and eval rules apply. A multi-tenant serving stack that hot-loads adapters from a shared bucket needs write controls on that bucket.
- Data exfiltration at inference. Tool-calling agents with network access are a live egress path. Egress allow-lists and per-tool credential scoping belong in the same review as the checkpoint.
A checklist you can drop into a review
- Inference artifacts are
safetensors;.pt/.binare blocked at the serving boundary. - No
weights_only=Falsein the codebase without a named owner and asafe_globalsallow-list. - PyTorch pinned at 2.6+ everywhere
torch.loadruns, so the safe default applies. - Third-party checkpoints converted in a
--network none, credential-free sandbox. - Pickle scanning is a blocking CI gate on any ingested artifact.
- All external models pinned to immutable commit SHAs, mirrored internally, never pulled from a hub at deploy time.
- SHA-256 manifest recorded at build and verified at load.
- Artifacts signed; deploy verifies signatures.
- Model SBOM emitted automatically, including base model, licences and data sources.
-
trust_remote_codevendored and reviewed, never enabled against a live hub. - Every third-party checkpoint passes your own eval harness before it reaches staging.
Most of this is a week of platform work, and it is the difference between "we downloaded a model" and "we can prove what is running, where it came from, and that nothing executed on the way in." If you are heading into a security review or a regulated deployment and your checkpoints are still unpinned .pt files pulled at container start, that is the thing to fix first — and it is exactly the kind of hardening our team does alongside model development.