Knowledge and some analysis for CVE-2026-34159

Knowledge and some general analysis with RCE vulnerability in RPC Backend of an open source AI - llama.cpp

Author HungNguyen

#1. Tác dụng của các file:

script: dọn file rác grammars: quy tắc về định dạng đầu ra (json, python,..) model: các model của AI docs: file hướng dẫn tests: lab check model scripts: tool vặt cmake: bản thiết kế ggml: tính toán phép toán ma trận src: kết nối các phép toán thành 1 luồng suy nghĩ (trong một promt việc tính toán của AI là rất nhiều và nó sẽ cần kết nối chúng lại để thành output hoàn chỉnh) common: code để in thông báo lỗi, đọc thông số từ người dùng examples: các chương trình hoàn chỉnh để test

#2. Tensor là gì? Basic

Tensor là mảng dữ liệu lưu trữ đa chiều, được sử dụng để lưu trữ dữ liệu của model và metadata cho các cách sử dụng dữ liệu lưu trữ như số chiều, kiểu nén dữ liệu hay pointer, bên cạnh đó tensor cũng là nơi để model làm các phép tính ma trận phức tạp trong quá trình suy nghĩ logic

Dù nó là nhiều chiều nhưng dưới góc độ RAM, tensor chỉ là một đường dữ liệu thẳng tắp (ví dụ tensor 3D thì trong mảng đó có nhiều mảng 2D và trong 2D là các dữ liệu 1D), khi muốn lấy dữ liệu từ tensor cần một thông số Stride (nb) để biết nhảy qua bao nhiêu byte để tới hàng tiếp theo hay chiều tiếp theo để tới đó. Nếu thao túng được nb ta có thể khiến model đọc dữ liệu sai chỗ

#3. Phân tích mã nguồn

Hiểu full code:

#a. tác dụng

rpc_tensor:

struct rpc_tensor {
// u: unsigned (0-> +) khi dùng -1 (tức 0xffffff...) sẽ rất lớn
    uint64_t id;
    uint32_t type;
    uint64_t buffer;
    uint32_t ne[GGML_MAX_DIMS];
    uint32_t nb[GGML_MAX_DIMS];
    uint32_t op;
    int32_t  op_params[GGML_MAX_OP_PARAMS / sizeof(int32_t)];
    int32_t  flags;
    uint64_t src[GGML_MAX_SRC];
    uint64_t view_src;
    uint64_t view_offs;
    uint64_t data;
    char name[GGML_MAX_NAME];
    char padding[4];
};

Struct này đóng vai trò làm metadata cho tensor như các kiểu dữ liệu uint64_t cho các thông số khai báo buffer, ne, nb,… Đây chính là thứ client gửi đi phía server theo port

rpc_cmd:

enum rpc_cmd {
    RPC_CMD_ALLOC_BUFFER = 0,
    RPC_CMD_GET_ALIGNMENT,
    RPC_CMD_GET_MAX_SIZE,
    RPC_CMD_BUFFER_GET_BASE,
    RPC_CMD_FREE_BUFFER,
    RPC_CMD_BUFFER_CLEAR,
    RPC_CMD_SET_TENSOR,
    RPC_CMD_SET_TENSOR_HASH,
    RPC_CMD_GET_TENSOR,
    RPC_CMD_COPY_TENSOR,
    RPC_CMD_GRAPH_COMPUTE,
    RPC_CMD_GET_DEVICE_MEMORY,
    RPC_CMD_INIT_TENSOR,
    RPC_CMD_GET_ALLOC_SIZE,
    RPC_CMD_HELLO,
    RPC_CMD_DEVICE_COUNT,
    RPC_CMD_GRAPH_RECOMPUTE,
    RPC_CMD_COUNT,
};

Khai báo sẵn các command để server và client hiểu nhau, các lệnh sẽ được config ở sau. Có một số lệnh khá quan trọng như RPC_CMD_ALLOC_BUFFER yêu cầu server cấp phát RAM/VRAM,…

deserialize_tensor:

