#);@>.*!]

retr0blog

联系方式

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.cppRPC 组件,当我看到这些安全问题发布在它的 GitHub 'Security' 选项卡上时 - 我就想: 哇,这些只是简单的 write-what-whereread-what-where;这一定是一个 '赚钱' 项目,只需要付出一点努力。

然后 Llama.cpp 证明我大错特错。在最初的两周里,我什么也没发现,因为他们对 RPC Tensor 的反序列化、完全分配内存 'buffer' 的跟踪以及 RPC 端点的实现都实施了大量的安全检查。这些 write-what-where 被严格地修补了,所以当你试图再次利用时,你可能会在途中触发两个断言错误,到处都检查了整数溢出,你再也无法以任何方式破坏指针了。它非常安全且不可利用 - 这令人沮丧,但我确实更好地理解了实现本身和 cpp (我从未系统地学习过 cpp)- Llama.cpp 有其自身的内存管理系统、内存安全补丁和缓解措施,你将在本文的大部分内容中看到我所说的,我们将处理不同的悖论、缓解措施、全新的方法和利用向量,这些都是我在如此独特的利用之前从未想过的。最后,一切都连接在一起,你将看到一个独特的利用脚本和过程,以及绕过一切并不放弃过程的满足感。

对于这篇 1 万字的文档,我花了一个月左右的时间完成主要部分,而完善/编辑则花费了额外的时间。撰写本文确实是一个痛苦的过程。我整个周末以及一周中剩余的 4-5 个小时都花在了上面,持续了大约两周。但另一方面,这也是一个逐步探索内存的快乐过程。谁不喜欢呢?祝您阅读愉快!

风暴前夕

故事始于 Llama.cppRPC 函数,在过去的几个月里,Llama.cppRPC Server 一直是漏洞利用的重点。llama.cpp 中的 rpc-server 能够在远程主机上执行 GGML 后端,从而实现分布式大型语言模型 (LLM) 推理。通过将计算卸载到一个或多个 rpc-server 实例,用户可以利用多台机器或专用硬件(如 GPU)的处理能力来提高性能。

RPC 服务器的开发初期,报告了底层内存安全漏洞(GHSA-wcr5-566p-9cwj , GHSA-5vm9-p64x-gqw9j , GHSA-mqp6-7pv6-fqj ),主要是在 Llama.cpptensor 内存相关操作上利用。这些早期阶段的漏洞是直接的利用,较少依赖于 GGML 的 RPC 内存管理逻辑,而更多地依赖于输入考虑。但是,我们应该了解一些关于其内存管理过程的信息;

Llama.cpp 实现了自己的内存管理机制,基于 glibc 的基本 malloc 和经典的 ptmalloc 管理方法;同时,添加了功能来管理进程,以优化 Tensor 相关的处理操作。

首先,所有内存相关操作都需要通过 alloc_buffer 命令来分配 RPC 内存。它的 RPC 端点只需要一个大小参数。但是,这比简单地返回 malloc 的指针地址要复杂一些。相反,将返回一个额外分配的 buffer 结构体的地址,而实际的 malloc 区域被包装为 buffer->data;与此同时,Llama.cppRPCTensor 的形式解析请求,不仅仅作为这些请求的有效负载。

先决条件:Tensorbuffer 结构


  // 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];
  };

Tensorllama.cpp 中随处可见。在这里我们不会深入研究 int64_t ne[GGML_MAX_DIMS]; / size_t nb[GGML_MAX_DIMS]; 的技术细节,以及它是如何存储张量的形状和步长的。除了张量数据传输之外,llama.cpp 中的 Tensor 结构还为 RPC 通信提供了一种序列化标准,结合之前对 buffer 结构的介绍,让我们看看内存分配端点是如何使用 bufferTensor 进行通信的。

过去的补丁、缓解措施

