Breaking Local AI Runtimes: 10 vulnerabilities in the Engine Behind Your Open-Source Models

Vladimir Tokarev
Ofek Itach
Cyera Research
August 7, 2026
Share

Key Findings

  • This blog represents a white paper of sorts for the DEF CON 34 (2026) talk, and more information can be found at https://info.defcon.org/defcon34/content/?id=66575.
  • Cyera Research found 10 vulnerabilities in llama.cpp, one of the most popular local inference engines. As we were unable to complete the disclosure process with the project maintainers, we coordinated with VulnCheck, who allocated 10 CVEs for the findings we reported (CVE-2026-43622 through CVE-2026-43632; CVE-2026-43625 was not used).
  • As of the 2026-06-01 patch check against build b9445 / gguf-v0.19.0, five of the ten llama.cpp CVEs were still unpatched, including both server UAFs (CVE-2026-43631 and CVE-2026-43632; CVSS 4.0 scores 9.2 / CVSS 3.1 scores 8.1). 
  • These vulnerabilities include among other: CVE-2026-43631 and CVE-2026-43632; CVSS 4.0 scores 9.2 / CVSS 3.1 scores 8.1. As a service to the community, we published patches for those remaining five findings at https://github.com/Vladimir-tokarev-cyera/llama-cpp-security-patches.

Introduction

The open-weight model landscape is evolving. Releases like Kimi-K3, GLM-5.2, and DeepSeek-V3 demonstrate that state-of-the-art capability (or near it)  is no longer locked behind API providers. Quantized variants of these models are published within days of each release, and people want to run them locally - for privacy, for cost, for control. That drives adoption of local inference tools across the board. Among those tools, llama.cpp is the most widely integrated engine: it is the backend behind Ollama, LM Studio, Jan, GPT4All, and hundreds of smaller projects. When you find vulnerabilities in llama.cpp, you are finding vulnerabilities in the engine that powers the majority of local inference deployments.

What is LLM inference

LLM Inference is the process of giving a trained language model some text and getting a response back. Under the hood, the model is a large file of numbers (called weights) that were learned during training - they encode the patterns the model picked up from its training data. When you run inference, you load those weights into memory, feed in your text (which gets converted into numeric tokens), and then run a series of mathematical operations through the model’s layers - this step is called the forward pass. The output is not deterministic - the model produces a probability distribution over possible next words, and the runtime samples from that distribution to pick each output token. This is why you can ask the same question twice and get slightly different answers.

In practical terms: inference is the stage where we take a binary file of weights, load it into memory, and use it to generate text one token at a time based on the input.

Figure 1: The LLM inference pipeline - a model file is loaded, a prompt is tokenized, the forward pass runs through model layers, and output tokens are sampled.

Remote vs local inference

Remote inference sends the prompt over a network to a hosted API - OpenAI, Anthropic, Google, OpenRouter, and similar services. The provider operates the hardware, manages the runtime, and applies whatever sandboxing and isolation they choose. Your data leaves your machine.

Local inference loads the model weights onto hardware you control and executes the entire inference pipeline on that machine. The prompt never leaves that machine (on which you run the inference). Privacy, cost control, and compliance requirements that prohibit sending data to third parties are the usual reasons teams choose local. Open-weight models such as DeepSeek-V3, Llama 4, Qwen3, and GLM-class releases made that choice practical for a wide range of workloads over the past two years.

Consider the scenarios where remote inference is not an option. An intelligence agency developing cyber capabilities cannot risk those techniques appearing in a third-party provider’s logs. A quantitative trading firm with a consistently profitable strategy cannot afford to expose its logic to an external API where it might be logged, audited, or leaked. A defense contractor processing classified material is prohibited by regulation from sending it off-premise. Or more broadly, any organization that treats its intellectual property as too sensitive to expose to a third-party provider. In each case the requirement is the same: the data must never leave the organization’s control, and that means inference must run locally.

What is llama.cpp

llama.cpp is a C/C++ inference library. It loads GGUF-format model weights (for more information on GGUF please refer to Bleeding Llama) , tokenizes prompts, executes the model’s layers (attention and matrix operations) on CPU or GPU, and exposes a C API that other tools build on top of. Public GitHub metrics show the project has surpassed 100,000 stars, and it has become the de facto standard for local LLM inference, with broad adoption across open-source projects.

