DianTrick's picture
add attack scenario section
7885736 verified
|
Raw
History Blame Contribute Delete
8.56 kB
---
license: mit
tags:
- security
- vulnerability-research
- proof-of-concept
- llama.cpp
- gguf
- not-a-real-model
viewer: false
inference: false
extra_gated_prompt: >-
This repository contains a deliberately malformed GGUF file used only to
demonstrate a memory-safety bug in llama.cpp for coordinated disclosure.
---
# ⚠️ Security PoC β€” NOT a usable model
**This is not a language model.** `evil_dflash.gguf` is a **deliberately crafted, malformed
DFlash draft GGUF** whose only purpose is to trigger a heap out-of-bounds **write** in
[`ggml-org/llama.cpp`](https://github.com/ggml-org/llama.cpp) when the victim runs DFlash
speculative decoding with it. It produces no useful output and must not be used for inference.
It is published here solely as the reproducible proof-of-concept artifact for a coordinated
vulnerability report (huntr / GGUF model-file-format bounty). Do not load it with any llama.cpp
build you care about.
---
## Vulnerability summary
- **Project:** `ggml-org/llama.cpp` (commit `c15c5c7`; the vulnerable line is also on `master`
HEAD `e3546c7`).
- **Weakness:** CWE-190 (integer overflow) β†’ CWE-787 (out-of-bounds write).
- **Component:** DFlash speculative decoding (`common/speculative.cpp`), DFlash arch loader
(`src/models/dflash.cpp`). DFlash was added in llama.cpp PR #22105.
A DFlash draft model's GGUF metadata key `dflash.target_layers` is an `int32` array whose length
is attacker-controlled and **unbounded** (read via the vector overload of
`llama_model_loader::get_arr`, no cap). That length feeds a **32-bit signed multiply**:
```cpp
// common/speculative.cpp:941
n_embd_enc = (int32_t) target_layer_ids_n * n_embd_tgt; // 1048577 * 4096 == 2^32 + 4096 β†’ wraps to 4096
```
The wrapped width under-sizes a heap buffer, but the feature-copy loop then runs the **full,
un-wrapped** count (`~1.05M` iterations), writing target-model activation slices past the end of
the allocation:
```cpp
// common/speculative.cpp:1050-1059
features_buf.resize((size_t) n_chunk * n_embd_enc); // sized from the wrapped 4096
for (uint32_t k = 0; k < target_layer_ids_n; ++k) { // full ~1.05M
...
std::memcpy(dst, src, (size_t) n_embd_tgt * sizeof(float)); // heap OOB WRITE
}
```
The per-iteration `if (!layer) GGML_ABORT` guard is bypassed by filling `target_layers` with a
single valid target layer id (e.g. `0`) repeated. EAGLE3 uses the same fused-encoder pattern but
rejects any `target_layers` not exactly length 3 (`src/models/eagle3.cpp:9-11`); **DFlash has no
such cap.**
The attacker controls *how far* the writes run past the buffer; the written bytes are the target
model's per-layer activations (not attacker-chosen bytes). Demonstrated impact is a **deterministic
heap out-of-bounds write / memory corruption / crash**. Code execution is not demonstrated.
### Configuration gate (honest scope)
DFlash is **not** enabled by default. The victim must run llama.cpp with both:
```
--model-draft <this evil_dflash.gguf> --spec-type draft-dflash
```
Once configured, the first ordinary text-generation request trips the write. This is a
*malicious-draft-model* bug in llama.cpp's documented untrusted-GGUF threat model, with the added
precondition that DFlash decoding is turned on.
---
## Attack scenario
DFlash is a newly merged speculative-decoding mode promoted for inference speed-ups, so users have a
concrete reason to download and run third-party DFlash draft models from model hubs β€” exactly as they
already do for main models. An attacker publishes a normal-looking DFlash draft whose
`dflash.target_layers` is a valid target layer id (e.g. `0`) repeated ~1.05 million times. A victim
who adopts DFlash speculative decoding starts their server or CLI with
`--model-draft <attacker>.gguf --spec-type draft-dflash` against any target model of hidden size 4096.
On the **first ordinary generation request**, the 32-bit encoder-width multiply wraps, `features_buf`
is under-sized, and the copy loop writes ~1.05M target-activation slices past the allocation β€” a
deterministic heap out-of-bounds write at `common/speculative.cpp:1059`. The victim's prompt is only
the trigger; the corruption's extent is fixed entirely by the attacker's draft metadata. This is
llama.cpp's documented untrusted-model threat model, with the single extra precondition that DFlash
decoding is enabled (off by default).
---
## Files in this repo
| File | What it is |
|---|---|
| `evil_dflash.gguf` | **The malicious DFlash draft** (390 MB): `target_layers` = 1,048,577 Γ— `0`, `fc.weight` shaped `{4096,4096}` to survive load-time validation. |
| `minimal_target_4096.gguf` | A tiny (0.36 MiB) benign 1-layer target model, hidden size 4096, used as the DFlash target in the live PoC. |
| `make_evil_dflash_gguf.py` | Regenerates `evil_dflash.gguf` from scratch (deterministic). |
| `make_minimal_dflash_target_gguf.py` | Regenerates `minimal_target_4096.gguf`. |
| `dflash_overflow_poc.cpp`, `build_dflash_poc.sh`, `dflash_asan.log` | **PoC 1** β€” focused arithmetic harness reproducing `speculative.cpp:941/1050/1059` verbatim (no llama.cpp build needed). |
| `dflash_loader_poc.c`, `build_dflash_loader.sh`, `dflash_loader.trimmed.log` | **PoC 2** β€” loads `evil_dflash.gguf` through the real ASAN `libllama.so`; proves the load-time wrap + `fc.weight`-shape bypass are real (`LOAD OK`). |
| `dflash_live_poc.cpp`, `build_dflash_live_poc.sh`, `dflash_live_asan.log` | **PoC 3** β€” loads both real GGUFs, runs llama.cpp's compiled `common_speculative_process()`, ASAN catches the OOB write at the real `common/speculative.cpp:1059`. |
| `run_dflash_live_server.sh` | Optional: drives the full `llama-server` HTTP wrapper (run outside a network-restricted sandbox). |
---
## Reproduce
Prerequisite: build llama.cpp `@ c15c5c7` with AddressSanitizer:
```bash
git clone https://github.com/ggml-org/llama.cpp && cd llama.cpp
git checkout c15c5c7
cmake -B build-asan -DLLAMA_SANITIZE_ADDRESS=ON -DGGML_NATIVE=OFF
cmake --build build-asan -j # llama + ggml shared libs are enough
```
Then, from this repo's directory, point the scripts at **your** llama.cpp checkout and its ASAN
build via the `LLAMA_CPP_DIR` / `BUILD_DIR` environment variables (no need to edit the scripts).
The model generators use a pip-installed `gguf` if present (`pip install gguf`), otherwise the
`gguf-py` inside `LLAMA_CPP_DIR`:
```bash
export LLAMA_CPP_DIR=~/llama.cpp # your checkout @ c15c5c7
export BUILD_DIR=$LLAMA_CPP_DIR/build-asan # the -DLLAMA_SANITIZE_ADDRESS=ON build
pip install gguf # (or rely on $LLAMA_CPP_DIR/gguf-py)
# PoC 1 β€” arithmetic + OOB, no llama.cpp build needed (fully standalone)
bash build_dflash_poc.sh # β†’ dflash_asan.log: heap-buffer-overflow WRITE, wrapped width = 4096
# PoC 2 β€” malicious model loads through the real loader
python3 make_evil_dflash_gguf.py # (re)generate evil_dflash.gguf (or use the uploaded one)
bash build_dflash_loader.sh # β†’ "[poc] LOAD OK ... n_embd=4096"
# PoC 3 β€” real two-model DFlash path hits the real sink
python3 make_minimal_dflash_target_gguf.py
bash build_dflash_live_poc.sh # β†’ dflash_live_asan.log: OOB WRITE at common/speculative.cpp:1059
```
Expected PoC 3 output (abridged):
```
common_speculative_impl_draft_dflash: - block_size=16, mask_token_id=-1, n_extract=1048577
[poc] target decode OK; entering common_speculative_process
==ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 16384 at 0x... 0 bytes to the right of 32768-byte region
#2 common_speculative_impl_draft_dflash::process ... common/speculative.cpp:1059
#3 common_speculative_process ... common/speculative.cpp:2508
allocated by ... common/speculative.cpp:1050
```
---
## Suggested fix
Bound the `target_layers` length and perform the width math in 64-bit, **rejecting** (not
saturating) oversized input, at both the load-time (`src/models/dflash.cpp:14`) and runtime
(`common/speculative.cpp:941`) multiplies. A small cap on the layer count is the cleanest fix β€” real
DFlash models extract only a handful of layers.
## Disclosure
Reported via coordinated disclosure. No public duplicate was found as of 2026-07-12 (distinct from
PR #25513, which is the unrelated `block_count`/`n_layer_all` family β€” different file, root cause,
and fix boundary). This "no match" is time-bounded and cannot rule out a private report.
*Security research artifact. Provided as-is for defensive/coordinated-disclosure purposes only.*