MBM7's picture
Upload 2 files
2fbdc0e verified
|
Raw
History Blame Contribute Delete
5.42 kB
# GGUF KV Array Field Unbounded Length β€” CPU/Memory Amplification DoS
**Status:** Reported to Huntr (pending)
**Package:** [`gguf`](https://pypi.org/project/gguf/) (PyPI) β€” official `gguf-py` reader/writer from [ggml-org/llama.cpp](https://github.com/ggml-org/llama.cpp)
**File / function:** `gguf/gguf_reader.py`, `GGUFReader._get_field_parts()` (~line 247)
**Class:** CWE-834 (Excessive Iteration) / CWE-400 (Uncontrolled Resource Consumption), root cause CWE-20 (Improper Input Validation)
**Severity:** High β€” a few hundred bytes of crafted input can consume gigabytes of RAM and/or hang the parsing process for minutes with no exception raised, on any application that loads a GGUF file (the standard format for llama.cpp-compatible LLM weights, used across the local-inference ecosystem: llama.cpp, Ollama, LM Studio, ktransformers, and many others that reuse this same `gguf` library).
## Summary
When `GGUFReader` parses a key/value metadata field of type `ARRAY`, it reads the declared element count (`alen`) as a raw `uint64` straight from the file β€” **with no upper bound check** β€” and then uses it directly as a Python loop counter:
```python
# gguf/gguf_reader.py, GGUFReader._get_field_parts()
alen = self._get(offs, np.uint64)
offs += int(alen.nbytes)
aparts: list[npt.NDArray[Any]] = [raw_itype, alen]
data_idxs: list[int] = []
for idx in range(alen[0]): # <-- attacker-controlled, unbounded
curr_size, curr_parts, curr_idxs, curr_types = self._get_field_parts(offs, raw_itype[0])
...
aparts += curr_parts
data_idxs += (idx + idxs_offs for idx in curr_idxs)
offs += curr_size
```
Each loop iteration performs a `numpy.memmap` slice read and appends to a Python list β€” **regardless of whether real data still exists in the file at that offset.** `numpy.memmap` slicing silently clips out-of-bounds reads instead of raising an exception, so the loop never fails fast; it just keeps running (and allocating) `alen` times, even when the file itself is only a few hundred bytes long.
A second, related trigger point exists at the top level: `GGUFReader.__init__` reads `kv_count` (also an unchecked `uint64`) from the header and passes it straight into `_build_fields(offs, kv_count)`, which loops `for _ in range(count)`. On a file with no real KV entries past the header, this crashes with an **uncaught `IndexError`** instead of a clean parse error β€” same root cause (CWE-20), a different and even smaller trigger.
## Proof of Concept
`poc_gguf_array_length_dos.py`:
1. Writes a normal, well-formed 169-byte GGUF file containing one small KV array field (5 integers).
2. Binary-patches **only the 8-byte declared-length field** of that array β€” nothing else in the file changes, and the file size stays 169 bytes.
3. Parses the patched files with the real `GGUFReader` and measures wall-clock time and peak RSS.
```bash
pip install gguf numpy
python poc_gguf_array_length_dos.py
```
### Measured results (169-byte file in all cases)
| Declared `alen` | Parse time | Peak RSS | Amplification (RSS bytes : file bytes) |
|---:|---:|---:|---:|
| 5 (legit baseline) | 0.002 s | 31.5 MB | 1 : 195,275 (interpreter baseline) |
| 100,000 | 0.76 s | 125 MB | 1 : 774,629 |
| 500,000 | 4.0 s | 499 MB | 1 : 3,097,957 |
| 1,000,000 | 8.2 s | 970 MB | **1 : 6,021,774** |
| 50,000,000 | did not finish (60 s timeout) | β€” | extrapolated: tens of GB / permanent hang |
RSS growth is close to linear (~1 KB per declared array element), so there is no ceiling short of process/OOM limits β€” a value near `UINT64_MAX` in the same 169-byte file is expected to hang or exhaust memory on essentially any machine.
## Why this matters
- **Trivial to trigger:** a single 8-byte field in an otherwise completely valid, small GGUF file.
- **No exception, no early warning:** the process just keeps consuming memory/CPU; nothing in the calling application (llama.cpp bindings, ktransformers, model-hosting services that auto-preview/validate uploaded GGUF files, etc.) gets a chance to reject the file before resources are exhausted.
- **Format is high-value and widely reused:** `gguf-py` is the reference GGUF reader reused by many downstream inference projects, so a fix here benefits the whole ecosystem rather than one application.
## Suggested fix
Validate `alen` (and `kv_count`, `tensor_count`) against a sane upper bound β€” and, more robustly, against the number of bytes actually remaining in the memory-mapped file β€” before using it as a loop count or allocation size, raising a clean `ValueError` immediately instead of iterating.
## Relationship to prior findings
This is a distinct vulnerability class from other model-file-parsing findings in the same research line:
- Different from "CULA" (Convergent Untrusted-Length Allocation, CWE-789) findings in numpy/joblib/zarr/hickle/skops/anndata/fastparquet/keras β€” those are a single upfront `np.empty()`-style allocation sized from an untrusted length; this is an **unbounded Python loop with per-iteration allocation** (CWE-834).
- Different from a recursion-depth finding in `protobuf`'s `json_format.ParseDict()` (CWE-674) β€” this is iteration-count-based, not recursion-based.
## Disclosure
Reported via Huntr under the GGUF / llama.cpp model-file-vulnerability scope. Please do not use this PoC against production systems you do not own or have explicit permission to test.