EnigmaConsultant's picture
PoC: whisper.cpp VAD loader uncaught exception (huntr MFV)
e48ed1b verified
|
Raw
History Blame Contribute Delete
6.64 kB
metadata
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:

    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:

    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:

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