If you download a model from a hub and load it, you are running a binary parser on a file a stranger gave you. That file decides how much memory gets allocated, how many times a loop runs, and where pointers land. The model weights are the payload everyone thinks about. The parser is the part nobody looks at, and it is the part I went after.
This is the first post in a series about Crucible, a structure-aware fuzzer I built to hunt bugs in the code that loads machine-learning model files. To date it has produced around 168 distinct findings across 60 projects. Six carry CVEs, fifteen fixes have merged upstream, and most of the rest sit somewhere in the disclosure pipeline or under embargo. This post is the map. It covers what the attack surface is, why a format-aware fuzzer beats a dumb one on this surface, and how the pipeline runs from a seed file to a filed advisory. Later posts go deep on the mutation engine, and on triage and disclosure.
The attack surface nobody loads carefully
A model file is not just a bag of floats. Formats like GGUF, ONNX, TorchScript, SafeTensors, and TFLite carry a structured header: magic bytes, a version, counts of how many metadata entries and tensors follow, then per-tensor descriptors with names, dimensions, types, and byte offsets into a data section. The loader reads those fields and acts on them. It resizes vectors to the declared counts. It multiplies dimensions together to size allocations. It adds an attacker-controlled offset to a base pointer to find where a tensor lives.
Every one of those values comes from the file, and most of these formats carry no checksum, no signature, and no integrity protection of any kind. When llama.cpp’s gguf_init_from_file opens a file, every field it reads is fully attacker-controlled. That is the whole problem in one sentence.
The consequences are the classic memory-safety catalog. A tensor count that passes a bounds check at the validation site but overflows a size_t multiplication at the usage site, producing a tiny allocation followed by a loop that writes hundreds of gigabytes of structures past the end. Dimensions multiplied into an integer overflow so the allocation is undersized and the copy is not. A dimension of zero that slips past a < 0 check and lands in an INT64_MAX / ne[j] overflow guard, where it becomes a divide-by-zero and a SIGFPE. A general.alignment value of zero that turns a padding calculation into a division by zero. These are not hypothetical. They are the recurring CVE classes in this code. Cisco Talos alone published five GGUF advisories against llama.cpp in a single 2024 batch, and the parser has been taking fixes of this shape ever since.
The reason this surface stays productive is that the ecosystem treats model files as data, not as untrusted input. People sandbox the inference, not the parser. The parser runs first, in-process, with full trust.
Why dumb fuzzing bounces off
The obvious move is to point AFL or libFuzzer at the loader and let it flip bytes. On this surface that mostly wastes cycles, and the reason is structural.
A GGUF file starts with four magic bytes, then a version, then two 64-bit counts. A random bit-flip somewhere in the first few bytes is overwhelmingly likely to corrupt the magic or the version or a count, and the parser rejects the file at the very first check. It never reaches the metadata loop, never reaches the tensor descriptors, never reaches the size arithmetic where the interesting bugs live. The fuzzer spends nearly all of its iterations being told “bad magic” at byte zero.
Even when a flat mutation gets past the header, the format is length-prefixed and self-referential. A string is a 64-bit length followed by that many bytes. Flip a byte in the middle of the metadata section and you almost certainly corrupt the length prefix of the next field, so the parser desynchronizes and bails out early. The deep code paths, the ones that multiply dimensions and add offsets to pointers, sit behind a gauntlet of consistency checks that random bytes cannot satisfy by accident.
Structure-aware fuzzing inverts this. Instead of treating the file as an opaque byte stream, Crucible parses the seed into typed structures, mutates specific fields while keeping the rest of the file well-formed, and re-serializes into a valid container. Every generated input passes the magic check, passes the version check, parses far enough to reach the code under test, and then carries one or two precisely poisoned values into it. The fuzzer stops spending its budget at byte zero and starts spending it where the bugs are.
The pipeline: seed to advisory
Crucible is a Go core wrapped around C and C++ harnesses. The data flow is a closed loop: seeds become a corpus, the corpus feeds a mutator, the mutator feeds a harness, the harness drives the target, crashes flow into triage, and triage produces reports. Here is each stage.
Seeds. crucible-gen writes a set of structurally varied minimal model files: different metadata types, tensor layouts, alignment values, quantization schemes. Some seeds are reconstructed from known CVE patterns, including the Cisco Talos TALOS-2024-1912 through 1916 family, so the fuzzer starts near places bugs have lived before. A foundation package, pkg/gguf, defines the Go types that mirror the binary spec and provides Unmarshal and Marshal, the round-trip that everything else depends on.
Mutator. pkg/mutator is the engine, and it gets a post of its own. It holds roughly 60 strategies grouped into weighted categories, with 70 percent of the mutation budget aimed at metadata and tensor-info because that is where bug density is highest. Each call parses a seed, applies one to three mutations to the in-memory structure, and re-serializes to bytes. Because it mutates a parsed structure rather than raw bytes, the output is always a file the parser will accept far enough to matter.
Harness. Crucible supports three backends: libFuzzer, AFL++, and Go’s native fuzzer. The harness is small. It takes the mutated bytes and calls the real parser entry point in the real target, compiled with AddressSanitizer and UndefinedBehaviorSanitizer so any memory-safety violation becomes an immediate, diagnosable abort. There are over 35 specialized harnesses covering GGUF shallow and deep parsing, model loading, the ggml-rpc protocol, server endpoints, quantization, and the non-GGUF formats.
Target. The actual victim code: llama.cpp, whisper.cpp, stable-diffusion.cpp, PyTorch/libtorch’s TorchScript path, TFLite’s FlatBuffer loader, ONNX Runtime, Apple MLX, and others. The harness links their real libraries, so a crash is a crash in shipping code, not in a reimplementation.
Triage. When the sanitizer fires, pkg/triage takes over, and it gets a post of its own too. It classifies the crash from the sanitizer text, computes a stable stack hash so the same bug found a thousand ways collapses to one entry, minimizes the reproducer, maps it to a CWE, scores it with CVSS 3.1, and emits both a CVE-ready report and SARIF 2.1.0 output. The decoupling matters: triage operates on sanitizer output, not on GGUF, so it works for every target.
Advisory. A human reads the triaged report, confirms the bug against current HEAD, and files it through the appropriate channel. That last part is not automated and will not be. The tool drafts; I decide what goes out and where.
What comes next
That is the whole machine: a structure-aware mutator that keeps inputs inside the parser’s reachable state, a fleet of sanitizer-instrumented harnesses, and a triage layer that turns raw crashes into something a maintainer can act on. The two pieces worth opening up are the engine and the back end.
The next post goes inside the mutation engine: the Strategy abstraction, why the weights look the way they do, and concrete examples of zeroing a tensor dimension, lying about an array count, and poisoning general.alignment, each tied to a real bug class. The one after that follows a single crash from sanitizer text to a filed advisory: deduplication, CVSS, SARIF, and the disclosure discipline that keeps embargoed findings embargoed.
There is also a negative result worth your time. One format produced zero crashes across tens of millions of inputs once its known bug was fixed, and the contrast with GGUF is the clearest argument I have for why format design is a security decision. That one is already up, on SafeTensors.
Crucible is a structure-aware fuzzer for ML model parsers, built at Halo Forge Labs. Individual findings are written up here as embargoes lift.