← voidwest    research notes

A Model File Is More Than Weights

hardening Ember's path from hostile GGUF bytes to numerical kernels
mohammed al-thobaiti · 2026-08-16
Ember GGUF Rust fuzzing systems security

Ember now validates tensor descriptors before they reach model construction. The frozen construction-mutation campaign went from 194 failures in 2,000 cases to zero. This is a result on that adversarial fixture set, not a security guarantee.

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.

the useful mental model

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 original boundary was better than it looked, and weaker than it should be

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.

The missing piece was a type that carried the validation guarantees into execution.

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.

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.

give trusted state a type

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.

parsing was only the first boundary

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 row dequantization API now accepts a Q8WeightView derived from a validated QuantizedWeight. This establishes the layout invariant before SIMD execution.

what fuzzing actually taught me

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:

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.

the frozen comparison

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.

failing early was cheaper

The hardened loader rejected the hostile context-length fixture before expensive allocation.

Rejection took 10.4 ms, versus 30.8 seconds on the baseline. The valid tiny control loaded in 10.5 ms.

Descriptor validation took about 69 ns per descriptor. A valid 1.3 GB Llama Q8_0 model loaded in 1.77 s on the hardened build and 2.03 s on the baseline.

Three thermally noisy runs cannot establish a normal-load speedup. The demonstrated benefit is early rejection of this hostile fixture.

what this does not mean

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.

the broader lesson

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.