| # Uncontrolled recursion (CWE-674) in gguf-py `GGUFReader._get_field_parts` via nested-array KV metadata β uncaught `RecursionError` DoS |
| |
| **Severity:** Medium (availability / denial-of-service on model load) |
| **Category:** Malformed model file β GGUF ($4k format), metadata KV parser |
| **Weakness:** CWE-674 Uncontrolled Recursion β uncaught `RecursionError` |
| |
| ## Affected software |
| |
| - **Package:** `gguf` (gguf-py, part of the `ggml-org/llama.cpp` project) β **version 0.19.0** (pip) |
| - **File:** `gguf/gguf_reader.py`, function `GGUFReader._get_field_parts()` (lines 221β257) |
| - **Tested environment:** gguf 0.19.0, numpy 2.5.1, CPython 3.13.12, default `sys.recursionlimit` = 1000, Linux x86-64 |
| |
| ## Summary |
| |
| `GGUFReader._get_field_parts()` parses an `ARRAY`-typed metadata KV value by reading the |
| array's element type (`raw_itype`, a `uint32` read straight from the file) and its length, |
| then **recursing into `_get_field_parts(offs, raw_itype[0])` for every element**. The element |
| type is itself attacker-controlled and may equal `GGUFValueType.ARRAY` (value `9`), producing |
| self-recursion with **no nesting-depth limit and no validation**. |
|
|
| Recursion depth equals the file's array-nesting depth, and each level costs only **12 bytes** |
| (`u32` element-type = ARRAY + `u64` count = 1). Once depth exceeds Python's recursion limit |
| (~1000), an uncaught `RecursionError` is raised and propagates out of `GGUFReader.__init__` |
| (via `_build_fields` β `_get_field_parts`), crashing any caller that opens the file. A ~60 KB |
| file (depth 5000) is enough to guarantee the crash regardless of a raised recursion limit. |
|
|
| ## Root cause |
|
|
| `gguf/gguf_reader.py`, lines 238β255 (the `# Handle arrays.` branch): |
|
|
| ```python |
| # Handle arrays. |
| if gtype == GGUFValueType.ARRAY: |
| raw_itype = self._get(offs, np.uint32) # element type β attacker-controlled |
| offs += int(raw_itype.nbytes) |
| alen = self._get(offs, np.uint64) # element count β attacker-controlled |
| offs += int(alen.nbytes) |
| aparts: list[npt.NDArray[Any]] = [raw_itype, alen] |
| data_idxs: list[int] = [] |
| # FIXME: Handle multi-dimensional arrays properly instead of flattening |
| for idx in range(alen[0]): |
| curr_size, curr_parts, curr_idxs, curr_types = self._get_field_parts(offs, raw_itype[0]) |
| # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ |
| # raw_itype[0] can be GGUFValueType.ARRAY (9) β unbounded self-recursion |
| if idx == 0: |
| types += curr_types |
| idxs_offs = len(aparts) |
| aparts += curr_parts |
| data_idxs += (idx + idxs_offs for idx in curr_idxs) |
| offs += curr_size |
| return offs - orig_offs, aparts, data_idxs, types |
| ``` |
|
|
| The developer-visible comment |
| `# FIXME: Handle multi-dimensional arrays properly instead of flattening` sits directly above |
| the unguarded recursive loop. Because `raw_itype[0]` is read verbatim from the file and passed |
| back into `_get_field_parts` as the element type, an array whose element type is itself `ARRAY` |
| recurses one level deeper per nesting, with no depth accounting. |
|
|
| ## Reachability (real callers) |
|
|
| - **transformers** β `transformers/modeling_gguf_pytorch_utils.py:599` `reader = GGUFReader(gguf_checkpoint_path)`, reached via the documented workflow |
| `AutoModel.from_pretrained(..., gguf_file="evil.gguf")` / `AutoModelForCausalLM.from_pretrained(..., gguf_file=...)`. |
| - **Direct API** β any code doing `gguf.GGUFReader("model.gguf")`. |
| - **Tooling** β `gguf-dump.py` / `gguf_dump.py` shipped with the package. |
|
|
| The crash occurs inside `GGUFReader.__init__`, i.e. at file-open time, before any tensor data |
| is touched β so it triggers on the very first act of inspecting an untrusted GGUF file. |
|
|
| ## Proof of Concept |
|
|
| `gen.py` (included) writes a GGUF v3 file: header |
| `magic 'GGUF' + version 3 + tensor_count 0 + kv_count 1`, then one KV: key `'x'`, |
| `value_type = ARRAY (9)`, followed by `depth` repetitions of `[u32(type=ARRAY) + u64(count=1)]`, |
| terminated by `[u32(type=UINT8) + u64(count=0)]`. |
|
|
| ```bash |
| python gen.py nested 5000 # writes nested.gguf (~60 KB) |
| python -c "import gguf; gguf.GGUFReader('nested.gguf')" |
| ``` |
|
|
| ## Captured evidence (verbatim) |
|
|
| ``` |
| $ ../gguf-venv/bin/python gen.py nested 5000 |
| wrote nested.gguf depth 5000 |
| $ ls -l nested.gguf |
| -rw-rw-r-- 1 kali kali 60049 ... nested.gguf |
| |
| $ ../gguf-venv/bin/python -c "import gguf; gguf.GGUFReader('nested.gguf')" |
| Traceback (most recent call last): |
| File "<string>", line 1, in <module> |
| import gguf; gguf.GGUFReader('nested.gguf') |
| File ".../gguf/gguf_reader.py", line 169, in __init__ |
| offs = self._build_fields(offs, kv_count) |
| File ".../gguf/gguf_reader.py", line 298, in _build_fields |
| field_size, field_parts, field_idxs, field_types = self._get_field_parts(offs, raw_kv_type[0]) |
| File ".../gguf/gguf_reader.py", line 248, in _get_field_parts |
| curr_size, curr_parts, curr_idxs, curr_types = self._get_field_parts(offs, raw_itype[0]) |
| [Previous line repeated 990 more times] |
| File ".../gguf/gguf_reader.py", line 240, in _get_field_parts |
| raw_itype = self._get(offs, np.uint32) |
| File ".../gguf/gguf_reader.py", line 203, in _get |
| arr = self.data[offset:end_offs].view(dtype=dtype)[:count] |
| File ".../numpy/_core/memmap.py", line 312, in __array_finalize__ |
| if hasattr(obj, '_mmap') and np.may_share_memory(self, obj): |
| RecursionError: maximum recursion depth exceeded |
| EXIT=1 |
| ``` |
|
|
| ### Negative controls |
|
|
| ``` |
| --- negative controls --- |
| loaded depth200 ok fields 4 # depth 200 (< recursionlimit) loads cleanly |
| NEG CONTROL good.gguf: loaded OK, x = [1, 2, 3] # well-formed array<uint8> [1,2,3] parses correctly |
| ``` |
|
|
| - `depth=200` (below the recursion limit) β the same code path returns normally with 4 fields. |
| - A well-formed single-level `array<uint8>` value `[1, 2, 3]` loads and reads back as `[1, 2, 3]`. |
|
|
| This isolates the defect to unbounded recursion depth, not to array parsing in general. |
|
|
| ### transformers reachability grep |
|
|
| ``` |
| --- transformers reachability grep --- |
| 599: reader = GGUFReader(gguf_checkpoint_path) |
| ``` |
|
|
| ## Impact |
|
|
| An attacker who can get a victim to open an untrusted `.gguf` file (Hub download, model |
| conversion service, CI, inference server auto-loading community GGUFs) causes an uncaught |
| `RecursionError` that terminates the loader process. The file is tiny (~60 KB for depth 5000) |
| and cheap to craft; raising the Python recursion limit does not help because the depth is |
| attacker-controlled and unbounded. No tensor data required. |
|
|
| ## Suggested fix |
|
|
| Bound array nesting depth (e.g. pass and check a `depth` parameter, rejecting beyond a small |
| constant), and/or validate that an array's element type is not itself `ARRAY` unless an explicit |
| multi-dimensional format is supported. Convert the unhandled deep-nesting case into a clean |
| `ValueError` (already the pattern for unknown field types at line 257) rather than letting a |
| `RecursionError` escape. |
|
|
| ## Dedup note |
|
|
| - **Distinct from public CVEs.** Known GGUF-adjacent CVEs are in different components/mechanisms: |
| CVE-2025-2099 / CVE-2025-6921 (transformers Marian/EnglishNormalizer ReDoS β different component), |
| CVE-2026-5760 (SGLang GGUF SSTI β different library), CVE-2025-66960 (Ollama |
| `readGGUFV1String` β different library, different mechanism). None targets gguf-py |
| `_get_field_parts` recursion. |
| - **Distinct from our prior gguf/ggml findings.** This is the "R2 nested-array recursion DoS" |
| in the gguf-py parser β separate from R1 (gguf-py array-length OOM, `gguf-slen-oom-poc`), |
| R3 (llama.cpp `convert_llama_ggml_to_gguf.py` int64-overflow, `huntr-poc-ggml-oob`), the |
| transformers `GGUFTokenizerSkeleton` O(NΒ²) tokenizer DoS (`huntr-poc-gguf-transformers-dos`), |
| the gguf vocab type-confusion, and the tensor-shape div-by-zero findings. Different mechanism |
| (uncontrolled recursion in the KV metadata parser) and different sink |
| (`_get_field_parts` self-recursion). |
|
|
| ## Files in this repo |
|
|
| - `gen.py` β PoC generator (`python gen.py nested <depth>`; also contains the `poc_good` control). |
| - `nested.gguf` β depth-5000 crash artifact (~60 KB). |
| - `good.gguf` β well-formed control (`array<uint8> [1,2,3]`). |
|
|