Many products call into that library: desktop apps (LM Studio, Jan, GPT4All and others), mobile samples under the llama.android lineage, and HTTP front ends including the bundled llama-server. Those products are different codebases, but they all end up calling into llama.cpp’s C/C++ core. At that level, memory is allocated and freed by hand (malloc / free, new / delete). There is no garbage collector watching for mistakes, no automatic reference counting preventing use-after-free, and in several of the interfaces we audited, no proper locking to stop one thread from freeing memory while another thread is still using it.

This is the exact layer we audited in this research, similar to the research we executed on Ollama (Bleeding Llama) where Cyera again found vulnerabilities in the same layer. The question we asked was whether the runtime that evaluates open-weight models treats object lifetime, buffer sizes, and thread synchronization correctly when attacker-influenced inputs (prompts, model files, API timing) can drive the relevant code paths. Figure 2 presents the llama.cpp stack and the layer we took interest in. 

Figure 2: The llama.cpp stack. Every integration eventually (OS agnostic) reaches the memory management layer where objects are allocated and freed manually. That layer is what we audited.

Trust Boundaries

Given that llama.cpp is the shared native layer underneath all these products, we scoped our audit by mapping the transitions where safe-language code hands off to unsafe native code. Those transitions are a good place for finding bugs - one side assumes the object is still alive, the other side has already freed it. We identified three such boundaries worth focused review:

  1. JNI (llama.cpp Android integration). Java/Kotlin holds opaque native pointers into llama_model / llama_context (and related objects). Without reference counting or a lock covering free-vs-use, one thread can free an object another thread is still using.
  2. HTTP lifecycle (llama-server). The REST API is commonly running without authentication. With --sleep-idle-seconds N, idle teardown can free model/vocab state while worker threads continue handlers that still hold ctx_server.vocab.
  3. GGUF metadata. GGUF is the model file format used by llama.cpp. Its header contains metadata - tensor shapes, counts, and types - that the code uses to decide how much memory to allocate and how far to read. If those fields are trusted without comparing them to the actual payload length, you get OOB read/write or integer wrap.

The 10 llama.cpp CVEs

Auditing those three boundaries produced the following ten findings. Scores below are CVSS 4.0 base scores from the VulnCheck CVE records. Where useful, CVSS 3.1 is noted in parentheses.

Server UAFs were filed under GHSA-wwh3-vwx5-j8m7 and the related tokenize-endpoint report. At the time of our June 2026 re-check, those server issues were still present on master-tagged builds we tested.

How We Found the UAFs

We found these issues by reading the lifetime paths. In server-context.cpp, destroy() tears down the loaded model (and therefore the embedded vocabulary) for sleep, but handlers that passed wait_until_no_sleep() can still call into tokenization with a stale ctx_server.vocab. In the Android JNI sample, global native pointers are shared across JNI entry points; bench_1model can run while free_1context frees the context those calls use.

The mental model we used while inspecting the code: first, we understand the happy-flow - how loading and normal operation look on a single thread. Then we split that flow into two or more concurrent threads and place the stages next to each other on a logical timeline. This is where you can sometimes see that if a free occurs at a specific moment, you have a potential UAF. Once we see that on paper, we go to the code and try to understand whether we can actually create that interleaving. If we can, we build a test. Reading the code with this multi-threaded mental model makes UAF cases visible - not all of them are triggerable, but some are. Here are two cases this methodology led to. Visually if represented it can look like: 

Figure 3: The mental model applied to the Android JNI case. Background thread runs bench_model (holding g_context), UI  Thread  calls unload (freeing g_context). The overlap is where the UAF occurs.

Bug 1: Android JNI Race (CVE-2026- 70640 CVSS 7.3)

The JNI API

You’re probably asking yourself, who runs inference on their Android phone? So it happens, and it happens a lot. Zuza, OfflineLLM, LocalMind, PocketPal AI, and Xirea are all shipping Android apps that run GGUF models locally via llama.cpp - no cloud, no internet required. Beyond standalone apps, SDKs like llama-tools-aisee and Llamatik let any developer add local inference to their Android app in a few lines of Kotlin. The official llama.android sample from the llama.cpp repo has been copied into dozens of projects as the reference integration. All of them link against the same native library.

