I started looking at GGUF loading in Ember as a security boundary and realized that “model file” is a slightly misleading phrase. A model file controls far more than model weights.
Tensor dimensions decide shape arithmetic and allocations. Offsets decide which bytes become memory views. Metadata selects the architecture, layer count, context length, head geometry, and RoPE tables. Quantization types select byte layouts and numerical kernels. Tokenizer configuration reaches a separate parser, regex engine, and vocabulary contract. All of this happens before the runtime generates one token.
Once a GGUF comes from an untrusted download, cache, or shared artifact, those fields are untrusted control inputs. The weight values may be opaque by design, but the structure around them is executable policy for the loader.
A GGUF is not merely a bag of numbers. It is a program for constructing the state that an inference engine will execute. Treat its structure with the same suspicion as any other complex binary format.
The starting point was not a reckless unsafe parser. Ember's GGUF
parser was safe Rust and already rejected most malformed input. It
checked reads against EOF, bounded strings by remaining bytes,
rejected duplicate names and keys, restricted tensor rank, used
checked shape products and byte extents, checked file ranges,
rejected overlaps, validated alignment, and rejected unsupported
dtypes. The parser itself contained no unsafe parsing.
That distinction matters. Adding another pile of bounds checks was not the core job.
The architectural gap was that a raw parsed TensorInfo
could flow through procedural checks and then into view
construction. The checks were real, but the program did not have a
distinct representation for “this descriptor has passed every
invariant required by allocation and execution.” A later refactor
could accidentally reorder or bypass a check without fighting the
type system.
GGUF bytes
│
▼
parser ───────────────► untrusted descriptors
│
semantic validation
│
▼
validated descriptors
│
▼
tensor / model construction
│
▼
validated execution-facing objects
│
▼
kernels
metadata ──► validated config ──► allocation / model construction
tokenizer.json ──► size / UTF-8 / JSON gates ──► tokenizer library
The hardening work made those transitions explicit. Parsing tells us what the file says. Validation decides whether Ember is willing to construct executable state from it. Those are different jobs.
The Rust shape of the change is simple:
struct TensorInfo {
// parser-derived and untrusted
}
pub struct ValidatedTensorInfo {
// private fields; no public constructor
}
impl TensorInfo {
fn validate(&self, data_start: u64, file_len: u64)
-> Result<ValidatedTensorInfo>;
}
In production the validated type stores the checked element count, encoded byte length, and absolute file range alongside the name, dimensions, dtype, and offset. Its fields are private. View construction consumes this type, not the raw parser descriptor. There is no public constructor that lets unrelated code claim a descriptor is valid.
The gate checks rank and non-zero dimensions, shape-product overflow, supported dtypes, encoded byte arithmetic, offset and extent overflow, file bounds, and overlapping ranges. For block quantization it also validates the contiguous dimension, not only the total element count. That last detail closed a correctness bug: the eager K-quant path could accept a malformed shape whose total size was block-aligned while its actual block axis was not, then interpret the data in a different order from a compliant runtime.
Named limits replace vague assumptions about “reasonable” counts. Tensor and metadata-table sizes, string and array sizes, rank, and nesting depth are bounded at the file boundary. Model dimensions such as context length, layer count, vocabulary size, head count, head dimension, embedding width, and the product used to size RoPE tables have their own limits at the configuration boundary.
A cap is not a proof that a value is semantically correct. It is a resource contract: this runtime refuses to let one field demand an absurd amount of host memory before model construction can reject the file for some later reason.
The first fuzz target stopped after parsing and descriptor validation. It was useful, but it mostly confirmed that the existing parser was already hard to upset. Raw byte mutations tend to damage the file early. They rarely survive long enough to reach model semantics.
So the next target loaded tiny but structurally complete models and called model construction. That reached a different class of assumptions: head dimensions must be even for the RoPE path; linear weights must actually be two-dimensional; a model needs a bounded, non-zero layer inventory; and all tensors required by the selected architecture should be present before metadata-sized allocations begin.
This produced a separate flow from metadata to validated config to allocation. Llama and GPT-2 builders also gained an inventory gate that checks all required tensors before construction starts. Per-layer shape checks remain close to the builder, where the architecture-specific contract is known.
The tokenizer needed its own boundary. Ember now checks a named
file-size limit before reading, verifies UTF-8 and JSON structure
before calling the third-party tokenizers crate, and
catches remaining deserializer panics in unwind builds. This is
containment, not a claim that arbitrary tokenizer behavior is safe:
crafted regex patterns may still consume excessive CPU during
encoding, and that remains a documented theoretical concern.
The same idea reaches the numerical side. The old public row
dequantization primitive accepted a raw byte slice plus a block
count. It now accepts a Q8WeightView that can only be
obtained from a validated QuantizedWeight. SIMD code
should receive a representation whose layout invariant is already
established, not a loose (data, count) pair and a
comment asking every caller to be careful.
The setup used cargo-fuzz with tiny structured seeds.
One target exercised parser → validator, another exercised complete
tiny GGUFs through model construction, and a third drove
tokenizer.json through the tokenizer boundary. Failures
were minimized, classified, and turned into regression tests. The
targets then received additional fixed-duration runs after each
repair.
The useful findings were spread across layers:
tokenizers 0.20.4. That was third-party panic
exposure, contained at Ember's dependency boundary.
Calling all of these “vulnerabilities” would flatten useful distinctions. The evidence contains panics, a silent layout misinterpretation, resource-amplification paths, dependency panic exposure, and structural weaknesses where no memory error was demonstrated. Classification kept the engineering response tied to what was actually observed.
Once the harness existed, the obvious question was whether these assumptions were peculiar to Ember. A frozen experiment compared pre-hardening Ember, hardened Ember, llama.cpp b7999 through a loader-and-construction harness, and Candle 0.11.0 at its GGUF parser API.
| frozen campaign | baseline Ember | hardened Ember | llama.cpp b7999 | Candle 0.11 |
|---|---|---|---|---|
| 62-case corpus | 5 panics, 1 crash, 1 timeout | 0 panic / crash / timeout | 2 process crashes | 1 parser panic |
| 10,000 parser mutations | 0 failures | 0 failures | 197 crashes, 4 timeouts | 6 panics |
| 2,000 construction mutations | 194 failures (9.7%) | 0 failures | 32 crashes, 3 timeouts | parser only |
frozen 2026-08-12. “failure” here means panic, process crash, or timeout, not ordinary structured rejection. The deterministic corpus is an adversarial fixture set, not a prevalence estimate.
The layers must stay separate. Both Ember versions showed zero failures in the parser-mutation campaign; the baseline problems appeared when valid-enough structure reached configuration, allocation, model construction, and tokenizer code. Candle's comparison was parser-level only. A Candle accept means its parser accepted the structure; it says nothing about whether a downstream Candle model builder would accept or execute it.
The external findings were handled conservatively. Several llama.cpp behaviors were rediscoveries: by the time the report was rebased onto current master, empty metadata keys and oversized declared strings were already fixed upstream, while a zero-layer case had a clearer assert. The remaining zero-dimension fix was proposed in llama.cpp PR #26946; that PR was closed after maintainers pointed to existing upstream issues and fixes. It was not merged. The Candle alignment-zero fix is PR #3876, which remained open when this note was written.
Rediscovery is still useful. It shows that a harness reaches known, meaningful failure classes and gives a regression oracle for the local design. But rediscovery is not novelty, and a closed pull request is not an accepted fix.
The descriptor gate measured about 69 ns per tensor descriptor in the frozen experiment. At model-load scale that disappeared into I/O and dequantization. A valid 1.3 GB Llama Q8_0 model loaded in 1.77 s on the hardened build versus 2.03 s on the baseline in this three-run, thermally noisy measurement; that difference should not be read as a speedup claim.
The more useful number is rejection cost. A hostile context-length fixture took 30.8 seconds on the baseline while thrashing through a pathological allocation path. The hardened loader rejected it in 10.4 ms, roughly the same as loading the valid tiny control at 10.5 ms. Semantic validation was not merely cheap. In the hostile case, it avoided doing the expensive wrong work.
Ember is not:
Rust made the boundary easier to express: private fields, fallible
constructors, slices, checked arithmetic, and enums give the
compiler something useful to enforce. It did not know that a head
dimension must be even, that a quantization block runs along
dims[0], or that two individually acceptable limits
can have an unacceptable product. Those are semantic contracts,
and the runtime still has to state and test them.
Binary ML model formats should be treated like other complex untrusted formats: parse them into an untrusted representation, validate their global semantics and resource implications, and only then construct execution-visible state.
The useful unit of hardening was not an individual if
statement. It was making the transition from “bytes we parsed” to
“state we're willing to execute” explicit in the program.
That is the cleanest lesson I took from EmberSEC. The loader is not finished when it understands the file. It is finished when every downstream consumer can tell, from the object in its hands, which promises have already been proved.