| # 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. |
|
|