| --- |
| license: mit |
| tags: |
| - security |
| - proof-of-concept |
| - gguf |
| - denial-of-service |
| --- |
| |
| # PoC: Denial of Service in `gguf` (gguf-py) `GGUFReader` via unchecked ARRAY length |
|
|
| This repository contains a **49-byte** proof-of-concept GGUF file that triggers a |
| non-terminating loop with unbounded memory growth in the official `gguf` Python |
| package (`gguf.GGUFReader`). Loading the file for metadata inspection causes the |
| process to hang and consume memory until it is OOM-killed. |
|
|
| This artifact is for **defensive security research / responsible disclosure** only. |
|
|
| ## Affected |
|
|
| - `gguf` (PyPI) **0.18.0** |
| - `gguf` (PyPI) **0.19.0** |
| - Upstream `ggml-org/llama.cpp` `gguf-py` at |
| `b5f52280fb781cf63e7c3fb79f8bb8de215293e3` (verified after the `#19856` |
| integer-overflow patch). |
|
|
| ## Root cause |
|
|
| In `gguf/gguf_reader.py`, `_get_field_parts()` parses a metadata ARRAY by reading a |
| 64-bit element count straight from the file and looping that many times: |
|
|
| ```python |
| alen = self._get(offs, np.uint64) # attacker-controlled element count |
| for idx in range(alen[0]): # no bound vs. remaining file bytes |
| curr_size, curr_parts, ... = self._get_field_parts(offs, raw_itype[0]) |
| aparts += curr_parts # grows every iteration |
| offs += curr_size # curr_size == 0 once past EOF |
| ``` |
|
|
| For a 1-byte element type (`UINT8`/`BOOL`), once `offs` reaches EOF the element read |
| returns an empty array (`curr_size == 0`), so `offs` never advances and the loop runs |
| up to `alen = 2**64-1` times, appending to `aparts` each iteration. |
|
|
| ## Reproduce |
|
|
| ```bash |
| pip install gguf==0.19.0 |
| python -c "from gguf import GGUFReader; GGUFReader('poc_array_dos.gguf')" |
| # never returns; resident memory climbs ~230 MiB/s |
| ``` |
|
|
| Safe, time-boxed demonstration (sampling RSS, aborts after 8s): |
|
|
| ```bash |
| python verify_dos.py poc_array_dos.gguf |
| ``` |
|
|
| Expected output: the parser is still running after 8s with RSS having grown >1 GiB |
| from a 49-byte input. A finite-length control (`make_poc.py out.gguf 0x2`) returns |
| in ~0.01s. |
|
|
| ## Rebuild the PoC |
|
|
| ```bash |
| python make_poc.py poc_array_dos.gguf 0xFFFFFFFFFFFFFFFF |
| ``` |
|
|
| ## Suggested fix |
|
|
| Reject array lengths that exceed the remaining file size (each element is at least |
| 1 byte), and add a progress guard for truncated elements, mirroring the bounds |
| checks already present in the C++ reader (`ggml/src/gguf.cpp`): |
|
|
| ```python |
| remaining = self.data.size - offs |
| if alen[0] > remaining: |
| raise ValueError(f'array length {int(alen[0])} exceeds remaining file size {remaining}') |
| ... |
| if curr_size <= 0: |
| raise ValueError('truncated GGUF ARRAY element') |
| ``` |
|
|