| --- |
| 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 |
| |