FortiGate SSLVPN CVE-2024-21762漏洞利用分析

一、漏洞简介

FortiGate二月份发布版本更新,修复多个中高危漏洞,其中一个严重级别漏洞是SSL VPN的未授权越界写漏洞,漏洞预警称该漏洞可能被在野利用。 本文将介绍笔者分析利用该漏洞,实现远程代码执行的过程。

https://www.fortiguard.com/psirt/FG-IR-24-015

二、漏洞分析

本文漏洞分析使用的环境是FGT_VM64-v7.4.2.F-build2571

2.1 diff patch

对修复版本的二进制进行对比(7.4.2和7.4.3),分析发现修复代码位于函数sub_18F4980(7.4.2) 。

分析该函数不难发现,该函数逻辑是读取HTTP POST请求的body数据。同时根据Transfer-Encoding请求头判断是按照chunk格式读取,还是根据Content-Length读取。根据控制流图对比结果,存在两处代码修改:

  • 解析chunk格式时,调用ap_getline读取分块长度,检查ap_getline的返回值是否大于16,大于16认为是非法的chunk length。
  • 读取chunk trailer时,写入\r\n的偏移line_off的赋值来源,修复前line_off的值来源于*(_QWORD *)(a1 + 744),修复后line_offap_getline的返回值。

继续向前回溯,可以找到*(_QWORD *)(a1 + 744)的值正是第一处校验的chunk length字段的长度。

同时阅读代码可以得知,当chunk length字段经过hex解码后值为0时,就会进入到chunk trailer读取的逻辑。

2.2 触发越界写

经过对patch的分析,我们可以得到以下结论:

1、解析chunk时,如果chunk length字段hex解码后值为0,则开始chunk trailer的读取。

2、调用ap_getline读取chunk trailer后,会根据chunk length字段的长度向缓冲区中写入\r\n

因此,如果chunk length字段传入很多个0,0的长度大于剩余缓冲区长度的1/2时,就会触发越界写入\r\n。通过调试可知目标缓冲区位于栈上(函数sub_1A111E0),偏移0x2028的位置保存了返回地址。如果在偏移0x202e的位置写入\r\n,当函数返回执行ret指令恢复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。因此需要把目光放在栈上保存的内存指针上。

3.1 失败的尝试

比较容易想到的是劫持rbp,通过覆盖rbp的低字节,使rbp刚好指向可控的内存区域。当上一级函数返回执行leave ret指令时,就可以完全劫持rip。然而验证时发现即使覆盖了栈上的rbp,也无法劫持rsp和rip,甚至程序不会产生崩溃。 继续向上回溯,找到sub_1A111E0的父函数sub_1A26040,该函数在返回时并没有调用leave ret来恢复rsp,而是直接add rsp, 0x18,因此无法达到预期的效果。

3.2 寻找栈上的内存指针

如上一小节看到的那样,sub_1A26040函数在栈上保存了rbx、r12-r15五个寄存器的值,并在函数返回时恢复这些寄存器。继续向上回溯找到父函数sub_1A27650。可以看到r13中保存的正是参数a1

a1是一个结构体指针,通过调试也可以看出栈上保存的r13是一个堆地址。

如果通过越界写覆盖图中红色区域的内存,那么sub_1A26040函数返回时恢复r13寄存器,就可以篡改a1指针的值。如果能够对堆内存进行布局,使得a1指向提前布置好的内存区域,那么就可以劫持整个a1结构体。同时通过分析sub_1A27650sub_1A26040的代码逻辑,其中存在大量a1多级结构体成员的动态函数调用,因此劫持a1会有更多的利用机会。

3.3 劫持结构体

根据设想,a1指针的低字节被覆盖成\r\n后,可以恰好指向预先布置好的内存。如图所示:

为实现这一效果,需要达成如下条件:

  1. a1结构体地址比堆喷区域地址更高,并且二者间隔很小。
  2. 0x7fxxxxxxx0a0d一定指向伪造的结构体。

调试可以发现a1结构体的大小是0x730,根据jemalloc的对齐规则,会分配到0x800大小的堆块。0x800的堆块在请求处理的过程中并不常用,因此很容易把tcache中0x800的堆块耗尽,同时申请更多新的0x800的块,使得释放后进入tcache。堆喷也选择不常用的大小的堆块,使得新申请的堆块是连续的,同时与新申请的0x800距离较近;堆喷选择使用较大堆块,以保证其地址为0x800对齐,这样就很容易做到每一个伪造的结构体地址的低12比特为0xa0d;堆喷范围不小于0x10000,以保证0x7fxxxxxxx0a0d指向堆喷的区域。 劫持后的效果如图:

3.4 寻找可利用的多级指针

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

当满足 *(_BYTE *)(a1+0x20*(N+6)+0x10)&6==0(0<N<5)时,就会动态调用*(__int64 (__fastcall **)(__int64))(*(_QWORD *)(*(_QWORD *)(a1 + 0x298)+0x70)+0xC0)(a1) 因此,需要把a1 + 0x298成员伪造成一个多级指针,最终指向我们想要调用的函数。由于目标二进制没有开启PIE保护,所以可以在目标二进制寻找符合条件的多级指针。分析二进制,可以发现Rela重定位节中每一个条目的第一个字段都指向对应函数的GOT表地址。

Rela重定位条目
GOT表

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

在堆喷时,将结构体偏移0x298处的值改成0x4368d0就可以实现对system函数的调用,效果如下:

如图所示,动态调用的参数正是a1,指向的内存可控。到这里正常就可以利用system函数执行任意命令了。但是在FortiGate中,/bin/sh文件不具备执行命令的能力,因此使用system函数执行命令无法执行成功。

3.5 劫持RIP

由于system函数无法执行命令,只能再想别的办法完成RCE。现有的条件是可以调用任意的GOT表函数,函数的第一个参数指向的内存可控,所以如果GOT表中存在某个函数会回调参数中的某个成员,就有机会实现RIP劫持。很容易想到在以前的FortiGate漏洞利用中经常使用到的函数SSL_do_handshake

int SSL_do_handshake(SSL *s)
{
    // ...

    s->method->ssl_renegotiate_check(s, 0);

    if (SSL_in_init(s) || SSL_in_before(s)) {
        if ((s->mode & SSL_MODE_ASYNC) && ASYNC_get_current_job() == NULL) {
            struct ssl_async_args args;

            args.s = s;

            ret = ssl_start_async_job(s, &args, ssl_do_handshake_intern);
        } else {
            ret = s->handshake_func(s);
        }
    }
    return ret;
}

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

3.6 ROP

FortiGate主程序是一个All in One的二进制,大小已超过70MB,有大量的gadget可以利用,利用ROP实现RCE并不困难,不再赘述。

四、利用演示

尽管7.4.2版本SSL VPN默认已关闭web mode,浏览器访问返回403,但是该漏洞仍然可以在默认配置下完成利用。

点击原文链接进入查看演示视频。

五、总结

该漏洞与去年的CVE-2023-27997XOR导致的堆溢出漏洞类似,都是看起来比较鸡肋的溢出漏洞,利用过程比较Trick,更像是一道CTF题目。但是与传统攻击堆管理器的CTF题目相比,真实漏洞更多的需要借助上下文的结构体和代码逻辑完成利用。笔者水平有限,如有纰漏,欢迎指正。