CVE-2025-32756:低腰牛仔裤回来了,缓冲区溢出也回来了
2025年5月13日,FortiGuard Labs 发布了一份公告,详细说明了 CVE-2025-32756,该漏洞影响了多种 Fortinet 产品:
- FortiCamera
- FortiMail
- FortiNDR
- FortiRecorder
- FortiVoice
FortiGuard Labs 在其公告中指出,Fortinet 已经观察到该问题在野外被利用。第二天,即5月14日,该漏洞被添加到 CISA KEV 目录中。
该漏洞在公告中被描述为管理 API 中的一个基于堆栈的缓冲区溢出,可能导致未经身份验证的远程代码执行。鉴于它正在野外被利用,我们决定仔细研究一下。如果您不想阅读这篇分析报告,而是想直接进行测试,那么 NodeZero 中已经提供了相关覆盖。
寻找线索
对于我们的逆向工作,我们选择了查看 FortiMail 的已修补和未修补版本。公告中列出的失陷指标(IOC)为我们从哪里开始提供了一些线索。
https://fortiguard.fortinet.com/psirt/FG-IR-25-254
这里显示的日志输出告诉我们几个重要的事情:
- 我们正在寻找一种执行 admin.fe cgi 二进制文件的方法
- Web 服务器正在使用 mod_fcgid,这使得我们在尝试利用目标时生活变得更轻松一些,因为失败的尝试不太可能导致整个 httpd 进程崩溃并将我们锁定在应用程序之外。
从 Web 服务器配置文件 (httpd.conf) 中,我们找到了我们的入口点:
ScriptAlias /module/ "/migadmin/www/fcgi/"
一个快速的 curl 请求验证了我们可以访问 admin.fe 端点:
# curl -k -L -v https://REDACTED/module/admin.fe
< HTTP/1.1 200 OK
< Date: Tue, 20 May 2025 23:17:44 GMT
< Cache-Control: no-cache
< Strict-Transport-Security: max-age=31536000; includeSubDomains
< Set-Cookie: APSCOOKIE_ffbe3e4d0e3350075e9c91f574e799cc=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: ParamStr=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: mTime=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: logLevel=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: logType=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: logStartline=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: logDomain=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: totalLineNumber=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: SearchResultFile=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Vary: Accept-Encoding
< X-XSS-Protection: 1; mode=block
< X-Frame-Options: SAMEORIGIN
< X-Content-Type-Options: nosniff
< Content-Security-Policy: script-src 'self'; object-src 'none'; frame-ancestors 'self' https://*.fortimailcloud.com/ https://fortimail.forticloud.com/
< Transfer-Encoding: chunked
< Content-Type: text/plain
<
{"errorType": 7,"errorMsg": "Failed: Access denied","reqAction": 0,"totalRemoteCount": 0,"collection": "[]"}
不幸的是,当尝试在已修补和未修补版本之间对 admin.fe 进行 diff 时……我们发现二进制文件是相同的。这意味着漏洞可能存在于一个共享库中,所以是时候深入挖掘了。
动手实践
Ghidra 导入视图
哦。恶心。一个 boost 库……这很可能意味着是 C++……为了不在接下来的几个小时里碰壁,我们通过 Ghidra-MCP 桥和 Github Copilot 向 ChatGPT 寻求了一些帮助。
在仔细查看了向我们推荐的函数后,我们发现了一个熟悉的字符串:APSCOOKIE。如果您回顾一下我们之前用 curl 进行的测试,您会注意到返回的 cookie 中有一个正是这个值。
如果您在管理 Web 界面上玩一会儿,您会开始看到偶尔会有包含此 APSCOOKIE 值的对 admin.fe 端点的请求,该值似乎用于会话管理。
解码此 cookie 值后,我们得到:
Era=0&Payload=qCStu1vT3v+Y++5pCCs9M/CxxddCRrC8SHg+9cfRCA42GU7Cf+8p3iBFSl/4vHteSGePZgk7KGMb8kzRR5c2boDUfiiD65jkByiD3DuRCj1NJR7ESpZQIZlOffSxykRbCTp5l3InoU+q6psG+ve+IRDk9za5K0No9T5RNxCwZxM=&AuthHash=kz4cHPsgudYxy4PPp123FUto=&
APSCOOKIE 具有以下 URL 编码的字段:
- Era
- Payload
- AuthHash
这些听起来是开始进行 grep 的好值。
$ grep -rl "Era" ./762 | xargs grep -rl "Payload" | xargs grep -rl "AuthHash"
rootfs/lib/libhttputil.so
$ diff 762/rootfs/lib/libhttputil.so 763/rootfs/lib/libhttputil.so
Binary files 762/rootfs/lib/libhttputil.so and 763/rootfs/lib/libhttputil.so differ
鉴于只有一个文件包含这些值,并且它在产品的已修补和易受攻击版本之间有所不同,我们很可能已经找到了罪魁祸首。
在 Ghidra 中加载两个版本后,我们可以看到这些字符串都在函数 cookieval_unwrap() 中被引用。我们决定让大脑休息一下,看看我们的小 AI 助手能帮我们走多远。
对于第一次响应来说还不错。我们继续。
不幸的是,在此之后响应开始变得越来越不可靠,所以让我们以 ChatGPT 的观察为起点,专注于一些传统的手动分析。
粗略地浏览函数,cookieval_unwrap() 似乎专门用于执行每个 ASPCOOKIE 字段的 base64 解码并将其写回输入缓冲区。由于 Era 预计是一个单位数,让我们将精力集中在 Payload 和 AuthHash 上。我们将浏览该函数的已修补和未修补版本的反编译代码,以追踪对这些值的引用。
----------
未修补:
----------
size_t input_size;
size_t __size;
uchar *AuthHash;
uchar *Payload;
long output_buffer [2];
out_00 = (uchar *)malloc(__size);
iVar2 = __isoc99_sscanf(param_1,"Era=%1d&Payload=%m[^&]&AuthHash=%m[^&]&",&Era,&Payload, &AuthHash);
input_size = strlen((char *)AuthHash);
__size = strlen((char *)Payload);
iVar3 = EVP_DecodeUpdate(ctx,(uchar *)output_buffer,&output_size,AuthHash,(int)input_size);
iVar2 = EVP_DecodeUpdate(ctx,out_00,&local_94,Payload,iVar2);
----------
已修补:
----------
size_t input_size;
size_t __size;
uchar *AuthHash;
uchar *Payload;
long output_buffer [2];
out_00 = (uchar *)malloc(__size);
iVar2 = __isoc99_sscanf(param_1,"Era=%1d&Payload=%m[^&]&AuthHash=%m[^&]&",&Era,&Payload, &AuthHash);
input_size = strlen((char *)AuthHash);
__size = strlen((char *)Payload);
input_size = strlen((char *)AuthHash);
if (input_size < 0x1e) {
iVar3 = EVP_DecodeUpdate(ctx,(uchar *)output_buffer,&output_size,AuthHash,(int)input_size);
iVar2 = EVP_DecodeUpdate(ctx,out_00,&local_94,Payload,iVar2);
已修补和未修补函数之间的关键区别似乎是对用户提供的 AuthHash 值大小的大小检查。在原始版本中,我们可以看到 AuthHash 被解码并写入输出缓冲区,而该缓冲区只够容纳 16 个字节。在已修补版本中,我们可以看到添加的大小检查限制了用户可以在此值中发送的数据量。基本上,我们现在知道 ChatGPT 指出的 memcpy() 并不完全正确,真正的溢出是由于调用 EVP_DecodeUpdate(),它写入了为解码的 AuthHash 值分配的边界之外。
现在我们知道溢出发生在哪里,但它给了我们多少控制权呢?让我们从输出缓冲区开始看一下堆栈分配:
RSP+0x50 : local_78 (16 字节) <- 溢出开始
RSP+0x60 : local_68 (4 字节) <- 被覆盖
RSP+0x70 : local_58 (16 字节) <- 被覆盖
RSP+0x80 : local_48 (16 字节) <- 被覆盖
RSP+0x90 : 已保存的 RBX <- 被覆盖 ---v
RSP+0x98 : 已保存的 RBP <- 被覆盖
RSP+0xA0 : 已保存的 R12 <- 被覆盖 这些在函数序言中保存
RSP+0xA8 : 已保存的 R13 <- 被覆盖
RSP+0xB0 : 已保存的 R14 <- 被覆盖
RSP+0xB8 : 已保存的 R15 <- 被覆盖 ---^
RSP+0xC0 : 返回地址 (RIP) <- 被覆盖
随着执行的继续,这些堆栈值保持不变,直到它们再次被 memcpy() 调用使用。memcpy() 的调用恰好使用了我们已经控制的值,这可能对制作一个有效的漏洞利用很有用,但现在没有必要深入研究,因为我们已经控制了将在函数尾声中写入 RIP 的值。
让我们开始发送一些垃圾数据,看看会发生什么!由于 AuthHashl 需要是有效的 base64,让我们发送一堆正确编码的 NULL 字符。
AuthHash%3DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%3D%3D
看起来很有希望。经过一些循环有效载荷的反复试验后,我们能够确定以下内容:
💥 鉴于像这样的简单内存损坏问题在现代已经变得多么罕见和过时,重温一些上世纪 90 年代的老式黑客技术是一种不错的改变。