llama.cpp exposes libllama.so - the shared native library that Android apps link against for local inference. It handles model loading and parsing (GGUF format), context management, tokenization, batch processing, and sampling. Having all of that in a single library makes it straightforward for any Android project to add local LLM capabilities.

The official sample JNI wrapper (llama-android.cpp, builds b1886-b7445) was widely adopted by third-party projects as a reference for how to integrate libllama.so into a Kotlin/Java app. The JNI names are mangled with _1 for underscores (for example bench_1model, free_1context); below we use the logical names.

The wrapper exposed methods for each stage of the lifecycle: initializing the backend (backend_init), loading a model from a GGUF file (load_model), creating an inference context (new_context), and allocating a batch buffer (new_batch). For inference itself, completion_init tokenizes the prompt and completion_loop generates one token per call. For benchmarking, bench_model runs decode iterations and reports throughput. And for cleanup: free_context, free_batch, free_model.

all. For benchmarking, bench_model runs decode iterations and reports throughput. And for cleanup: free_context, free_batch, free_model.

How a simple app uses these

A basic Android app that loads a model and generates a response follows this sequence:

On a single thread, this works fine. Each step finishes before the next one starts, and by the time free_context runs, nobody is using the context anymore.

Lifetime mismatch across JNI

Here is the problem. Every one of these methods operates on the same set of process-global native pointers. There is no per-call handle or opaque object passed back to Java - the native side just reads and writes the globals directly. The code was written with multi-threading in mind (a std::mutex exists, and some paths do acquire it), but specific critical cases were overlooked: the lock does not cover the window between free_context deallocating the context and bench_model or completion_loop finishing their use of it. The dangerous interleavings - free running concurrently with decode - are exactly the ones left unprotected.

A std::mutex existed in the sample, but the free-during-bench race we exploited was not closed by locking around both the free path and the decode path that uses the context.

What goes wrong with threads

In a real app, these calls do not happen on a single thread. The Android UI thread needs to stay responsive, so inference runs on a background thread. Navigation events, lifecycle callbacks, and user actions happen on the UI thread. That means:

  • A background thread calls bench_model or completion_loop, which internally uses g_context for the entire duration of the call.
  • The UI thread calls free_context when the user navigates away, closes the chat, or the activity is destroyed.
  • Both threads touch the same g_context global pointer. There is no lock that prevents free_context from running while bench_model is mid-execution.

The result: free_context deallocates the 648-byte llama_context object while the background thread still holds a pointer to it. The next time the background thread dereferences that pointer (for example, during a virtual method call inside llama_decode), it reads freed memory.

Exploitation: from free to code execution

This section gets technical, so here is the short version first. We achieve code execution in five steps. We free the context, leaving llama_context as a dangling pointer. We spray the heap to reclaim that freed chunk and simultaneously place our COOP gadget objects in nearby memory. Once the reclaim lands, we have planted a pointer at offset 0x118 that leads into our gadget chain. When the background thread resumes and calls through the dangling pointer, it follows our chain of fake objects until it reaches dlsym and calls back into Java with full app privileges.

Figure 4: Android JNI exploit flow: from free to code execution in five steps.

The longer version:

Step 1 - The free. On our ARM64 demo, llama_context was 648 bytes. When free_context deallocates it, that 648-byte chunk goes back to the allocator. On Android (Scudo/jemalloc-class behavior, not glibc tcache), a same-sized allocation from the same thread can reclaim that exact chunk.

In practice, the attacker does not need to call free_context from their own code. They just need to trigger any app behavior that results in the free while inference is still running on another thread. That could be a user navigating away from the chat screen (which destroys the activity and frees the context), an idle timeout kicking in, a model-switch request that tears down the current context before loading a new one, or even free_model being called due to storage pressure. Any of these paths deallocate the 648-byte object while the background thread still holds a pointer to it.

