EnigmaConsultant's picture
PoC: whisper.cpp VAD loader uncaught exception (huntr MFV)
e48ed1b verified
|
Raw
History Blame Contribute Delete
6.64 kB
---
license: other
tags:
- security
- model-file-vulnerability
- whisper.cpp
- ggml
---
# Uncaught C++ Exception / Process Abort via Unvalidated Length Fields in whisper.cpp's Silero-VAD 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 function:** `whisper_vad_init_with_params()` / its header-parsing block, `src/whisper.cpp:4796-4850`
**Reachable via public API:** `whisper_vad_init_from_file_with_params()` (`src/whisper.cpp:4731`), and transitively `whisper_full()` whenever `params.vad = true` and an attacker influences `vad_model_path` (`src/whisper.cpp:6667`).
## Summary
`whisper_vad_init_from_file_with_params()` parses a second, distinct `ggml`-magic binary model file (the Silero VAD model, e.g. `ggml-silero-v5.1.2.bin`). Unlike `whisper_init_with_params_no_state()` (the main ASR loader), which explicitly wraps its model-loading call in `try { ... } catch (const std::exception &) { ... }` specifically "so that this can be caught by langbindings (Rust via whisper-rs, Go via cgo, ...)" (see comment at `src/whisper.cpp:3724`), **`whisper_vad_init_with_params()` has no such guard**. Several attacker-controlled length/count fields are used directly, unchecked, in ways that can throw C++ exceptions that then propagate uncaught out of the public API and terminate the whole process:
1. **`str_len` (model-type string), `src/whisper.cpp:4798-4802`:**
```cpp
int32_t str_len;
read_safe(loader, str_len);
std::vector<char> buffer(str_len + 1, 0);
loader->read(loader->context, buffer.data(), str_len);
std::string model_type(buffer.data(), str_len);
```
If the file sets `str_len = -1`, `str_len + 1 == 0` so `buffer` is allocated with size 0 (`buffer.data()` may be `nullptr`), while the `std::string` constructor is then called with `str_len` sign-extended to `SIZE_MAX`. libstdc++ detects this specific misuse and throws `std::logic_error("basic_string: construction from null is not valid")`, which is never caught β†’ `std::terminate()` β†’ `SIGABRT`.
2. **`n_encoder_layers`, `src/whisper.cpp:4822-4826`:**
```cpp
read_safe(loader, hparams.n_encoder_layers);
hparams.encoder_in_channels = new int32_t[hparams.n_encoder_layers];
hparams.encoder_out_channels = new int32_t[hparams.n_encoder_layers];
hparams.kernel_sizes = new int32_t[hparams.n_encoder_layers];
```
A negative `n_encoder_layers` sign-extends to an enormous `size_t` array-new request, which always exceeds any real system's addressable/available memory and throws (`std::bad_array_new_length`/`std::bad_alloc`), again uncaught β†’ abort.
Both are symptoms of the same missing-validation-and-missing-catch pattern (no upfront sanity check on any VAD header field, no top-level exception guard), so this is reported as **one finding** with two triggering fields as evidence, not two separate bugs.
## Proof of concept
Two minimal files, both starting with the `ggml` magic:
- `crash_str_len_neg1.bin`: `magic | str_len=-1 | major/minor/patch | n_window/n_context` β†’ triggers the `std::logic_error` path.
- `crash_n_encoder_layers_neg1.bin`: `magic | str_len=4,"AAAA" | major/minor/patch | n_window/n_context | n_encoder_layers=-1` β†’ triggers the `new[]`/allocation-failure path.
`harness_vad.cpp` calls the exact public API:
```cpp
struct whisper_vad_context_params cparams = whisper_vad_default_context_params();
cparams.use_gpu = false;
struct whisper_vad_context * ctx = whisper_vad_init_from_file_with_params(argv[1], cparams);
if (ctx) whisper_vad_free(ctx);
```
Output (`trace_str_len_neg1.txt`):
```
terminate called after throwing an instance of 'std::logic_error'
what(): basic_string: construction from null is not valid
```
Output (`trace_n_encoder_layers_neg1.txt`):
```
==...==ERROR: AddressSanitizer: requested allocation size 0xffffffffffffffff (0x800 after adjustments...) exceeds maximum supported size of 0x10000000000
#0 ... in operator new[](unsigned long)
#1 ... in whisper_vad_init_with_params src/whisper.cpp:4824:39
==...==ABORTING
```
This is a genuine uncaught-allocation-failure crash, not an artifact of ASan's allocator ceiling: a request derived from a negative/corrupted length always exceeds any real system's address space regardless of sanitizer, so in a non-ASan production build the same input throws `std::bad_alloc`/`std::bad_array_new_length` uncaught and aborts identically.
## Impact
Denial of service: any application that loads a VAD model file supplied or influenced by an attacker (e.g. a "bring your own VAD model" feature, or a shared/download-cached model directory) can be crashed outright by two independent single-field mutations, with no possibility for the embedding application to catch/recover (unlike the main ASR loader, which explicitly supports this via its `try/catch`). This is a correctness/availability bug (CWE-248 Uncaught Exception / CWE-20 Improper Input Validation), not memory corruption β€” reported at the appropriate (lower) severity tier.
## Suggested fix
- Validate `str_len >= 0` (and apply a sane upper bound) before allocating/reading/constructing the string.
- Validate `n_encoder_layers > 0` (and apply a sane upper bound) before the `new[]` calls.
- Wrap `whisper_vad_init_with_params()`'s model-loading body in the same `try { ... } catch (const std::exception &)` pattern already used by `whisper_init_with_params_no_state()`.
## Dedup / novelty check
No existing GitHub issue/advisory found for uncaught exceptions in `whisper_vad_init_with_params` / VAD model loading (this code path was only added recently β€” VAD support and the `whisper_vad_*` API β€” and predates a security-focused audit). Distinct from Issue #3807 (zero-dimension hparams in the *main* ASR loader, which *is* wrapped in try/catch and instead hits a `GGML_ASSERT`) and from the `n_dims`/`ne[4]` stack-overflow finding filed alongside this one (different function, different flaw class: uncaught exception/DoS here vs. out-of-bounds write there).
## Files in this repo
- `harness_vad.cpp` β€” harness calling the real public VAD-init API
- `make_vad_seed.py` β€” PoC/seed generator
- `crash_str_len_neg1.bin`, `crash_n_encoder_layers_neg1.bin` β€” minimal PoCs
- `trace_str_len_neg1.txt`, `trace_n_encoder_layers_neg1.txt` β€” crash output