我们之前提到的三个报告的漏洞 ((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_tensorset_tensor 中使用)会被检查是否在 ggml_backend_buffer_get_baseggml_backend_buffer_get_size 的范围内,同时它还会考虑 tensor_size 可能为负的漏洞利用,这可能会导致在 set/get_tensor 方法中向后写入/读取。与此同时,ggml_backend_buffer_get_baseggml_backend_buffer_get_sizetensor->contexttensor->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_baseggml_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_tensorggml_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.cpplibggml-base.so (ggml.c) 中加载,以根据 Tensor 的形状 tensor->ne[] 和步长 tensor->nb[] 来计算 Tensor 的数据大小 (Tensor 是一种多维数据结构,通常用于机器学习和数值计算)

ggml_blck_size 通过方法 ggml_blck_size 获取其对应的 blck_sizeggml_blck_size 是全局变量 type_traits 的包装器 (有趣的是,过去在 ggml_blck_size 中发现了漏洞,当时 ggml_tensortype 没有限制/检查,这允许基于 type_traits 全局变量进行越界读取,直到他们在 Tensor->type 上引入了大小限制); 这些 .blck_size 不会线性增加,而是取决于 GGML_TYPE_X 自身的属性。

这里有趣的部分是 nbytesTensor 的大小由 tensor->ne[i] 数组、tensor->nb[0]tensor->type (使用 ggml_blck_size 转换为 blck_size) 来计算和确定,这意味着,如果 Tensor(ne[] || nb[]) 是可控的,那么返回的 nbytes 也会是可控的 (为了更深入地研究关于 ggml_nbytes 计算的利用部分,我们现在将不解释大小是如何计算的)。这在 llama.cppggml_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 的大小是由 srcTensor 维度大小使用 ggml_nbytes 计算的,你将开始看到问题。你看,尽管 ggml-rpc.cpp:854 使用 uint64_t tensor_size = (uint64_t) ggml_nbytes(result) 检查了 ggml_nbytes,以及 tensor->databuffer_start但是,这些仅检查越界是否发生在 buffer->context 内部

在将一个 src->data 复制到另一个 contextdst->data 的情况下,ggml_nbytes 的计算是由输入可控的 Tensor 成员 ne[]/ nb[] 操作的,这些成员将在 deserialize_tensor() 期间精确地复制,并且此 iface 实现没有比较 srcdst 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 操作为例:

  1. 进入 static void rpc_serve_client(ggml_backend_t backend, sockfd_t sockfd, size_t free_mem, size_t total_mem) {,RPC 服务器在这里监听套接字连接
  2. 根据 p8() 命令指示,进入特定的 case switch;在本例中,它将进入 case RPC_CMD_GET_TENSOR:
  3. 然后进入 server.set_tensor(input),这是用于处理请求的 rpc_server 类型方法,服务器在这里反序列化张量,检查边界和检查...
  4. 最终,进入 ggml_backend_* 的方法(在本例中为 ggml_backend_tensor_set),此方法位于 ggml-backend.cpp 文件中,其中发生关于张量的实际操作。
  5. ggml_backend_* 文件内部,由于 RPC 服务器的不同类型/架构,Llama.cpp 不使用单个静态方法来操作这些张量;相反,这些操作 "线程" 在运行时分配并存储在 buffer 线程上(作为 buf->iface),例如 tensor_set 操作调用 buf->iface.set_tensor(buf, tensor, data, offset, size); 最终。

这意味着,如果我们能够通过溢出控制缓冲区地址(不是上下文地址),因为它们在如此相邻的地址中,那么我们可以控制 buffer 结构的成员;例如,通过操作 buffer->iface,后端操作方法。我们可以将执行流重定向到任意地址;通过操作 context 变量,我们可以绕过现有的范围检查,以重新建立/绕过先前漏洞的缓解措施!

目前,我们的工作将是使用 cyclicflat() 来操作堆,计算块和结构成员之间的堆偏移量;这是一个漫长的过程,因为通常其他堆组件会在溢出期间受到影响。溢出的大小需要仔细计算,否则你将无意中覆盖,这需要你使用 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_baseggml_backend_buffer_get_size 检查 tensor->data 是否在范围内(这两个方法都依赖于 buffer 成员 buffer->contextbuffer->size),而实现检查边界是否可能在请求参数的影响下损坏。问题出现在这里:将此溢出利用到一个新的、地狱般的水平。即使我们可以控制 RPC 服务器到任意地址的执行流程,我们也没有任何地址可以通过操作执行流程来利用。通常,这会是一个容易解决的问题,因为我们已经控制了 buffer 结构,我们可以操作在 tensor->data / buffer base 的检查过程中使用的 buffer 成员,以绕过先前漏洞的先前补丁。但是,这里的溢出悖论发挥了作用:

  1. 为了绕过 tensor->data / buffer_base 边界缓解,我们将不得不修改 buffercontext / get_base 成员
  2. 修改 buffer 会损坏其他 buffer 成员和指针,因为我们尚未获得/泄漏 ggmlbase 的基地址来计算这些 buffer->iface 指针的实际地址。
  3. 为了泄漏 ggmlbase 指针或 ggmlbase 基地址,我们必须在合法 tensor->data 范围之外,这意味着我们必须绕过边界缓解,这将我们带回了第一步。

重新提及我们在第一次溢出时持有零指针的事实,溢出悖论 使得仅依靠 buffer 进行利用成为不可能 - 当我们可以编辑诸如 contextget_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 中编译的任意方法。但是,再仔细看一下,你会发现 这实际上是一个极其困难的漏洞利用路径,因为:

  1. 控制 ggml_nbytes 非常困难/耗时,因为你无法 "计算" 大小。相反,你将需要更改张量的维度规范,留下大约 40 种不同的组合。
  2. 在最好的情况下,我设法部分溢出了第一个成员的最后两位 - free_buffer - 很难在 0xffff 范围内找到 gadget/函数(0x17000 < addr < 0x26fff 转换为 ggmlbase 库中的偏移量)
  3. free_buffer 在苛刻的条件下被调用,只有 buffer 被解析到函数中,从而消除了使用 ggml_backend_cpu_buffer_get/set_tensor 来管理任意写入/任意读取的机会。

上图展示了 ggmlbase 库中存在多少方法/gadget,你看到的大多数 GOT 都超出了可控范围 0x17000 < addr < 0x26fff,我们无法尝试部分覆盖这些地址,因为控制超过 0xff 空间需要太多的资源(因为 vmmap 基地址与 0xfffffffffffffff000 对齐,最后三个半字节是固定的,尽管基址发生变化,我们仍然可以在多线程中的 1 位数字差异期间进行预测,因为我们只需要猜测 0xf max)。

在最初几个小时研究部分写入时,这确实_似乎_