Step 2 - The spray. Once the context is freed, we need to fill that 648-byte hole with data we control. The allocator reuses recently-freed chunks of the same size, so if we can trigger enough allocations of ~648 bytes, one of them will land at the exact address the context used to occupy. The background thread (still running bench_model or completion_loop) never noticed the free, so it still uses the old address, which now contains our data instead of a real llama_context.

The attacker can trigger these allocations through the app’s own API without needing arbitrary code execution in the process. Calling completion_init with prompts of a specific length causes the tokenizer to allocate internal buffers in the right size class. Calling new_batch with tuned n_tokens and n_seq_max parameters produces arrays that match. Even just flooding the app with repeated inference requests (if it accepts external input through a chat interface or document pipeline) generates enough internal allocations at the right size to reclaim the freed chunk. The attacker only needs the ability to influence inputs that reach the JNI layer.

Step 3 - Planting the fake vtable. Inside llama_context, offset 0x118 holds the memory pointer, which points to a llama_memory object whose virtual methods get called during decode. We write a pointer at +0x118 in our spray buffer that points to a second controlled region (our fake object). That fake object’s first 8 bytes are a vtable pointer that points to a fake vtable we also control; at slot 6 (offset +0x30 in the vtable) we place the address of our payload function. When bench_model resets the KV cache by calling llama_memory_clear(mem, false), it dispatches mem->clear(false) through that vtable slot and lands on our address.

We chose offset 0x118 because it is the earliest virtual call in the post-free code path (bench_model -> llama_memory_clear -> clear()). The earlier the dispatch, the less chance the code crashes on an unrelated field read before reaching our controlled call. That said, llama_context has other exploitable fields: it holds pointers to objects used during sampling, encoding, and KV cache operations, each with their own virtual methods. There are also raw function pointer fields (callbacks, custom allocators) that would be simpler targets since you overwrite the pointer directly without building a two-level fake vtable structure. We went with 0x118 because the call chain to reach it is short and deterministic.

Step 4 - The trigger. bench_model on the background thread resets the KV cache before each benchmark iteration by calling llama_memory_clear(mem, false). That function dispatches the virtual call mem->clear(false), which loads the vtable pointer from our fake object, reads slot 6 at vtable offset +0x30, and jumps to our controlled address, achieving code execution.

We used bench_model because it gives the widest race window - it runs many iterations in a loop, each of which calls llama_memory_clear to reset the KV cache. But it is not the only trigger. completion_loop calls llama_decode internally on every generated token, which also dereferences the freed context. completion_init also accesses the context during prompt evaluation. Really, any JNI method that dereferences g_context after the free has landed will trigger the UAF. The question is only which call is mid-flight when the free happens. bench_model gives you the widest window; A completion_loop within a generation loop is another plausible scenario.

Step 5 - From vtable hijack to arbitrary execution (COOP + ASLR defeat). At this point we control which address the virtual call jumps to, but we have not loaded or injected any shellcode into the process. There is no writable-and-executable memory region we can simply write a payload into. So instead of injecting new code, we reuse code that already exists in the process.

The technique is called COOP (Counterfeit Object-Oriented Programming). The idea: libllama.so and the JNI runtime are both mapped into the same process address space, full of C++ objects with virtual methods. Each virtual method is a “gadget” we can redirect to by building fake objects that chain one virtual call into the next. By constructing a sequence of fake objects where each one’s virtual dispatch does one useful thing (load a register, set up an argument, call the next fake object), we can build a chain that eventually lands in dlsym to resolve a JNI callback or internal runtime function, achieving code execution within the app’s own privilege context. On modern Android, system() is blocked by seccomp and SELinux, so the practical target is not a shell command but a JNI-accessible entry point - for example, invoking a method that reads app-private files, exfiltrates credentials stored in shared preferences, or manipulates the app’s own network stack.

To lay out these fake objects in memory, we use the same spray technique from Step 2. The allocations we trigger through completion_init and new_batch give us controlled buffers on the heap. Their addresses are not predictable on their own, but once we defeat ASLR (next paragraph), we can derive heap region locations from the leaked pointers. We place the COOP chain across those buffers: the initial fake object in the reclaimed context chunk points to a second fake object in a spray buffer, which chains to a third, and so on until the final gadget calls into the resolved JNI target. The spray is both our reclaim primitive and our write primitive for the entire chain.

