Stack Out-of-Bounds Write via Unbounded Tensor n_dims Field in whisper.cpp's Legacy GGML Model Loader
Gated PoC repository for a huntr Model File Vulnerability submission.
Access is granted on request (e.g. to protectai-bot for triage).
Target format: GGML (.ggml) β llama.cpp / ggml-org ecosystem (huntr target id ggml)
Project: ggml-org/whisper.cpp
Affected commit: 6fc7c33b4c3a2cec83e4b65abd5e96a890480375 (2026-07-01, current master at time of testing)
Affected functions / locations:
whisper_model_load()βsrc/whisper.cpp:1882-1887(main Whisper ASR model loader)parakeet_model_load()βsrc/parakeet.cpp:1402-1407(NVIDIA Parakeet ASR model loader, same file format/magic)whisper_vad_model_load()βsrc/whisper.cpp:5024-5029(Silero VAD model loader) β same unbounded-loop pattern confirmed by source review; not independently crash-reproduced (see Notes).
Summary
whisper.cpp loads its legacy ggml binary model format (magic 0x67676d6c, used for all ggml-*.bin Whisper/Parakeet/VAD model files) via a hand-rolled reader (whisper_model_load/parakeet_model_load/whisper_vad_model_load). Each per-tensor record in the file begins with:
int32_t n_dims;
int32_t length;
int32_t ttype;
read_safe(loader, n_dims);
read_safe(loader, length);
read_safe(loader, ttype);
...
int32_t nelements = 1;
int32_t ne[4] = { 1, 1, 1, 1 };
for (int i = 0; i < n_dims; ++i) {
read_safe(loader, ne[i]);
nelements *= ne[i];
}
(src/whisper.cpp:1870-1887)
n_dims is read directly from the attacker-supplied file with no upper-bound check against GGML_MAX_DIMS (4, ggml/include/ggml.h:222). The loop writes n_dims attacker-controlled int32_t values into the fixed 4-element stack array ne[4] via read_safe(loader, ne[i]) (a plain sizeof(T)-byte write to &ne[i]). Any n_dims > 4 writes past the end of ne into adjacent stack memory β an out-of-bounds stack write (CWE-787), fully attacker-controlled in both position (n_dims, up to INT32_MAX) and value (the ne[i] payload bytes).
Reachable directly through whisper.cpp's public, documented API β no special configuration needed:
whisper_context * ctx = whisper_init_from_file_with_params_no_state(path, whisper_context_default_params());
which is exactly what every application embedding whisper.cpp calls to load a user-/attacker-supplied .bin model file.
Proof of concept
crash_ndims7_whisper_model_load.bin is built as:
magic(4) | 11Γint32 hparams (tiny model, ftype=0) | n_mel/n_fft (1,1) + 1 float | n_vocab=0 | n_dims=7, length=4, ttype=0, ne[7]Γint32 payload, name="test"
n_dims = 7 is the minimal trigger found (writes 3 extra int32s / 12 bytes past the 16-byte ne[4] array); n_dims = 5 and 6 did not reproduce a visible crash with this exact harness build (the overwritten bytes land in slack not subsequently dereferenced), but n_dims >= 7 deterministically corrupts a std::vector control structure (buft_list_t) declared later in the same stack frame, SEGV'ing when its destructor runs at function exit.
harness_whisper.cpp calls the exact public API:
struct whisper_context_params cparams = whisper_context_default_params();
cparams.use_gpu = false;
struct whisper_context * ctx = whisper_init_from_file_with_params_no_state(argv[1], cparams);
if (ctx) whisper_free(ctx);
Built with afl-clang-fast++ -g -O1 -fsanitize=address linked statically against whisper.cpp's own CMake-built libwhisper.a/libggml*.a (-DWHISPER_SANITIZE_ADDRESS=ON) β i.e. the real production parsing code, not a synthetic stub.
Running the harness on crash_ndims7_whisper_model_load.bin deterministically produces (asan_trace_whisper_model_load.txt):
AddressSanitizer:DEADLYSIGNAL
==...==ERROR: AddressSanitizer: SEGV on unknown address 0x...fff1
==...==The signal is caused by a WRITE memory access.
#0 ... __asan::Allocator::Deallocate(...)
...
#7 ... in whisper_model_load(whisper_model_loader*, whisper_context&) src/whisper.cpp:1956:1
#8 ... in whisper_init_with_params_no_state src/whisper.cpp:3730:24
#9 ... in whisper_init_from_file_with_params_no_state src/whisper.cpp:3659:16
#10 ... in main harness_whisper.cpp:21
Reproduces identically (same trace, same corrupted-pointer pattern 0x????fffffff1) across independent runs β deterministic, not an uninitialized-memory artifact.
Second independent confirmation: parakeet.cpp
The identical unchecked loop exists verbatim in parakeet_model_load() (src/parakeet.cpp:1402-1407), reachable via the equally public parakeet_init_from_file_with_params_no_state(). crash_ndims64_parakeet_model_load.bin uses n_dims = 64 with filler 0x41414141 to guarantee corruption of a live pointer despite this function's different stack layout; the same crash class reproduces (asan_trace_parakeet_model_load.txt):
AddressSanitizer:DEADLYSIGNAL
==...==ERROR: AddressSanitizer: SEGV on unknown address (pc ...)
==...==The signal is caused by a READ memory access.
==...==Hint: this fault was caused by a dereference of a high value address
β consistent with corrupted 0x41414141-tainted stack data being read back as a pointer.
Why this is one finding, not three
whisper_model_load, parakeet_model_load, and whisper_vad_model_load all contain the identical copy-pasted idiom (read n_dims, loop into a fixed ne[4], no bound check). This is reported as a single root-cause vulnerability β a systemic missing bounds check in the shared ggml legacy tensor-header parsing convention β with three call sites as evidence of scope, not three separate bugs. The fix is identical in all three places: reject the file if n_dims < 1 || n_dims > GGML_MAX_DIMS.
Impact
Out-of-bounds stack write with fully attacker-controlled offset and payload during model loading, before any inference occurs. Depending on stack layout/compiler/platform this corrupts adjacent locals (observed: a std::vector control structure, producing a corrupted-pointer dereference at scope exit β not merely a benign abort). Any application that lets a user pick/supply a .bin/ggml model file (server, chat bot, plugin β a very common whisper.cpp deployment pattern) is exposed.
Suggested fix
In all three loader functions, validate immediately after reading n_dims:
if (n_dims < 1 || n_dims > GGML_MAX_DIMS) {
WHISPER_LOG_ERROR("%s: invalid n_dims (%d) in model file\n", __func__, n_dims);
return false;
}
Dedup / novelty check
Searched GitHub issues/advisories for ggml-org/whisper.cpp and public CVE trackers for "n_dims", "GGML_MAX_DIMS", "stack overflow model load", "tensor descriptor overflow". Found only:
- Issue #3807 ("null pointer dereference + assertion abort via zero-dimension model parameters") β a different root cause (zero
n_audio_state/n_audio_layerhyperparameters causing a nulltensor->dataand aGGML_ASSERTabort), not this bug. - CVE-2025-14569 (UAF in
read_audio_data, audio-input handling, unrelated to model-file parsing). - GGUF (
ggml/src/gguf.cpp) integer-overflow advisories (GHSA-vgg9-87g3-85w8, GHSA-3p4r-fq3f-q74v) β in llama.cpp's distinct GGUF parser, not the legacyggmlformat/loader whisper.cpp uses for its own model files.
No prior disclosure of this specific unbounded-n_dims/ne[4] stack overflow was found. Repository HEAD tested is current as of 2026-07-01.
Files in this repo
harness_whisper.cpp,harness_parakeet.cppβ libFuzzer/AFL-style harnesses calling the real public APImake_seed_whisper.pyβ seed/PoC generator for the whisper.cpp pathcrash_ndims7_whisper_model_load.binβ minimal PoC, whisper_model_loadcrash_ndims64_parakeet_model_load.binβ PoC, parakeet_model_loadasan_trace_whisper_model_load.txt,asan_trace_parakeet_model_load.txtβ full ASan crash traces