ggml_tensor * rpc_server::deserialize_tensor(struct ggml_context * ctx, const rpc_tensor * tensor) {
// Kiểu dữ liệu trả về của hàm là ggml_tensor (hay tensor) | thuộc layer rpc_server, tên hàm deserialize_tensor
// ctx (context): con trỏ dùng để cấp phát RAM cho tensor được tạo ra
// tensor: con trỏ gói dữ liệu thô nhận được từ client qua mạng, const (chỉ đọc không sửa - đảm bảo tính toàn vẹn)
    // Kiểm tra xem kiểu dữ liệu từ client gửi lên có nằm trong danh sách được hỗ trợ (GGML_TYPE_COUNT) không, không thì trả về nulll
    if (tensor->type >= GGML_TYPE_COUNT) {
        GGML_LOG_ERROR("[%s] invalid tensor type received: %u\n", __func__, tensor->type);
        return nullptr;
    }
    // dấu -> có tác dụng truy cập vào một thành phần cụ thể bên trong một struct. Ở đây truy cập vào type (kiểu dữ liệu) trong
    // tensor và kiểm tra nếu nó khác GGML_TYPE_COUNT
    // mỗi kiểu dữ liệu AI có một blocksize, nếu nó = 0 thì máy tính sẽ bị lỗi chia hết cho 0 nên dòng này ngăn lỗi này xảy ra
    if (ggml_blck_size((enum ggml_type)tensor->type) == 0) {
        GGML_LOG_ERROR("[%s] invalid tensor type received (blck_size is 0): %u\n", __func__, tensor->type);
        return nullptr;
// __func__: lấy tên hàm hiện tại (deserialize_tensor)
// blocksize có thể bằng 0 khi kiểu dữ liệu sai lệch (do cơ chế tính toán số block trong tensor)
// nên khi chia cho 0 sẽ lỗi nên đoạn này dùng điều kiện nếu cái ggml_type của cái type trong tensor = 0 thì in lỗi rồi
// trả về null
    }

Hàm này có tác dụng khởi tạo một tensor cấu trúc ggml_tensor (dạng ma trận) dựa trên thông tin nháp được gửi từ mạng cấu trúc rpc_tensor (dạng byte)

rpc_server: là một class(thường có tác dụng gom các thứ liên quan lại một chỗ cho dễ nhận diện) các hàm xử lý từng loại lệnh từ client như alloc_buffer, set_tensor, graph_compute,…

Các hàm được tạo trong class rpc_server sử dụng deserialize_tensor set_tensor: Đổ dữ liệu từ client vào RAM server set_tensor_hash: Check lỗi init_tensor: Khởi tạo thông số get_tensor: Cho phép server gửi dữ liệu từ RAM -> client copy_tensor: Bắt server copy dữ liệu từ vùng này sang vùng khác create_node: Tạo liên kết các tensor để tìm tensor cha mẹ của nó rồi từ đó xây nên graph tính toán (logic)

Các hàm này đều sử dụng hàm deserialize trong cấu trúc của nó, hầu hết đều có những chức năng khác nhau nhằm tạo ra cấu trúc ggml_tensor toàn vẹn

Nhưng tại sao init_tensor lại không check dữ liệu ngặt như những hàm khác? Vì init_tensor có tác dụng có sự ảnh hưởng yếu tới cấu trúc thật sự, chủ yếu chức năng là thiết lập các thông số cơ bản để khởi tạo một tensor mới nên nó không ảnh hưởng gì đến buffer. Nhưng khi không check result kẻ xấu có thể lợi dụng kẽ hở này để tạo các thông số yêu cầu tạo tensor lớn khiến crash RAM server

Luồng hoạt động: Giai đoạn: Thiết lập kết nối (Handshake)

  1. Client: Gọi hàm get_socket() để tạo kết nối TCP đến IP
    của Server.
  2. Client: Gửi lệnh RPC_CMD_HELLO (ID: 14).
  3. Server: Nhận lệnh trong hàm rpc_serve_client, kiểm tra nếu đúng là HELLO thì gọi server.hello().
  4. Server: Phản hồi lại cấu trúc rpc_msg_hello_rsp chứa thông tin phiên bản (Major, Minor, Patch).
  5. Client: Kiểm tra phiên bản. Nếu khớp, kết nối chính thức được xác lập và lưu vào buft_map (danh sách các bộ đệm).

Giai đoạn: Workflow

Đây là luồng dữ liệu khi Client thực hiện một phép tính AI thực tế trên Server

1: Cấp phát Buffer

  • Client: Gọi ggml_backend_buft_alloc_buffer
  • Giao thức: Gửi RPC_CMD_ALLOC_BUFFER kèm kích thước yêu cầu
  • Server: Hàm alloc_buffer gọi backend nội bộ (như CUDA hoặc CPU) để lấy RAM, sau đó trả về một remote_ptr (địa chỉ RAM trên Server)

2: Lập danh sách Tensor

  • Client: Trước khi tính toán, nó biến các ggml_tensor (C++) thành rpc_tensor (Byte thô) thông qua hàm serialize_tensor.
  • Lưu ý: Lúc này rpc_tensor.buffer sẽ chứa cái remote_ptr đã nhận ở Bước 1

3: Đổ dữ liệu vào RAM Server (Set Tensor)

  • Client: Gọi ggml_backend_rpc_buffer_set_tensor
  • Giao thức: Gửi gói tin gồm: [rpc_tensor metadata] + [offset] + [Dữ liệu byte thô].
  • Server:
    1. Nhận gói tin trong set_tensor
    2. Gọi deserialize_tensor để dựng lại khung Tensor
    3. Check bảo mật: Kiểm tra xem data có nằm trong buffer không
    4. Thực thi: Gọi ggml_backend_tensor_set để chép dữ liệu vào RAM

4: Graph Building

  • Client: Gọi ggml_backend_rpc_graph_compute
  • Server:
    1. Nhận danh sách các Tensor ID.
    2. Gọi create_node liên tục. Hàm này sẽ dùng deserialize_tensor để biến các ID thành các vật thể Tensor thực trong RAM Server và kết nối chúng lại (Node A nối với Node B)

5: Kích hoạt tính toán (Compute)

  • Server: Sau khi Graph đã dựng xong, nó gọi ggml_backend_graph_compute (của backend thật như CUDA). Server sẽ “suy nghĩ” dựa trên dữ liệu đã nhận

6: Lấy kết quả về (Get Tensor)

  • Client: Gửi RPC_CMD_GET_TENSOR
  • Server: Tìm Tensor tương ứng, đọc dữ liệu từ RAM Server và gửi ngược lại cho Client qua socket.

Chú ý:

  • #pragma pack(push, 1): Toàn bộ struct RPC được nén chặt không có khoảng trắng thừa. Điều này giúp tính toán Offset cực kỳ chính xác khi viết script Python
  • tensor_map & tensor_ptrs: Server dùng std::unordered_map để quản lý các Tensor. Nếu bạn gửi sai ID, Server sẽ không tìm thấy và trả về nullptr (Dẫn đến logic bug).
  • deserialize_tensor là trái tim: Mọi hàm (set, get, copy, graph) đều phải đi qua nơi này để biến Byte từ mạng thành vật thể trong RAM.

Tóm lại: Luồng hoạt động là một chuỗi: Kết nối -> Thuê RAM -> Gửi Metadata -> Đổ dữ liệu -> Ra lệnh tính -> Lấy kết quả

#b. Hiểu đoạn code gây bug

struct ggml_tensor * result = deserialize_tensor(ctx, tensor);
if (result == nullptr) {
return nullptr;
}

#struct ggml_tensor * result = deserialize_tensor(ctx, tensor);

struct: một kiểu dữ liệu có thể chứa nhiều kiểu dữ liệu result: được khai báo là một con trỏ tới một vùng nhớ chứa dữ liệu có cấu trúc là ggml_tensor (ở đây là cái tensor rỗng được tạo ra từ hàm deserialize_tensor) deserialize_tensor: tạo tensor trong RAM rộng theo kích thước ctx (được tính toán kĩ)

#if (result == nullptr) {return nullptr;}

Điều kiện nếu cái tensor đó rỗng thì trả về null

#c. Hiểu bản vá

struct ggml_tensor * result = deserialize_tensor(ctx, tensor);
// tạo tensor rỗng
if (result == nullptr || result->buffer == nullptr) {
// trước đó chỉ có dòng nếu result = 0 thì về null
// nhưng giờ thêm điều kiện là nếu cái buffer của result = 0 thì có lỗi và về null
GGML_LOG_ERROR("[%s] invalid tensor: null %s (id=%" PRIu64 ")\n",
__func__, result == nullptr ? "tensor" : "buffer", id);
return nullptr;
}

Bản vá thêm một điều kiện về số buffer của tensor

#d. Tại sao result và buffer của nó = null thì gây lỗi

Result Như bạn đã biết, ctx sẽ tính toán và cấp RAM cho tensor được tạo ra từ hàm deserialize_tensor, nhưng nếu Hacker tạo ra một tensor có kích thước lớn hơn RAM rất nhiều được cấp thì con trỏ result sẽ lỗi và gây crack nếu không có cơ chế kiểm tra như ở trên

Giả dụ: Một gói tin metadata nhẹ vài KB, bên trong khai báo tensor có 4 chiều với nb =2^60 ne = 2^60 . Nhưng khi dùng hàm deserialize_tensor nó tính toán ra một tensor cực lớn (> 2^60), điều này khiến máy tính kiểm tra thấy con số quá lớn và result = null, từ đây gây crack.

Vậy để làm gì để result không bằng null -> khiến sinh ra cái điều kiện thứ 2 ở bản vá

Buffer Nếu bạn đưa ra một metadata có kích thước nhỏ nhưng số mũ lại vừa nhỏ hơn kích thước tối đa mà máy tính cho phép thì result sẽ không = null

Vì result không bằng null -> điều kiện không xảy ra -> tiếp tục chạy -> lấy RAM để tạo tensor nhưng khi tính toán số RAM lớn hơn số RAM của máy chủ -> crack

Để hiểu rõ hơn: tensor: là cái vỏ được tạo ra khi sử dụng hàm deserialize_tensor, chứa gói tin chứa metadata (rpc_tensor) được client gửi qua port Các phần tử trong tensor trỏ tới các giá trị trong buffer

RAM được cấp phát (hay gọi là buffer): chứa các giá trị được client gửi (set_tensor) sau đó -> đây là ruột

#4. Attacking flow

The attack is network-based and requires no user interaction or privileges. An attacker must have TCP access to the RPC server port. The attack sequence involves:

  1. Establishing a TCP connection to the llama.cpp RPC server
  2. Sending ALLOC_BUFFER and BUFFER_GET_BASE messages to leak pointer information and bypass ASLR
  3. Crafting a malicious GRAPH_COMPUTE message with the tensor buffer field set to 0
  4. Exploiting the missing bounds validation to perform arbitrary memory read/write operations
  5. Achieving remote code execution by overwriting critical memory structures

#5. Kĩ thuật cần biết:

#Arbitrary Write

Gửi một gói tin vào server sao cho khi server xử lý đến hàm set_tensor , có thể thấy adr mà mình muốn Dưới đây là chi tiết: Nhìn vào đoạn code này trong file bug

if (result->buffer) {
        // require that the tensor data does not go beyond the buffer end
        uint64_t tensor_size = (uint64_t) ggml_nbytes(result);
        uint64_t buffer_start = (uint64_t) ggml_backend_buffer_get_base(result->buffer);
        uint64_t buffer_size = (uint64_t) ggml_backend_buffer_get_size(result->buffer);
        GGML_ASSERT(tensor->data + tensor_size >= tensor->data); // check for overflow
        GGML_ASSERT(tensor->data >= buffer_start && tensor->data + tensor_size <= buffer_start + buffer_size);
}
    result->op = (ggml_op) tensor->op;
    for (uint32_t i = 0; i < GGML_MAX_OP_PARAMS / sizeof(int32_t); i++) {
        result->op_params[i] = tensor->op_params[i];
    }
    result->flags = tensor->flags;
    result->data = reinterpret_cast<void *>(tensor->data);
    ggml_set_name(result, tensor->name);
    return result;
}

