利用堆溢出在 Llama.cpp 中实现远程代码执行
#);@>.*!]
retr0blog
- retr0
- blog
联系方式
Llama 的悖论 - 深入 Llama.cpp 并利用 Llama.cpp 的堆迷宫,从堆溢出到远程代码执行
作者:Patrick Peng 2025 年 2 月 6 日
如果您是一位漏洞利用爱好者,那么这篇文章将是完美的娱乐来源。我花了大约 ~
30h
在利用堆溢出实现远程代码执行。与此同时,此前我已经花了大约2 周
的时间研究/理解Llama.cpp
关于其特有的 RPC 和内存实现的源代码。由于Llama.cpp
具有如此特殊的堆管理系统,该系统的特殊功能将导致我们熟悉的经典ptmalloc
利用失败。因此,即使这是一个堆溢出,其利用也不会是经典的ptmalloc
堆利用,而是利用Llama.cpp
实现逻辑的有趣向量。祝您阅读愉快:)
Llama.cpp
一直是我喜欢研究的东西,也是我 AI/ML
研究的某种 '终极' 目标;不仅如此,在这种复杂且完善的 ML 项目中发现堆/栈溢出 RCE 总是听起来很酷。(此外,我非常渴望在 AI
项目中进行二进制漏洞挖掘,以证明我的二进制漏洞利用技术并没有“过时”,但那是另一回事了)。因此,我决定研究 Llama.cpp
的 RPC
组件,当我看到这些安全问题发布在它的 GitHub 'Security' 选项卡上时 - 我就想: 哇,这些只是简单的 write-what-where 和 read-what-where;这一定是一个 '赚钱' 项目,只需要付出一点努力。
然后 Llama.cpp
证明我大错特错。在最初的两周里,我什么也没发现,因为他们对 RPC
Tensor
的反序列化、完全分配内存 'buffer'
的跟踪以及 RPC
端点的实现都实施了大量的安全检查。这些 write-what-where 被严格地修补了,所以当你试图再次利用时,你可能会在途中触发两个断言错误,到处都检查了整数溢出,你再也无法以任何方式破坏指针了。它非常安全且不可利用 - 这令人沮丧,但我确实更好地理解了实现本身和 cpp
(我从未系统地学习过 cpp
)- Llama.cpp
有其自身的内存管理系统、内存安全补丁和缓解措施,你将在本文的大部分内容中看到我所说的,我们将处理不同的悖论、缓解措施、全新的方法和利用向量,这些都是我在如此独特的利用之前从未想过的。最后,一切都连接在一起,你将看到一个独特的利用脚本和过程,以及绕过一切并不放弃过程的满足感。
对于这篇 1 万字的文档,我花了一个月左右的时间完成主要部分,而完善/编辑则花费了额外的时间。撰写本文确实是一个痛苦的过程。我整个周末以及一周中剩余的 4-5 个小时都花在了上面,持续了大约两周。但另一方面,这也是一个逐步探索内存的快乐过程。谁不喜欢呢?祝您阅读愉快!
风暴前夕
故事始于 Llama.cpp
的 RPC
函数,在过去的几个月里,Llama.cpp
的 RPC
Server 一直是漏洞利用的重点。llama.cpp
中的 rpc-server
能够在远程主机上执行 GGML
后端,从而实现分布式大型语言模型 (LLM) 推理。通过将计算卸载到一个或多个 rpc-server
实例,用户可以利用多台机器或专用硬件(如 GPU)的处理能力来提高性能。
在 RPC
服务器的开发初期,报告了底层内存安全漏洞(GHSA-wcr5-566p-9cwj
, GHSA-5vm9-p64x-gqw9j
, GHSA-mqp6-7pv6-fqj
),主要是在 Llama.cpp
的 tensor
内存相关操作上利用。这些早期阶段的漏洞是直接的利用,较少依赖于 GGML 的 RPC 内存管理逻辑,而更多地依赖于输入考虑。但是,我们应该了解一些关于其内存管理过程的信息;
Llama.cpp
实现了自己的内存管理机制,基于 glibc 的基本 malloc 和经典的 ptmalloc 管理方法;同时,添加了功能来管理进程,以优化 Tensor
相关的处理操作。
首先,所有内存相关操作都需要通过 alloc_buffer
命令来分配 RPC
内存。它的 RPC
端点只需要一个大小参数。但是,这比简单地返回 malloc 的指针地址要复杂一些。相反,将返回一个额外分配的 buffer
结构体的地址,而实际的 malloc
区域被包装为 buffer->data
;与此同时,Llama.cpp
的 RPC
以 Tensor
的形式解析请求,不仅仅作为这些请求的有效负载。
先决条件:Tensor
、buffer
结构
// ggml/src/ggml-backend-impl.h:60
struct ggml_backend_buffer {
struct ggml_backend_buffer_i iface;
ggml_backend_buffer_type_t buft;
void * context;
size_t size;
enum ggml_backend_buffer_usage usage;
};
buffer
结构包含 buffer
方法/指针结构 ggml_backend_buffer_i iface
,后端管理线程 ggml_backend_buffer_type_t buft;
,分配内存的实际地址 context
,分配内存的 size_t size;
,最后是 ggml_backend_buffer_usage usage
;这里有趣的部分是 iface
结构,我们将大量使用它,并在利用步骤中进行更深入的分析。
struct ggml_backend_buffer_i {
void (*free_buffer) (ggml_backend_buffer_t buffer);
void * (*get_base) (ggml_backend_buffer_t buffer);
void (*init_tensor) (ggml_backend_buffer_t buffer, struct ggml_tensor * tensor);
void (*memset_tensor)(ggml_backend_buffer_t buffer, struct ggml_tensor * tensor, uint8_t value, size_t offset, size_t size);
void (*set_tensor) (ggml_backend_buffer_t buffer, struct ggml_tensor * tensor, const void * data, size_t offset, size_t size);
void (*get_tensor) (ggml_backend_buffer_t buffer, const struct ggml_tensor * tensor, void * data, size_t offset, size_t size);
bool (*cpy_tensor) (ggml_backend_buffer_t buffer, const struct ggml_tensor * src, struct ggml_tensor * dst);
void (*clear) (ggml_backend_buffer_t buffer, uint8_t value);
void (*reset) (ggml_backend_buffer_t buffer);
};
Llama.cpp
的多架构使其有必要根据目标服务器架构分配不同的方法;例如,仅支持 CPU 的机器的 iface.get_tensor
将设置为 ggml_backend_cpu_buffer_get_tensor
,而 CUDA 支持的服务器将启用 ggml_backend_cuda_buffer_get_tensor
。这些方法的架构不同,实现相同,但是兼容性有所不同(例如,CUDA 机器使用 cudaMemcpyAsync
,另一方面,CPU 版本使用 C 标准库中的原生 memcpy
)。
struct ggml_tensor {
enum ggml_type type;
GGML_DEPRECATED(enum ggml_backend_type backend, "use the buffer type to find the storage location of the tensor");
struct ggml_backend_buffer * buffer;
int64_t ne[GGML_MAX_DIMS]; // number of elements
size_t nb[GGML_MAX_DIMS]; // stride in bytes:
// compute data
enum ggml_op op;
// op params - allocated as int32_t for alignment
int32_t op_params[GGML_MAX_OP_PARAMS / sizeof(int32_t)];
int32_t flags;
struct ggml_tensor * src[GGML_MAX_SRC];
// source tensor and offset for views
struct ggml_tensor * view_src;
size_t view_offs;
void * data;
char name[GGML_MAX_NAME];
void * extra; // extra things e.g., for ggml-cuda.cu
char padding[8];
};
Tensor
在 llama.cpp
中随处可见。在这里我们不会深入研究 int64_t ne[GGML_MAX_DIMS];
/ size_t nb[GGML_MAX_DIMS];
的技术细节,以及它是如何存储张量的形状和步长的。除了张量数据传输之外,llama.cpp
中的 Tensor
结构还为 RPC
通信提供了一种序列化标准,结合之前对 buffer
结构的介绍,让我们看看内存分配端点是如何使用 buffer
和 Tensor
进行通信的。
过去的补丁、缓解措施
我们之前提到的三个报告的漏洞 ((GHSA-wcr5-566p-9cwj
, GHSA-5vm9-p64x-gqw9j
, GHSA-mqp6-7pv6-fqj
) 实际上是利用了一个基本的设计缺陷 - 缺少对 buffer
/ buffer->data
指针的边界检查。此缺陷的存在应用于 RPC
服务器的不同功能 - 无论是 get_tensor
还是 set_tensor
,都允许攻击者实现 read-what-wheres 或 write-what-where。
static void ggml_backend_cpu_buffer_set_tensor(ggml_backend_buffer_t buffer, struct ggml_tensor * tensor, const void * data, size_t offset, size_t size) {
memcpy((char *)tensor->data + offset, data, size);
// write-what-wheres, 过去版本的 Llama.cpp 的 RPC 没有对 buffer->data 的有效性进行检查,遗憾的是你可以直接 write-what-where
GGML_UNUSED(buffer);
}
但是,这些内存问题通过实现大量的 glibc
级别的内存检查来解决 - 有时会检查两次甚至更多次指针或 Tensor
的大小;这些缓解措施是在张量的反序列化之前实现的 (deserialize_tensor()
)、RPC
方法调用包装器(例如 rpc_server::set_tensor
)、调用包装器的内部实现(例如 ggml_backend_tensor_set
)甚至在 buffer->iface
实现中(例如 ggml_backend_cpu_buffer_cpy_tensor
),这四个阶段的检查使你了解了根据 RPC
处理的每个步骤进行的指针验证 (有趣的是,在研究的最初,我花了大约 3-5 个小时只是为了弄清楚张量检查是如何工作的,这样我就可以尝试过去的漏洞利用,看看他们是否正确地修复了它,而他们确实修复了)。
查看这些缓解措施,一步一步地进行,远程 Tensor
将面临的第一个检查是在 deserialize_tensor()
处进行的检查,其中 tensor->data
指针(主要在 get_tensor
和 set_tensor
中使用)会被检查是否在 ggml_backend_buffer_get_base
到 ggml_backend_buffer_get_size
的范围内,同时它还会考虑 tensor_size
可能为负的漏洞利用,这可能会导致在 set/get_tensor
方法中向后写入/读取。与此同时,ggml_backend_buffer_get_base
、ggml_backend_buffer_get_size
是 tensor->context
和 tensor->size
的包装器,使得绕过缓解措施并不容易,否则我们将需要伪造一个 buffer
结构,其中包含有效的 buffer
内部指针。
// ggml/src/ggml-rpc/ggml-rpc.cpp:848
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);
}
在这里提到,
buffer
指针的有效性也在ggml/src/ggml-rpc/ggml-rpc.cpp:843 (deserialize_tensor())
->result->buffer && buffers.find(result->buffer) == buffers.end()
中检查,通过检查全局buffer
管理数组buffers
,这可以防止任何预先伪造的buffer
结构的漏洞利用。
request.tensor.data
/ (buffer->data
) 的有效性,以及 request.offset
/ request.size
在调用包装器实现中进行了进一步的检查,这里的检查与之前使用的 ggml_backend_buffer_get_base
、ggml_backend_buffer_get_size
的检查类似(我们以后可能会将其称为 p0
p1
),但是,包含作为 RPC
传递参数一部分的 offset
/ size
,这些可以更改最终 get_tensor
/ set_tensor
的范围,因此与 buffer->data
一起检查。有趣的是,这里还检查了 request.tensor.data + request.offset
是否为负数,以防止向后写入/读取。同时通过 request.size
防止越界读取/写入。
// ggml/src/ggml-rpc/ggml-rpc.cpp:924
{
const size_t p0 = (size_t) ggml_backend_buffer_get_base(tensor->buffer);
const size_t p1 = p0 + ggml_backend_buffer_get_size(tensor->buffer);
if (request.tensor.data + request.offset < p0 ||
request.tensor.data + request.offset >= p1 ||
request.size > (p1 - request.tensor.data - request.offset)) {
GGML_ABORT("[%s] tensor->data out of bounds\n", __func__);
}
}
最后,一些 buffer->iface
实现也实现了检查。例如,ggml_backend_cpu_buffer_cpy_tensor
检查了 (ggml_backend_buffer_is_host(src->buffer)) {
以确保 src->buffer
的有效性。这是一个经过充分考虑的检查,因为我考虑的一种可能的利用是手动将 src->buffer
留给 NULL
,这将导致 src->data
检查中的 p0/p1
检查失败 (因为 buffer
是一个 NULL
指针,ggml_backend_buffer_get_base
的内部处理将跳过测试并返回 0
),从而可能允许我们泄漏任意地址。这是非常容易利用的,并且这是他们考虑的一部分。
分析:维度到毁灭,cpy_tensor
和 ggml_nbytes
尽管 buffer->data
指针以各种可能的方式进行了检查,但 get_base()
(buffer->context
) 和 buffer->size
却以如此可怕的方式进行检查。但是,在研究过程中,我们仍然在 ggml_backend_cpu
方法的丛林中发现了一个有趣的堆溢出向量。
利用始于一个有趣的方法:ggml_nbytes
,这是一种计算 Tensor 对象维度大小的技术。
size_t ggml_nbytes(const struct ggml_tensor * tensor) {
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;
}
int64_t ggml_blck_size(enum ggml_type type) {
return type_traits[type].blck_size;
}
static const struct ggml_type_traits type_traits[GGML_TYPE_COUNT] = {
[GGML_TYPE_I8] = {
.type_name = "i8",
.blck_size = 1,
.type_size = sizeof(int8_t),
.is_quantized = false,
},
// ....
ggml_nbytes()
是一种方法,通常由 llama.cpp
在 libggml-base.so
(ggml.c
) 中加载,以根据 Tensor
的形状 tensor->ne[]
和步长 tensor->nb[]
来计算 Tensor
的数据大小 (Tensor 是一种多维数据结构,通常用于机器学习和数值计算)。
ggml_blck_size
通过方法 ggml_blck_size
获取其对应的 blck_size
,ggml_blck_size
是全局变量 type_traits
的包装器 (有趣的是,过去在 ggml_blck_size
中发现了漏洞,当时 ggml_tensor
的 type
没有限制/检查,这允许基于 type_traits
全局变量进行越界读取,直到他们在 Tensor->type
上引入了大小限制); 这些 .blck_size
不会线性增加,而是取决于 GGML_TYPE_X
自身的属性。
这里有趣的部分是 nbytes
,Tensor
的大小由 tensor->ne[i]
数组、tensor->nb[0]
和 tensor->type
(使用 ggml_blck_size
转换为 blck_size
) 来计算和确定,这意味着,如果 Tensor
的 (ne[] || nb[])
是可控的,那么返回的 nbytes
也会是可控的 (为了更深入地研究关于 ggml_nbytes
计算的利用部分,我们现在将不解释大小是如何计算的)。这在 llama.cpp
对 ggml_nbytes
的一般使用中不会成为一个问题,因为由于内存限制,GGML Tensor
通常对其维度有实际的限制。但是,这确实成为了 GGML 的后端动态 iface
绑定方法之一 ggml_backend_cpu_buffer_cpy_tensor
的风暴的开始。
static bool ggml_backend_cpu_buffer_cpy_tensor(ggml_backend_buffer_t buffer, const struct ggml_tensor * src, struct ggml_tensor * dst) {
if (ggml_backend_buffer_is_host(src->buffer)) {
memcpy(dst->data, src->data, ggml_nbytes(src));
return true;
}
return false;
GGML_UNUSED(buffer);
}
这似乎是一个正常运行且安全的 buffer->iface
实现 (如果我没有将其置于漏洞利用博客的上下文中,而只是在漏洞利用博客中介绍 ggml_nbytes
,则可能会显得更无辜),考虑到 llama.cpp
实现的所有 3 级检查,这些检查阻止了我们以任何方式破坏 buffer->data
指针或传递的 offset
/ size
。提到对 ggml_backend_buffer_is_host
的小检查,实际上没有任何可利用的 cpy
功能。
但是,请注意 memcpy
的大小是由 src
的 Tensor
维度大小使用 ggml_nbytes
计算的,你将开始看到问题。你看,尽管 ggml-rpc.cpp:854
使用 uint64_t tensor_size = (uint64_t) ggml_nbytes(result)
检查了 ggml_nbytes
,以及 tensor->data
和 buffer_start
。但是,这些仅检查越界是否发生在 buffer->context 内部。
在将一个 src->data
复制到另一个 context
,dst->data
的情况下,ggml_nbytes
的计算是由输入可控的 Tensor
成员 ne[]
/ nb[]
操作的,这些成员将在 deserialize_tensor()
期间精确地复制,并且此 iface
实现没有比较 src
和 dst
Tensor
之间的 ggml_nbytes
大小,这允许我们将一个 '较大' 的 Tensor
构造为 dst
,为 '较小' 的 src
指定一个较大的维度大小 (对于大小,我们指的是实际应用的大小),这将导致 src
的数据与 dst
重叠,从而导致堆溢出。
与此同时,src->context 的可控性也保证了此漏洞的可利用性;我们可以使用 set_tensor 之前设置 src->context 以用有效负载填充 src->context,并根据 dst->context
进行溢出。
风暴:利用悖论世界
发现堆溢出是一件很棒的事情,但是利用堆溢出甚至更好,通常来自 堆溢出
/ asan
的崩溃文件足以作为 堆溢出
提交,但是那有什么乐趣呢。但另一方面,在这种独特而复杂的内存管理系统中利用此堆溢出确实将我们拖入了这场风暴和悖论世界。
整个利用部分都是按照研究过程编写的,这意味着其中一部分是在漏洞被证明可利用之前编写的(这也使得阅读非常愉快)。我建议阅读到最后,事情会变得越来越有趣。
在事件发生的房间设置一个断点,这是我的第一个想法,查看 dst->data
,我们会发现此结构与 buffer
结构高度接近:
在这种情况下,最近的 buffer
结构位于 0x55555557dd10-0x55555557dc00
,仅 0x110
到溢出的 dst->data
。块的这种 "巧合" 的安排让我们非常高兴!你可能会问为什么?这将需要我们回头看看 ggml/src/ggml-rpc/ggml-rpc.cpp
;这是当一个请求被解析到 RPC 服务器时发生的事情,以 get_tensor
操作为例:
- 进入
static void rpc_serve_client(ggml_backend_t backend, sockfd_t sockfd, size_t free_mem, size_t total_mem) {
,RPC 服务器在这里监听套接字连接 - 根据
p8()
命令指示,进入特定的 case switch;在本例中,它将进入case RPC_CMD_GET_TENSOR:
- 然后进入
server.set_tensor(input)
,这是用于处理请求的rpc_server
类型方法,服务器在这里反序列化张量,检查边界和检查... - 最终,进入
ggml_backend_*
的方法(在本例中为ggml_backend_tensor_set
),此方法位于ggml-backend.cpp
文件中,其中发生关于张量的实际操作。 - 在
ggml_backend_*
文件内部,由于 RPC 服务器的不同类型/架构,Llama.cpp 不使用单个静态方法来操作这些张量;相反,这些操作 "线程" 在运行时分配并存储在buffer
线程上(作为buf->iface
),例如tensor_set
操作调用buf->iface.set_tensor(buf, tensor, data, offset, size);
最终。
这意味着,如果我们能够通过溢出控制缓冲区地址(不是上下文地址),因为它们在如此相邻的地址中,那么我们可以控制 buffer
结构的成员;例如,通过操作 buffer->iface
,后端操作方法。我们可以将执行流重定向到任意地址;通过操作 context
变量,我们可以绕过现有的范围检查,以重新建立/绕过先前漏洞的缓解措施!
目前,我们的工作将是使用 cyclic
和 flat()
来操作堆,计算块和结构成员之间的堆偏移量;这是一个漫长的过程,因为通常其他堆组件会在溢出期间受到影响。溢出的大小需要仔细计算,否则你将无意中覆盖,这需要你使用 written-tensor
/ overflown-tensor
中的 维度大小
。
经过一个上午的溢出副作用处理后,我们现在可以调用任意地址了!(由于某些原因,我们会在溢出期间破坏其他块的头,导致 free()
/ munmap_chunk(): invalid pointer
错误,通过观察溢出前/后的堆结构来解决,你需要手动设置块的头),因为我们可以自由地排列分配的块,如果我们在同一时间使用相同的分配方法(同时考虑 prev_in_use
标志,在本例中,我们将 sizeof(buffer)+0x8
写入 0x110
),我们可以_预测_下一个块的大小头。
溢出的悖论
正如我之前应该提到的,deserialize_tensor
或后端方法的实现都对 context
大小和 tensor->data
范围进行了严格的边界检查。具体来说,deserialize_tensor
中的缓解措施通过 ggml_backend_buffer_get_base
和 ggml_backend_buffer_get_size
检查 tensor->data
是否在范围内(这两个方法都依赖于 buffer
成员 buffer->context
和 buffer->size
),而实现检查边界是否可能在请求参数的影响下损坏。问题出现在这里:将此溢出利用到一个新的、地狱般的水平。即使我们可以控制 RPC 服务器到任意地址的执行流程,我们也没有任何地址可以通过操作执行流程来利用。通常,这会是一个容易解决的问题,因为我们已经控制了 buffer
结构,我们可以操作在 tensor->data
/ buffer
base 的检查过程中使用的 buffer
成员,以绕过先前漏洞的先前补丁。但是,这里的溢出悖论发挥了作用:
- 为了绕过
tensor->data
/buffer_base
边界缓解,我们将不得不修改buffer
的context
/get_base
成员 - 修改
buffer
会损坏其他buffer
成员和指针,因为我们尚未获得/泄漏ggmlbase
的基地址来计算这些buffer->iface
指针的实际地址。 - 为了泄漏
ggmlbase
指针或ggmlbase
基地址,我们必须在合法tensor->data
范围之外,这意味着我们必须绕过边界缓解,这将我们带回了第一步。
重新提及我们在第一次溢出时持有零指针的事实,溢出悖论 使得仅依靠 buffer
进行利用成为不可能 - 当我们可以编辑诸如 context
、get_base
之类的 buffer
成员而不会冒损坏整个 buffer
的风险时,这是一个非常有用的向量(在这种情况下,我们可以绕过任何东西来泄漏 libc
等等)。但是现在,最好将此向量留在这里,并在我们需要它时使用它!
部分写入:现实生活中的部分写入?
在经典的 glibc
CTF 漏洞利用中,有一种人们都知道但现在变得越来越不实用的技术或小技巧,称为部分写入。通过 "部分写入",这意味着我们正在写入指针的各个部分(听起来像陈词滥调,但需要加强)。
对于不熟悉部分写入的读者来说,在大多数系统中,指针存储为多字节值(例如,在 64 位系统上为 8 个字节)。这些字节可以分解为更小的部分,例如低 2 个字节 (LSB
)、中间字节或高位字节 (MSB
),并且它们的存储方式在小端体系结构中通常是违反直觉的,其中 MSB
存储在较大的内存地址,而 LSB
存储在较低的内存地址,这意味着一个 0xdeadbeef
指针,例如,在内存中看起来像 ef be ad de
。
二进制文件通常需要库才能运行,例如,标准 C 库 libc
或其他自编译库,尽管_你可以静态编译一个二进制文件(将外部方法/类型嵌入到二进制文件中),_但这将导致一个相当大的 ELF。相反,在运行时,elf 将_静态链接_库到 elf,并且库将映射到程序的内存分段中(你可以使用 gdb
中的 vmmap
检查内存映射),并且使用(offset
/ 库中的 '真实地址' + mapping_base
)引用。但是,由于 ASLR
,这些基地址是随机加载/映射的,如果 elf 没有封装一个方法,例如,执行命令,并且我们正在寻找 RCE,那么我们调用 system
的唯一方法是首先泄漏动态链接库(通常是 libc.so.6
)在 elf 中映射的基地址,然后使用库中的固定偏移量来计算该方法的实际地址。
现在,这就是部分写入变得特别有趣的地方。由于动态链接库的地址映射中的内存对齐机制和小端体系结构的组合,它启用了一个有趣的向量,允许我们在不知道任何映射的基地址的情况下访问动态链接库中的_某些_方法:部分溢出指针 - 而不损坏映射的基地址,因为所有基地址都对齐在 0x1000
,动态链接指针的最后两个字节不会被加载的 aslr-ed 基址反映,而是直接由动态链接偏移量表示,因此凭借小端给我们的力量,我们可以修改 LSB
部分的指针,从而允许我们**将指针操作到同一基地址的任意偏移量!**即使我们无法将一个半字节写入指针,但是,最多需要 0xf = 16
次猜测才能保证验证映射基址的半字节。
部分写入的悖论?再次?
在我们的漏洞利用案例中,所有 buffer->iface
指针都属于 libggml-base
动态链接库,共享相同的基地址(你可以观察到所有指针都以 0x7ffff7e7....
开头),理论上,我们可以将它们操作为 libggml-base.so
中编译的任意方法。但是,再仔细看一下,你会发现 这实际上是一个极其困难的漏洞利用路径,因为:
- 控制
ggml_nbytes
非常困难/耗时,因为你无法 "计算" 大小。相反,你将需要更改张量的维度规范,留下大约 40 种不同的组合。 - 在最好的情况下,我设法部分溢出了第一个成员的最后两位 -
free_buffer
- 很难在0xffff
范围内找到 gadget/函数(0x17000 < addr < 0x26fff
转换为ggmlbase
库中的偏移量) free_buffer
在苛刻的条件下被调用,只有buffer
被解析到函数中,从而消除了使用ggml_backend_cpu_buffer_get/set_tensor
来管理任意写入/任意读取的机会。
上图展示了 ggmlbase
库中存在多少方法/gadget,你看到的大多数 GOT
都超出了可控范围 0x17000 < addr < 0x26fff
,我们无法尝试部分覆盖这些地址,因为控制超过 0xff
空间需要太多的资源(因为 vmmap
基地址与 0xfffffffffffffff000
对齐,最后三个半字节是固定的,尽管基址发生变化,我们仍然可以在多线程中的 1 位数字差异期间进行预测,因为我们只需要猜测 0xf
max)。
在最初几个小时研究部分写入时,这确实_似乎_