CVE-2023-27997-FortiGate-SSLVPN-HeapOverflow
由于CVE-2023-27997
漏洞的影响比较大,所以我一直没有公开这篇博客, 但是距离该漏洞公开已经差不多过去了三个月了, 公网的设备应该都修的差不多了吧, 因此这里可以大家分享一下当时我和@leommxj 一起复现该漏洞的笔记。
更具体的漏洞细节可以参考这篇文章: Pre-authentication Remote Code Execution on Fortigate VPN , 而我这里分析版本依旧是 7.2.2
漏洞环境搭建
参考可以参考我上一篇文章 《CVE-2022-42475-FortiGate-SSLVPN-HeapOverflow》
在调试的时候 , 找 @leommxj 和 @explorer 帮我配置了网络环境, 一开始用的是 gdb + vmware 的调试方法,后面改用 gdbserver + gdb 的方法了, 由于 fortigate 的防火墙原因,我们复用了 22 端口 和23 端口
1 | kill -9 $(pidof sshd) && ./busybox_TELNETD -b 0.0.0.0:22 -l /bin/sh |
漏洞分析
当我们向 fortigate sslvpn 发送一个 enc
的 HTTP 参数的时候, 会进到一个 parse_enc_data
的函数逻辑里.
另外这个 enc
处理的 URI 有很多可以进来, 包括 /remote/hostcheck_validate
以及 ^[1] 提到的 /remote/logincheck
, 具体 URI 的选择,我们后文接着会提到 。这里接着分析 parse_enc_data
函数。
1 | __int64 __fastcall parse_enc_data(__int64 a1, __int64 *pool, const char *in) |
首先进到函数里, 会先判断 enc
参数的值是否长度大于11, 且偶数 。
1 | MD5Data(salt, (__int64)in, 8, (__int64)md5); |
当符合要求后, 会以长度的 1/2 的大小分配一个 buffer , 然后中间会经过一些数据处理,然后到达另外一个 check
1 | out = decodedData_ + 2; |
这里会将数据的长度(实际传入的长度 ) 和 enc
这个参数定义的 payload 的长度比较, 如果符合 int_len - 5 <= payloadLength
, 即实际长度大于定义的长度, 即接着往下走。 注意这里会出现一个安全问题:
因为实际分配的buffer 的长度应该是实际长度的 1/2 ,而这里却是用原来的长度比较的,因此后面会发生溢出。但是这里的溢出的字节是一个 md5 异或, 这里会对我们后面的利用提出一点点的难度,但是作者却用了一个很巧妙的来完成 。
这里简单总结下这个函数和提炼下 enc
的结构, 首先 enc
参数是一个包含 seed、size(2 个字节)和数据的结构。大小和数据都是加密的。 大致就下图的样子.
seed 存储为 8 个十六进制字符,用于计算 XOR 密钥流的第一个状态:
S0 = MD5(salt|seed| "GCC is the GNU Compiler Collection.")
1 | int MD5Data(char *salt, __int64 enc, int size, __int64 output) |
这里的 salt
是由服务器创建的随机值, 可以通过 GET /remote/info HTTP/1.1
获取到
密钥流的其他状态计算如下:
- 计算 MD5(16 字节),这是来自盐和种子的密钥的第一个状态(in 的前 8 个字符)
- 分配大小为 in_len / 2 + 1、out 和十六进制解码输入的缓冲区
- 通过将有效负载的前两个字节与密钥的前两个字节进行异或运算,计算用户给定的长度 given_len
- 边界检查:验证给定的长度不大于缓冲区的大小
- 就地解密整个字符串:对前 14 个字节进行 XOR,然后计算一个新状态 𝐾 1个 ,用它对接下来的 16 个字节进行异或,然后重复。
- 在解密数据的末尾放置一个 NULL 字节
- 当程序检查给定长度不大于发送的有效负载的长度时,它会将 in_len 与 given_len 进行比较。但是,前者以十六进制描述有效负载的长度(例如“41424343”),而后者以原始字节描述其大小(例如“ABCD”)。因此,given_len 可以是它应该的两倍大。因此造成了溢出
这里稍微吐槽一下, IDA 的反编译错误导致很多文章对该漏洞的产生原因的描述有些错误
漏洞利用
利用原语
首先第一个问题是我们最终选择了 /remote/hostcheck_validate
来做漏洞的触发, 由于漏洞利用原因需要多次请求, 我们如果使用了 /remote/logincheck
容易触发 login-attempt-limit
的限制, 这个默认限制为 2
接着就是利用原语的问题, 这里直接采用了作者提供的方法 ^[2]
大致的核心原理就是使用两次异或, 这样就不会让前面的数值发生混乱.
假设我们要修改 5000偏移的值为 0xff , . 那么我们要溢出两次, 第一次将长度设置为 4999 , 此时溢出结束后会将 5000 位置的值写成 0 , 紧接着第二次用我们计算好的 seed 通过 0xff ^ 0
的方式 , 将5000位置设置成 0xff
按照作者说明就是:
堆布局
我们的目标是去溢出覆盖 SSL 结构中的 handshake_func
指针, 这利用是参考的 orange 当时的一个博客 ^[3]
1 |
|
SSL 结构体如下 ^[4]:
1 | struct ssl_st { |
那这的问题就是转化为, 我们如何稳定的将 out
这个缓冲区放置在 SSL 结构体的缓冲区前面, 这样溢出的时候我们才能覆盖到。这里我们参考了部分作者的思路, 在我们这个测试版本中, SSL 结构的大小为 0x1db8 字节, 他将分配在 0x2000 的缓冲区内 。 另外提一句这里的堆分配器用的是 jemalloc , 符合一些后进先出的规则,因此我们的最终思路大概就是:
我们用了 gdb 设置当前 PC 为 je_malloc_stats_print
函数地址,打印当前 jemalloc
的分配情况
可以发现默认情况下 0x2000
这么的大的内存是不会怎么使用到的, 因此我们只需要先分配几次 (这里使用 10 次 )分配0x2000 的 buffer,然后释放掉让这连续的内存进入到链表里,方便后面利用的时候让 out 的缓冲区在 ssl 结构体前面。这里的分配原理是通过一个请求给一个解析POST参数的网页 , 在这个请求中,发送了POST key-value对, 其中sizeof(key) = sizeof(struct_ssl) - 0x18 - 0x10 而sizeof(value)=0 , 例如我们发送一个
1 | POST /remote/hostcheck_validate HTTP/1.1 |
这样理想情况下会分分配一个如下的内存:
有三个 AAAA
的内存原因是在解析POST数据的时候,程序会这么做:拿到整个POSTDATA缓冲区(例如a=b&c=d&e=f),然后提取出’&’之前的内容,并把它存储在一个新的块里(那是1个分配)。然后,拿到’=’之前的内容,并把它存储在一个新的块里(两个分配)。然后,它将键和值存储在一个全局哈希映射中,这会导致产生第三个分配
这里为了方便观察分配的情况, 我们还可以用到 gdb 的commands 和 logging 功能。大致就是在 je_malloc
分配结束后下断
1 | //.text:0000000001776C85 E8 D6 5A CC+ call _je_malloc |
我们尽量让其分配的时候是连续的内存:
当然在实际环境中可能有其他的干扰,因此我们可以多分配几次 ,例如我上个版本的利用是分配了 301
次, 然后在这几个 sock 都close掉让其释放。我这部分代码如下。
1 | def heap_layout(IP, port): |
这样之后,我们需要创建两个 sock , 代码如下
1 | def do_rewrtie_ssl_struct(IP, port, salt, seeds): |
其中一个 vul_sock
是用来溢出 buffer , 然后 sock4 是用来分配 ssl 结构体, 用来被溢出的。 这样之后我们就能稳定触发溢出,且稳定的让 ssl 结构体分配在 out 的缓冲区后面
。
栈迁移
当触发溢出的时候, 我们的这个时候指针和内存大概如下:
我们可以发现,当我们控制 PC 后, 这里的 RDI
寄存器指向的是我们的 ssl 结构体, 因此第一个涌上的思路是做栈迁移, 找一个类似于
push rdi; pop rsp; ... ; ret
的 gadget 即可, 我们最后使用的是 push_rdi_pop_rsp = 0x669129 # push rdi ; pop rsp ; pop r13 ; pop r14 ; pop r15 ; pop rbp ; ret
这样就将栈成功迁移到了我们的 ssl stuct , 即可控的可写的缓冲区内。 然后这里预期直接在 ssl 缓冲区接着写我们剩下的 gadget
, 但是这里突然发现了一个问题, ssl struct 似乎有很多结构体不能被写, 一写就报错 。
于是我在这里换了个思路, 接着尝试布局堆结构,理想情况应该是:
或者
在 out 前面 , 或者 ssl struct 的后面布局一块完全可控的内存, 但是由于我们的这块完全可控的内存是不能被 00 截断的, 因此key-value 对的 key 似乎是不能用来布局的,但是这里我想了下, key 不能被用来布局堆, 但是 value 应该是可以的!! 因此我在溢出结束之后, 接着尝试用如下代码发包:
1 | def layout_gadget(IP, port): |
成功在 out 缓冲区写下了一块可控的内存
因此此时内存结构如下:
由于 ssl struct 有很多不能写的地方, 于是我想到一个方法, 尝试去找大量连续是 0 的缓冲区, 然后仅仅写入另外一段 stack pivot chain,将栈迁移到前面的可控缓冲区中 。最后我使用了这样的 chain :
1 | 0x000000000060bdb4 # pop rax ; pop rdx ; ret |
通过这条 chain, 将栈迁移到前面的缓冲区, 进行更复杂的操作。
执行任意指令
在完成此部分之后,接下来就是组装ROP链的过程了。尽管该程序非常庞大,以至于几乎可以找到所需的任何gadget链,但找寻gadget终究是一个相对繁琐的任务。因此,最后决定采用mprotect + shellcode的方法。首先,利用一些gadget将rdi指向ROP链的内存开头。
这一部分内容就留作给读者完成吧
Demo
I learned a lot from @cfreal_ , and it's great to write exploits together with @leommxj.#CVE-2023-27997 pic.twitter.com/nEFndgvoVD
— swing (@bestswngs) June 17, 2023
Reference link
^[1] https://labs.watchtowr.com/xortigate-or-cve-2023-27997/
^[2] https://blog.lexfo.fr/xortigate-cve-2023-27997.html
^[3] attacking-ssl-vpn-part-2-breaking-the-Fortigate-ssl-vpn
^[4] https://github.dev/openssl/openssl/tree/openssl-3.0.0