Bài này có logic là nếu result->buffer: con trỏ result trỏ vào một cái buffer tồn tại (buffer > 0) thì nó sẽ thực hiện các lệnh trong đoạn điều kiện

Lấy size, address bắt đầu cái buffer, kích thước buffer rồi dùng GGML_ASSERT để kiểm tra:

  • Kiểm tra xem tensor->data + tensor_size >= tensor->data thì cho qua: tức là bạn có thể hiểu trong bộ nhớ máy tính địa chỉ là một vòng tròn (từ 0x00 -> 0xff… giống số nhà bạn khi đi một vòng trái đất lại về nhà bạn), địa chỉ cũng vậy nên dùng cái này để tránh nó sử dụng một địa chỉ tensor->data pass điều kiện một cái bên dưới nhưng dùng một kích thước rất lớn để trỏ về các vùng địa chỉ thấp hơn mà không vướng phải điều kiện check

  • Kiểm tra nếu cái tensor->data (address trỏ đến data trong metadata) >= buffer_start (address start của buffer) và tensor->data + tensor_size <= buffer_start + buffer_size thì cho qua: Tức là cái đoạn đầu để tránh cái địa chỉ khai báo nằm thấp hơn cái địa chỉ buffer, còn cái sau là địa chỉ trỏ đến data trong metadata + kích thước tensor phải nhỏ hơn buffer size

Theo đó, khi mình set được cái buffer = 0 thì cái điều kiện kia nó không xảy ra, code sẽ chạy tiếp xuống các dòng khác Đặc biệt có một lệnh:

result->data = reinterpret_cast<void *>(tensor->data);

nó sẽ set data trong ggml_tensor = data trong rpc_tensor. nếu ta khai báo cái tensor->data là một vùng địa chỉ ngoài buffer (bypass condition checking) nó sẽ đặt địa chỉ ta trỏ vào làm vùng bắt đầu dữ liệu của tensor

#Cần làm tiếp theo

  1. Tìm cách dùng leak information trong lab này
  2. Cần hiểu code rõ hơn, các đoạn code thực thi các lệnh để có thể áp dụng leak information
  3. Học cách viết script exploiting, biết lý do tại sao lại dùng các lệnh để xử lí logic