For this to work we need to know where libllama.so and libc are loaded in memory, which means defeating ASLR. The Android JNI sample does not expose state save/restore (so CVE-2026-43630, the OOB read we found in the core library’s recurrent state path, is not reachable here). Instead, two options are available in this context. First, a partial pointer overwrite: ASLR randomizes the library base, but offsets within a page (the low 12 bits) are fixed. If we only need to redirect execution to a different function within libllama.so itself, we can overwrite just the low bytes of a pointer without knowing the full base. Second, error-path pointer leaks: ai_chat.cpp logs through LOGe/LOGi which write to Android’s logcat. On error paths (null model, failed context creation), these logs can include pointer-derived values. Any code running in the same process can read logcat, giving the attacker a leaked address from which to compute the library base. With either approach providing enough address information, the COOP chain targets known offsets and achieves code execution under the libllama.so process context.

Patch status: b7446 removed the old llama-android.cpp path (5c0d18881). The replacement binding (ai_chat.cpp) still uses unsynchronized native globals at the C++ layer; Kotlin limitedParallelism(1) serializes some call sites but does not make direct native calls from other code in-process safe. We therefore mark CVE-2026- 70640 as partially patched.

Bug 2: Server Sleep UAF (CVE-2026-43631 and CVE-2026-43632)

What is llama-server

llama-server is the HTTP inference server bundled with llama.cpp. It exposes OpenAI-compatible and Anthropic-compatible REST APIs for chat completions, embeddings, tokenization, and reranking. It supports parallel decoding for multiple concurrent users, continuous batching, function calling, and speculative decoding. It runs without authentication by default, typically bound to 0.0.0.0:8080.

Who runs it: teams that want a self-hosted OpenAI-compatible endpoint (so their existing code that calls api.openai.com works by changing one URL), developers running local inference during development (point your IDE copilot at localhost:8080), organizations that need an internal LLM API without sending data to third parties, and GPU box operators serving multiple users from one loaded model.

How it gets used

A typical deployment starts the server with a model file and a port:

Clients then send HTTP requests. Because the API is OpenAI-compatible, any application that already uses the OpenAI SDK works out of the box by changing the base URL:

The server tokenizes the input, runs inference, and streams tokens back. Multiple clients can use it concurrently. Available endpoints include /v1/chat/completions, /v1/messages (Anthropic format), /completion, /tokenize, /detokenize, /embeddings, /rerank, and /infill.

Figure 5: The server accepts concurrent HTTP requests on worker threads. The main loop monitors idle time and can call destroy() to free the model. If a worker is mid-request when destroy runs, it accesses freed memory.

The sleep-idle feature

When you run llama-server with --sleep-idle-seconds N, the server monitors for inactivity. If no requests arrive for N seconds, it unloads the model from memory (frees the model, context, and KV cache) to save GPU/RAM. When a new request comes in, it reloads the model automatically. up until now seems like a really good feature.

This exists for shared GPU boxes where multiple models need to take turns using VRAM, or for cost optimization on cloud instances where you pay per hour of GPU memory. The feature was introduced in PR #18228 and the server documentation describes it as: “the model and its associated memory are unloaded from RAM to conserve resources.”

The problem: “unloads from RAM” means calling destroy() which actually frees the heap allocations. If an HTTP request is mid-flight when that happens, the worker thread is left holding pointers into freed memory. That is the bug.

How a user interacts with the server

A typical session looks like this. The user (or their application) sends a chat completion request:

The server tokenizes the input (this is where it reads the vocabulary), runs inference through the model layers, and streams output tokens back. The user sends follow-up messages for multi-turn conversation. Between turns, if the server has been idle long enough and sleep is enabled, the model gets unloaded. The next request triggers a reload.

The server also supports the Anthropic message format (this is the endpoint the exploit spray uses):

Beyond chat completions, there are utility endpoints that access the vocabulary directly without running full inference: /tokenize (converts text to token IDs), /detokenize (converts tokens back to text), /embeddings, /rerank, and /infill. These are relevant because CVE-2026-43632 specifically targets them - they access ctx_server.vocab outside the main task queue, making the race window easier to hit.

