| 1 | --- |
| 2 | archetype: ml-model-loaders |
| 3 | headline: true |
| 4 | primary_gate_families: [1, 7] |
| 5 | --- |
| 6 | |
| 7 | # Archetype - ML Model Loaders & Inference Runtimes (HEADLINE) |
| 8 | |
| 9 | **This is the highest-yield archetype for Cyclops v2.** The thesis: model files are *executable |
| 10 | attack surface masquerading as data*. The entire ML ecosystem ships parsers and deserializers that |
| 11 | run at **load time**, before any inference, often in C/C++ with weak bounds checking, written fast, |
| 12 | shipped weekly, and consumed by millions who treat a downloaded `.gguf` / `.safetensors` / `.pt` / |
| 13 | `.onnx` as inert. It is not inert. A model is a program. |
| 14 | |
| 15 | Two distinct kill-chain classes live here and you must hunt both: |
| 16 | - **(A) Deserialization-as-design** - formats that *intentionally* execute code (pickle, joblib, |
| 17 | arbitrary Python in custom nodes). The bug is the trust, not a memory error. |
| 18 | - **(B) Parser memory-corruption** - formats that *claim* to be safe data (GGUF, safetensors, ONNX |
| 19 | protobuf, NPY) but whose C/C++ parsers have OOB read/write, integer overflow, type confusion. The |
| 20 | bug is in the loader, and the oracle is ASan. |
| 21 | |
| 22 | See: [[../00-doctrine/SEVERITY-GATE#family-1-remote-code-execution-the-crown|Gate Family 1]] · |
| 23 | [[memory-unsafe-parsers]] (sister archetype for the pure-parser fuzzing mechanics) · |
| 24 | [[ai-agent-mcp]] (model loaders reached *over the network* via inference servers). |
| 25 | |
| 26 | --- |
| 27 | |
| 28 | ## Principals & trust boundaries |
| 29 | |
| 30 | - **Principals:** the model *author/publisher* (untrusted - anyone on HuggingFace Hub, Civitai, an |
| 31 | npm/PyPI package, a torrent), the *runtime/library* (the thing parsing the file), the *host user* |
| 32 | (whose process and credentials the load runs as). |
| 33 | - **The core trust boundary:** *"downloaded model file → load function"*. Every user who does |
| 34 | `from_pretrained(...)`, `llama_load_model_from_file(...)`, `onnxruntime.InferenceSession(...)`, |
| 35 | `torch.load(...)`, `joblib.load(...)`, `comfyui` "drag this workflow in" - crosses it. |
| 36 | - **Why it's so wide:** model sharing is the *entire culture* of the field. The file is meant to be |
| 37 | passed around. The trust boundary is crossed billions of times a day and almost no consumer thinks |
| 38 | of the file as code. |
| 39 | |
| 40 | **Boundary map (fill this per target):** |
| 41 | ``` |
| 42 | principals: [model publisher (untrusted)] → [loader lib] → [host process / user creds] |
| 43 | trust inputs: .gguf | .safetensors | .pt/.pth | .onnx | .npy/.npz | .pkl | .joblib | |
| 44 | .bin (HF) | workflow.json (ComfyUI) | model_index.json | tokenizer.json | |
| 45 | config.json | *.gradio cache | adapter_config.json (PEFT/LoRA) |
| 46 | enforcement pts: header magic check, version gate, "weights_only" flag, allowlist of globals, |
| 47 | tensor-count/dim sanity checks, mmap bounds, protobuf size limits |
| 48 | cross-boundary |
| 49 | sinks: pickle.Unpickler.find_class / __reduce__ ; numpy buffer parse ; |
| 50 | memcpy/alloca from file-controlled length ; ggml tensor dim multiply ; |
| 51 | protobuf arena alloc ; eval/exec in custom node ; os.system in workflow |
| 52 | confusion |
| 53 | hypotheses: "weights_only=True is actually bypassable here"; "the header length field is |
| 54 | trusted before the file size is checked"; "n_dims * n_elements overflows size_t"; |
| 55 | "the allowlist misses one __reduce__-able global"; "the safe format has an |
| 56 | unsafe metadata side-channel" |
| 57 | ``` |
| 58 | |
| 59 | --- |
| 60 | |
| 61 | ## Kill chains (each lands on a Gate family) |
| 62 | |
| 63 | ### KC-1 - Pickle / `__reduce__` deserialization → RCE (Class A) |
| 64 | The classic and *still* live. `pickle` calls `__reduce__` on unpickling, which can return |
| 65 | `(os.system, ('id',))`. PyTorch `.pt`/`.pth` are zip files containing a pickle. `joblib`/sklearn |
| 66 | `.pkl`, many HF `.bin` legacy weights, are pickles. |
| 67 | - **Sinks:** `pickle.load`, `pickle.Unpickler.find_class`, `torch.load(..., weights_only=False)`, |
| 68 | `joblib.load`, `numpy.load(..., allow_pickle=True)`, `dill.load`, `cloudpickle.load`. |
| 69 | - **The fresh angle (novelty, not sibling-CVE):** everyone knows raw pickle is dangerous. The novel |
| 70 | bugs are in the **allowlist bypasses** of the "safe" loaders: `weights_only=True` (PyTorch's |
| 71 | defense) has had repeated bypasses (e.g. via allowed globals that themselves reach dangerous |
| 72 | reductions; via `torch.storage` / rebuild functions; via nested types the allowlist forgot). Hunt |
| 73 | the *allowlist*, the `_get_allowed_globals` table, the `GLOBAL`/`REDUCE` opcode handling - find one |
| 74 | reachable dangerous global the allowlist trusts. |
| 75 | - **Oracle:** loading the crafted model fires a callback / writes `/tmp/cyclops-$RAND` → Family 1. |
| 76 | - **CVE class to cite & avoid duplicating:** CVE-2025-class PyTorch `weights_only` bypasses, |
| 77 | `numpy.load` allow_pickle RCE, the perennial sklearn/joblib pickle RCE. Check the feed first. |
| 78 | |
| 79 | ### KC-2 - GGUF / GGML parser memory-corruption → RCE (Class B) *(prime hunting ground)* |
| 80 | `llama.cpp` and everything built on it (Ollama, LM Studio, text-generation-webui, countless wrappers) |
| 81 | parse GGUF with hand-written C. The format has a header → metadata KV array → tensor-info array → |
| 82 | tensor data, all with **file-controlled length and count fields**. |
| 83 | - **Sinks to CPG-trace:** `gguf_init_from_file`, `gguf_read_emplace`, the KV-pair reader |
| 84 | (`gguf_kv` / `gguf_get_arr_*`), `ggml_new_tensor`, `ggml_nbytes`, `ggml_row_size`, and every |
| 85 | `malloc`/`GGML_MALLOC`/`memcpy` whose size derives from a file field. |
| 86 | - **Bug shapes:** integer overflow in `n_dims * ne[i] * type_size` → undersized alloc → heap |
| 87 | overflow on tensor copy; an array `n` count read before validating it against remaining file size → |
| 88 | OOB read; a metadata string length field trusted into a `memcpy`/`malloc`; type/enum confusion in |
| 89 | `ggml_type` indexing a per-type size table OOB. |
| 90 | - **Oracle:** build `llama.cpp` (or the wrapper) with `-fsanitize=address`, feed the crafted `.gguf`, |
| 91 | get the ASan report with a controlled fault → Family 1 (SOURCE/REPLICA). Escalate to control of |
| 92 | RIP/allocations for the full RCE claim. |
| 93 | - **CVE class:** the GGUF heap-overflow / OOB family (multiple 2024 llama.cpp advisories). Novelty = |
| 94 | a *different* field or a wrapper that re-implemented the parse and reintroduced the class. |
| 95 | |
| 96 | ### KC-3 - safetensors metadata / header parse (Class B, the "safe format" trap) |
| 97 | `safetensors` was designed to *be the safe alternative* to pickle - no code execution by design. That |
| 98 | makes it the perfect place for a novel bug, because everyone assumes it can't have one. |
| 99 | - **Surface:** the 8-byte little-endian header length, then a JSON header describing each tensor's |
| 100 | `dtype`, `shape`, and **byte offsets** `[begin, end]` into the buffer. The Rust core is memory-safe, |
| 101 | but: (a) the **offset/shape arithmetic** - does `begin..end` get validated against the buffer and |
| 102 | against `shape × dtype_size`? An offset overflow or a mismatch can yield OOB; (b) **language ports** - |
| 103 | C++/Zig/Go re-implementations and the C ABI consumers don't get Rust's guarantees; (c) the JSON |
| 104 | metadata fed to a *downstream* consumer that does something unsafe with it. |
| 105 | - **Sinks:** `SafeTensors::deserialize`, the offset-validation routine, and any C/C++ reimpl's |
| 106 | `memcpy(dst, base+begin, end-begin)`. |
| 107 | - **Oracle:** ASan crash on a port, or a proven OOB read returning out-of-tensor bytes. |
| 108 | |
| 109 | ### KC-4 - ONNX / protobuf model graph → parse corruption or operator abuse (Class B) |
| 110 | ONNX Runtime parses a protobuf graph then *builds and runs operators*. Two sub-surfaces: |
| 111 | - **Protobuf/loader corruption:** `onnx::ModelProto` parse, external-data references (an ONNX model |
| 112 | can point at an *external* data file path - path traversal / arbitrary file read sink), and the |
| 113 | shape-inference pass (`onnxruntime::...ShapeInference`) doing arithmetic on attacker dims. |
| 114 | - **Operator-level:** custom ops, the `Scan`/`If`/`Loop` control-flow ops, and ops that allocate from |
| 115 | attacker-controlled dims. ORT has a real history of heap overflows in op kernels. |
| 116 | - **External-data trap (Family 7):** the `external_data` location field can be a path → arbitrary |
| 117 | file read at load. Cite the ONNX external-tensor path-traversal class. |
| 118 | - **Sinks:** `onnxruntime::Model::Load`, `LoadExternalTensor`, op `Compute()` kernels with dim-derived |
| 119 | allocs. **Oracle:** ASan crash, or the external-data path reading our canary file → Family 7. |
| 120 | |
| 121 | ### KC-5 - ComfyUI custom nodes / workflow.json → RCE (Class A, social) |
| 122 | ComfyUI workflows are shared as JSON/PNG-embedded graphs that reference **custom nodes** (arbitrary |
| 123 | Python packages auto-installed from a registry). "Load this cool workflow" → pulls a custom node → |
| 124 | runs its `__init__` / node code. |
| 125 | - **Sinks:** the custom-node loader (`nodes.py` import path), any node calling `eval`/`exec`/ |
| 126 | `subprocess`/`os.system` on workflow-supplied strings, the model-manager auto-download. |
| 127 | - **This is also a [[cicd-supplychain|supply-chain]] / [[ai-agent-mcp|agent]] surface** - rotate the lens. |
| 128 | - **Oracle:** importing/running the malicious workflow fires a callback → Family 1. |
| 129 | |
| 130 | ### KC-6 - Gradio / model-server file & SSRF surface (Family 7, bridges to [[ai-agent-mcp]]) |
| 131 | Gradio (the demo UI under most HF Spaces and local model demos) has a history of path traversal |
| 132 | (`/file=` arbitrary file read) and SSRF in the proxy/`/queue` paths. |
| 133 | - **Sinks:** the `/file=` route's path resolution, the URL-fetch in upload/proxy handlers. |
| 134 | - **Oracle:** read our canary secret via `/file=` (Family 7) or land SSRF→metadata. |
| 135 | - **CVE class:** Gradio `/file=` traversal (CVE-2023/2024 class), Gradio SSRF. Find the *new* route. |
| 136 | |
| 137 | ### KC-7 - Tokenizer / config side-channels (Class A/B, the overlooked input) |
| 138 | Even with "safe" weights, the *accompanying* files load too: `tokenizer.json`, |
| 139 | `tokenizer_config.json`, `config.json`, `generation_config.json`, `chat_template` (a **Jinja2 |
| 140 | template** → SSTI sink!), `adapter_config.json` for PEFT/LoRA. The `chat_template` Jinja2 is a real |
| 141 | [[../00-doctrine/SEVERITY-GATE#family-1-remote-code-execution-the-crown|SSTI→RCE]] surface - HF added |
| 142 | a sandbox; hunt the sandbox bypass. |
| 143 | - **Sinks:** `jinja2.Environment` rendering the chat template, any `eval`-ish config parse, the |
| 144 | `trust_remote_code=True` path (which literally imports model-repo Python - a known but still-fertile |
| 145 | RCE: hunt where `trust_remote_code` defaults true or is reachable without the flag). |
| 146 | - **Oracle:** template/remote-code executes → Family 1. |
| 147 | |
| 148 | --- |
| 149 | |
| 150 | ## The dynamic oracle, concretely (this archetype's whole point) |
| 151 | |
| 152 | For **Class B (parser memory-corruption)** the pipeline is: |
| 153 | 1. `git clone` the freshest in-scope loader. Pin the commit. |
| 154 | 2. Build with `-fsanitize=address,undefined` (or use the project's fuzz harness if present - |
| 155 | `llama.cpp` ships `tests/`, ORT has fuzzers, many have OSS-Fuzz targets you can *re-run on new code*). |
| 156 | 3. Write a libFuzzer/AFL++ harness whose entry is the **load function** (`gguf_init_from_file`, |
| 157 | `safetensors deserialize`, `Model::Load`). Seed with a real valid model, mutate. |
| 158 | 4. Run; collect crashes; **`triage_crash`** to confirm a *controlled* fault (write to attacker data, |
| 159 | PC control, OOB on heap with our pattern). A null-deref / pure read-DoS is **DISPROVEN for RCE** |
| 160 | per [[../00-doctrine/SEVERITY-GATE#family-6-mass-data-exfil-financial-impact|the gate]] - log it, |
| 161 | do not dress a DoS as critical. |
| 162 | 5. Minimize the crashing model file; that file *is* the REPLICA_CONFIRMED artifact. |
| 163 | |
| 164 | For **Class A (deserialization)** the oracle is direct: craft the malicious model, load it in a |
| 165 | disposable container, observe the callback/file-write. No fuzzing needed - the proof is the popped |
| 166 | shell. |
| 167 | |
| 168 | Relevant tooling in the kit: ASan/UBSan builds, libFuzzer/AFL++/`boofuzz`, `joern_query` & |
| 169 | `codeql_query` for the source→sink path, `triage_crash`, a disposable `sandbox_create` to detonate |
| 170 | Class-A models, `start_callback_server` for the RCE callback. |
| 171 | |
| 172 | --- |
| 173 | |
| 174 | ## BORING traps - do NOT spend time here |
| 175 | |
| 176 | - **"`torch.load` runs arbitrary pickle."** Known since forever. Reporting raw-pickle RCE against a |
| 177 | tool that *documents* "only load models you trust" is a non-finding. The finding is the |
| 178 | **safe-path bypass** (`weights_only=True` defeated, allowlist escaped, a "safe" format corrupted). |
| 179 | - **Null-deref / assertion-abort / pure read-DoS in a parser.** Not RCE. Cleared-axes hardening note. |
| 180 | - **"This model could be malicious."** Of course it could. That is the threat model, not a bug. The |
| 181 | bug is a *specific* loader flaw with an *artifact*. |
| 182 | - **Re-finding a public llama.cpp/ORT CVE in an old checkout.** Sibling-CVE. Hunt code days old. |
| 183 | - **Out-of-memory from a huge dim field.** Resource exhaustion ≠ corruption. Verify it's actually an |
| 184 | OOB write before getting excited. |
| 185 | - **Gradio version banner / "old Gradio".** Version-based "findings" are not findings; prove the route. |
| 186 | |
| 187 | --- |
| 188 | |
| 189 | ## Where the novel bugs actually are (steer here) |
| 190 | |
| 191 | 1. **Allowlist / safe-mode bypasses** in the *defenses* (`weights_only`, find_class allowlists, the |
| 192 | chat-template sandbox, safetensors offset validation). The defense is newer code than the format. |
| 193 | 2. **Re-implementations** - a Go/C++/Zig/Rust-C-ABI port of a "safe" format that dropped a check. |
| 194 | 3. **Integer arithmetic on attacker dims** - `n0*n1*n2*type_size` overflow before alloc. Grep/CPG for |
| 195 | every multiply feeding a `malloc`/`memcpy` in `ggml`, ORT kernels, NPY/`safetensors` consumers. |
| 196 | 4. **External-reference fields** - ONNX `external_data` path, GGUF embedded paths, anything where the |
| 197 | model file names *another* file/URL → traversal / SSRF (Family 7). |
| 198 | 5. **The accompanying files** - tokenizer/chat-template/config that load alongside "safe" weights and |
| 199 | reach Jinja2/`trust_remote_code`/eval. |
| 200 | 6. **Freshly-merged format extensions** - a new GGUF KV type, a new ONNX opset, a new quant format |
| 201 | landed last week. New parse code = unfuzzed code. See [[../06-methods/NOVELTY-DOCTRINE]]. |