2023 年 3 月 7 日,Fortinet 发布了影响 FortiOS 管理端口的漏洞 CVE-2023-25610,官方解释该漏洞为 “堆内存下溢”,通过利用此漏洞,未经授权的攻击者可能实现任意代码执行,本文对该漏洞进行分析。
漏洞分析
关于 FortiOS 本博客中包含数篇历史文章,讨论了如何获取其系统权限、如何通过授权验证以及复现漏洞时的基本思路。
同理,针对此漏洞,我们可以通过补丁对比的方式来分析,或者是编写一些 FUZZ 脚本进行测试。
考虑 FortiOS 曾经出现过的缓冲区溢出类漏洞,包括近期的 CVE-2022-42475,猜测 CVE-2023-25610 应该也是出现在解析 HTTP 请求过程中。在进行补丁对比时我们重点关注处理请求的相关函数,最终找到疑似关键位置 util_read,此函数在 7.x 新旧版本代码如下
1 | // old |
1 | // new |
经过分析确认,此函数用来处理 POST 请求体数据。我们看到它发生了显著变化,新版中添加了一系列大小检查。
那么问题出现在哪里呢?函数中 a1 + 216
变量对应 apache request_rec 结构中的 remaining 成员(可能),表示 body 中还剩余的数据长度。代码获取到这个值(v5),并分配 v5 + 1 大小的内存(记为 heap_start)供后续使用。
随后进入循环,调用 ap_get_client_block 函数每次从请求中获取 0x2000 大小的数据块,将它拷贝到 heap_start 。这里注意到存在一个 int 类型变量 v3,汇编对应 32 位寄存器。循环中不断对它进行累加操作,但循环的边界依靠 int64 类型变量来判断,所以当传入了较多的数据时,变量 v3 将发生整数溢出,回绕成一个负数。
当 v3 变成负数时,代码继续调用 memcpy 尝试将数据块拷贝到堆内存。此时新的目的地址变成 heap_start 减去某值,数据将被拷贝到 heap_start 的低地址方向,可能导致未定义的行为。
根据以上分析,想要让 v3 发生整数溢出,至少要在请求体中构造 2GB 的数据。这一点可能难以实现,本文暂不做进一步讨论。
FortiGate 有几个不同的版本系列,而这个漏洞在不同版本中的形态不同,我们以 6.0.x 为例再次分析。
1 | unsigned __int64 __fastcall util_read(_QWORD *a1, _QWORD *a2, _DWORD *a3) |
逻辑和 7.x 大体相同,但是存在几个关键差异。首先分配内存时不使用 remaining 字段,而是直接使用 Content-Length 的值。第二,alloc 执行 + 1 操作时会将 __int64 类型变量强制转换为 unsigned int 类型,存在整数溢出,这一点和历史漏洞 CVE-2022-42475 相似。
我们构造较大的 Content-Length 值,在 alloc 时发生 int 溢出截断,导致分配了较小的内存空间,而后续循环拷贝数据时又使用了较大的 CL 值,最终将导致堆内存溢出。
利用分析
感谢”天空之城”分享了在复现该漏洞时遇到的一些问题。
- 本文提及的利用分析基于 TLSv1.3,在其他 TLS 版本上可能存在差异。
- 如果使用脚本和服务进行交互,需要注意 openssl 版本问题,比如 python 建议使用 3.11 以上版本。
首先来构造一个可以触发漏洞的 PoC,根据之前的分析,在 POST 请求中伪造一个较大的 Content-Length 值,然后在请求体中填充随机数据,例如
1 | POST /login HTTP/1.1 |
在 httpsd 进程上挂载调试器,然后发送请求,得到以下结果:
段错误导致程序崩溃,观察崩溃的地址,rdi 是一个越界的堆地址,通过查看栈回溯地址,崩溃就发生在 util_read 的 memcpy 位置,拷贝数据时目的地址已经越界。
根据以往的 sslvpn 利用经验,我们可以通过堆溢出覆盖请求的 SSL 结构体,控制函数指针来劫持程序流程。但是 SSL 结构体通常被分配在 0x7f 开头的高地址,而发生越界的位置在较低地址,无法直接覆盖 SSL 结构。
参考历史资料发现,fortios 中内存分配部分使用了 jemalloc,这种内存管理策略中包含地址重用以及针对较大的分配请求重新映射一块内存等机制。
在 PoC 中我们构造的 CL 值为 0x100100000,分配的内存位于低地址,我们尝试扩大 CL 值,看看能否将内存分配到 0x7f 的高地址。
经过测试,当 CL 等于 0x100821000 时,SSL 结构体地址:
发生崩溃时的状态:
此时数据被拷贝到 0x7f 的高地址,对应内存布局:
这样可控的数据恰好位于 SSL 结构体之上且两个内存块之间不存在间隙,可以通过溢出去覆盖其内部变量。
我们观察到 SSL 结构体地址是 0x7f5aee9b8000,而内存块起始地址为 0x7f5aee800000,两者之间还存在大小为 0x1b8000 的其他数据,再查看栈回溯信息和崩溃现场,代码尝试从 0x9e9f1df98d3a1000 地址取值,这显然不是一个正常地址,看起来像经过某种运算后得到的指针。
尝试修改发送的请求数据,发现这个值和我们的输入有关,并且根据 jemalloc 内存管理算法确定,SSL 结构体之上的数据可能是 jemalloc 中的一些关键变量。
当发送某特定值时,可以让以上计算得到一个合法的地址,防止非法地址访问崩溃。在填充到 SSL 结构体之前的数据中还存在数个类似的地址计算过程,依次布置好相关数据就能绕过。
接着覆盖 SSL 结构体,尝试在其中填充随机数据得到以下崩溃:
在对应位置填充一个合法地址,继续执行:
代码用 EVP_CIPHER_flags 函数取值并 call rax 跳转到对应位置。如果可以找到一个合适的目标地址,就能劫持控制流来执行进一步利用。
所谓合适的目标地址,即能否利用程序中的代码片段,将栈迁移到可控内存,构造 ROP 链完成后续利用。在 init 程序中存在一个叫做 ENGINE_ctrl 的函数,其内容如下:
此函数会从参数中获取一些变量,判断之后将取出的值作为函数指针去调用。这个函数地址位于 GOT 表中,且查看 call rax 时寄存器状态发现第一个参数的地址中包含可控数据。我们将 EVP_CIPHER_flags 的地址指向 ENGINE_ctrl 函数,这样就可以将一个只能部分控制程序执行流的情况转变成了完全控制程序执行流。
当执行指针跳转时 RBP 寄存器恰好指向我们可控的数据,这里利用 push_rbp_pop_rsp 实现栈迁移,迁移之后就可以按照常规思路进行 ROP 完成利用。
参考资料
https://www.fortiguard.com/psirt/FG-IR-23-001
https://wanghenshui.github.io/2019/05/01/jemalloc.html
- 本文作者: Catalpa
- 本文链接: https://wzt.ac.cn/2023/05/29/CVE-2023-25610/
-
版权声明:
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。