All of these endpoints internally access the loaded model’s vocabulary to tokenize input. That vocabulary is what gets freed during sleep.

Prerequisite

Remote triggering of this bug requires llama-server running with --sleep-idle-seconds set to a positive value. Without that feature, the sleep teardown path that frees the model while HTTP workers continue is not in play.

What sleep does wrong

When idle long enough, the server calls destroy(), which resets the model ownership (llama_init.reset()), nulls some pointers (ctx, model), and does not null the cached vocab pointer used by request handlers. After teardown, that pointer is dangling.

The race window

Applying the same mental model from the Android case here on a single thread, the happy flow is straightforward. A request arrives, the handler checks that the server is awake, tokenizes the input, runs inference, returns the response. Then the server goes idle, eventually sleeps, frees the model, and waits for the next request to reload. No overlap, no problem. But when you split this into two concurrent events (the HTTP worker thread handling requests and the main loop managing sleep state), and place their stages next to each other on a timeline, you can immediately see the issue: the worker checks “awake”, releases the lock, starts slow work (parsing a large body), and in that window the main loop decides to sleep and frees everything. By the time the worker reaches tokenization, it is reading freed memory.

In code terms: handlers call wait_until_no_sleep(), observe that the server is awake, release synchronization, then parse and process the request body. The main loop can enter sleep and call destroy() during that gap. Tokenization then uses ctx_server.vocab.


Figure 6: The server sleep UAF race. The HTTP worker checks that the server is awake and starts parsing a large request body. During that slow operation, the main loop enters sleep and frees the 17,816-byte model (including vocab.pimpl at offset 0x43D0). When the worker finally calls tokenize_mixed(), it reads through a dangling pointer into freed heap.

AddressSanitizer confirms the cross-thread free/use: free stack in server_context_impl::destroy() on the main thread; use stack in the tokenizer on an HTTP worker (for example llm_tokenizer_spm_session::try_add_bigram via tokenize_mixed).

When reclaim fails and pimpl was cleared by unique_ptr teardown, faults often land near NULL + small field offsets (we observed offsets in the 0x38 / 0x48 range depending on which field the tokenizer touched first). When reclaim succeeds, the faulting load uses the sprayed pointer base instead.

Heap spray via /v1/messages

On Linux glibc, a freed 17,816-byte chunk is reusable across threads. We sprayed through the Anthropic-compatible /v1/messages handler with message bodies sized to that class (about 17,800 bytes per message, tens of messages per request, multiple concurrent clients). Conversion helpers that copy message content into new std::strings increase allocation pressure in the same size class.

The /completion endpoint works the same way. The prompt field in the request body becomes a std::string allocation on the heap, and the attacker controls its length. Sending a /completion request with a ~17,800-byte prompt creates an allocation in the same size class as the freed model. This gives the attacker a second spray path that does not depend on the Anthropic-format handler or convert_anthropic_to_oai(). In practice we used both endpoints in parallel to maximize reclaim pressure.

Spray pattern used in the successful reclaim run: repeating 0x5A5A5A5A5B5B5B5B. After a hit, GDB showed that value at the model address + 0x43D0 (vocab.pimpl). At the crash site, register X0 held the spray pattern and the faulting instructio was a load of the form:

ldr w0, [x0, #0x48]    ; read field at sprayed_base + 0x48

That is a controlled dereference: the CPU loads from an address derived from attacker-controlled heap contents. We achieved a successful reclaim roughly once every 100 attempts, at which point we stopped. Turning this into a full RCE chain would still require an ASLR defeat and constructing a fake llama_vocab::impl that survives the tokenizer’s subsequent reads, but the controlled dereference primitive is proven.

On macOS, aggressive zeroing and magazine allocation make the same reclaim strategy much harder.

CVE-2026-43632

The same dangling vocab after sleep also affects handlers that touch vocabulary outside the main completion task queue, including:

  • POST /tokenize
  • POST /detokenize
  • POST /infill
  • POST /apply-template
  • POST /rerank
  • POST /anthropic/count_tokens

VulnCheck issued a separate CVE because the entry points differ; the actual fix is the same: do not leave a live vocab pointer after destroy, and do not tokenize without holding a consistent view of model lifetime under the sleep lock.

Same class of bug as Android, different exploit geometry

llama_vocab is embedded in llama_model, so reclaiming pimpl means reclaiming the whole model allocation and writing controlled bytes at offset 0x43D0.

We reported these issues through GitHub Security Advisories starting in July 2025. Several advisories were closed without fixes. MITRE never assigned CVEs despite months of follow-up. We eventually went to VulnCheck, who allocated the ten CVE IDs on 2026-05-07. As of our June 2026 re-check, five of the ten were still unpatched on the latest build.

Disclosure Timeline

  • 2025-07-13: Core llama_batch_init overflow reported to llama.cpp (GHSA-wwq5-4jr6-6m93).
  • 2025-10-20: MITRE contacted for CVE assignment on multiple llama.cpp issues.
  • 2025-10-22 to 2025-11-04: MITRE request IDs issued for several reports.
  • 2025-12-17: Android JNI rewrite landed (5c0d18881, b7446).
  • 2025-12-21: Server sleep-idle UAF paths introduced (ddcb75dd8, from b7492).
  • 2026-01-05: Batch-init advisory closed without a fix.
  • 2026-01-07: Android JNI advisories submitted; closed same day after rewrite context.
  • ~2026-04: Server sleep UAF reported (GHSA-wwh3-vwx5-j8m7).
  • ~2026-05: Server sleep UAF advisory closed without a fix.
  • 2026-05-07: VulnCheck reserves CVE-2026-43622 through CVE-2026-43632 (no 43625).
  • 2026-06-01: Re-check: five CVEs still unpatched on b9445; CVE-2026-43630 patched in gguf-v0.19.0.

What to Do

llama-server

  • Do not enable --sleep-idle-seconds on any host that accepts untrusted HTTP until the UAF is fixed and you have verified the build.
  • Do not expose the API on 0.0.0.0 without an authenticating reverse proxy or equivalent control plane.
  • Treat repeated worker crashes with faults at low addresses (NULL + small offsets) after idle periods as a possible sleep-UAF signal worth correlating with that flag.

Android / JNI integrators

  • If your app still uses the old llama-android.cpp JNI wrapper (anything before build b7446), replace it. That code has the UAF we exploited.
  • If you have already moved to the newer ai_chat.cpp bindings, be aware that the native C++ side still has no locking on its global pointers. The Kotlin limitedParallelism(1) dispatcher serializes calls from Kotlin, but any other native code in the same process (a third-party SDK, another JNI library) can still race against it. Audit your native layer for concurrent access.

Library consumers of llama_batch_init and state restore

  • CVE-2026-43627 (unchecked n_seq_max multiply) remained unpatched on our June 2026 check; validate inputs at the API boundary.
  • CVE-2026-43629 (KV cache restore overflow) remained unpatched; do not restore slot/state blobs from untrusted writers.

Patches

We published patches for the five unpatched findings at https://github.com/Vladimir-tokarev-cyera/llama-cpp-security-patches.

Closing

CWE-416 (use after free), CWE-190 (integer overflow), and CWE-125 (out-of-bounds read) are familiar bug classes. Shipping them behind local AI product surfaces changes who can reach them: unauthenticated HTTP on a GPU box, or a malicious model file in a mobile sample, rather than a classic remote daemon people already treat as hostile.

We are presenting the exploitation detail and operator guidance for this work at DEF CON 34. Demo recordings in the research package cover the Android vtable hijack and the server ASAN/reclaim run (controlled pimpl load). Patches for the five unpatched CVEs are available at https://github.com/Vladimir-tokarev-cyera/llama-cpp-security-patches.

For our related Ollama research (CVE-2026-7482, CVSS 9.1), see Bleeding Llama: Critical Unauthenticated Memory Leak in Ollama.

Acknowledgment

Special thanks to Wade Sparks and the team at VulnCheck for promptly allocating the llama.cpp CVE IDs, being highly responsive and collaborative throughout the process, and serving as an excellent CNA partner from start to finish.

Share