2022 年 12 月 12 日,Fortinet 官方发布了影响 FortiGate SSLVPN 的 RCE 漏洞 CVE-2022-42475 相关信息。官方公告显示该漏洞已经被发现在野利用,建议所有用户尽快升级。本文对此漏洞的成因进行分析。
环境准备
Fortinet 官方对 Fortigate 等设备的虚拟机版本开放下载,下载链接:https://support.fortinet.com/Download/VMImages.aspx
下载到虚拟机镜像后导入 vmware 安装,第一次启动先配置网络
1 2 3 4 5 6 7 |
使用默认用户 admin:空密码 登录到 CLI config system interface edit port1 set mode static set ip 192.168.x.x/255.255.255.0 end |
配置好网络后通过浏览器访问到设备 web 界面,首次登录系统会要求导入 license。这里有两种选择,一是完整 license,二是试用版。我们选择试用版 license,先去官方网站注册一个 FortiCloud 账号,然后在系统上登录,等待重启即可。
漏洞位于设备的 SSLVPN 功能中,分析前需要配置 VPN 功能。配置过程可参考官方文档,简单来说,首先在 User & Authentication -> User Definition 功能中创建一些 VPN 账户,添加到同一个 group 中。然后在 VPN -> SSL-VPN Settings 中填写监听网卡和端口等信息。最后按照提示创建一条防火墙规则允许外部请求进入。
这样访问对应接口即可看到 SSLVPN 界面。
代码和权限获取
我们采用挂载磁盘的方法,关闭虚拟机,将较小的磁盘卸载并挂载到另一台 Linux 系统上,开机之后看到系统识别到一些硬盘分区:
在 FORTIOS 分区中的 rootfs.gz 是主要文件系统,将其解压得到一些系统文件,但 bin 等目录下没有任何内容。我们参考网络上的文章发现关键文件在 bin.tar.xz、migadmin.tar.xz 等压缩包内,这些压缩档案使用 Fortinet 自己修改过的工具打包。具体解包方法,在解压目录下执行命令
1 2 3 4 |
sudo chroot . /sbin/xz --check=sha256 -d /bin.tar.xz sudo chroot . /sbin/ftar -xf /bin.tar sudo chroot . /sbin/xz --check=sha256 -d /migadmin.tar.xz sudo chroot . /sbin/ftar -xf /migadmin.tar |
解包之后找到 /bin/init,系统中的大部分业务程序都软链接到该二进制文件,是我们主要的分析目标。
按照相同的方法提取出 7.2.2 和 7.2.3 中的 init 文件,准备进行补丁分析。
权限获取可参考网络文章。
漏洞分析
首先进行补丁对比,将不同版本的 init 程序导入 IDA 分析,保存 idb 之后用 bindiff 比较,需要注意一点,直接使用 bindiff GUI 可能会卡在解包 idb 阶段,建议使用 IDA 中的 bindiff 插件比对,程序较大需要分析较长时间。
比较完成后按照相似度和置信度逐个分析代码差异,新版本对 wad 部分进行了很多修改,除此之外比较明显的修改位于内存分配函数中。
举例来说,7.2.2 版本中某内存分配函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
__int64 __fastcall sub_1776C70(__int64 a1, __int64 a2, unsigned int a3) { __int64 v4; // rax __int64 v5; // r12 v4 = je_malloc(); v5 = v4; if ( !v4 ) { sub_16CFB00(0, 8, "malloc(%ld) calling from %s:%d failed.\n", a1, a2, a3); return v5; } ++qword_A8AC610; if ( !byte_A8AC620 ) return v5; sub_1777590(v4, a1, a2, a3); return v5; } |
在 7.2.3 中对应函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
__int64 __fastcall sub_1776E60(unsigned __int64 a1, __int64 a2, unsigned int a3) { __int64 v3; // r12 __int64 v5; // rax v3 = 0LL; if ( a1 > 0x40000000 ) return v3; v5 = je_malloc(); v3 = v5; if ( !v5 ) { sub_16CFB30(0, 8, "malloc(%ld) calling from %s:%d failed.\n", a1, a2, a3); return v3; } ++qword_A8AD770; if ( !byte_A8AD780 ) return v3; sub_17777D0(v5, a1, a2, a3); return v3; } |
我们发现新版本的内存分配相关函数中都添加了对 size 的判断,要求其不能大于 0x40000000
考虑到该漏洞是一个堆内存溢出,根据修复方式推测漏洞的根本原因可能是某处发生整数溢出,导致内存分配函数返回了一块较小的内存,而后续拷贝数据时又使用了较大的 size。
而在 HTTP 请求中可能有两种情况会导致以上结果,一是某些功能 handler 函数中对用户提交的参数验证不严格,或者代码在解析请求时对 Content-Length 的解析出现异常。
sslvpn 中在未授权情况下能够访问的功能点不多,漏洞出现在请求解析阶段可能性比较大。sslvpnd 是基于 Apache httpd 修改而来,开发者在其中添加了很多自定义代码,导致复杂度较高,而且程序不包含符号信息,分析起来会消耗很多时间。
我们可以采取更简单的方法,基于补丁分析和推测,漏洞可能发生在解析请求,特别是处理 Content-Length 阶段。那么只需要按照 fuzz HTTP 协议的思路,构造一些带有畸形 Content-Length 的请求,例如 CL 过大、或者等于负数的情况,将这些请求发送到能够未授权访问的接口中,同时检测 web 服务状态,发生崩溃或无法收到响应时记录下对应的请求报文。
编写出测试脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import socket import ssl path = "/remote/login".encode() content_length = ["0", "-1", "2147483647", "2147483648", "-0", "4294967295", "4294967296", "1111111111111", "22222222222"] for CL in content_length: try: data = b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.232.129\r\nContent-Length: " + CL.encode() + b"\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\na=1" _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) _socket.connect(("192.168.232.129", 4443)) _default_context = ssl._create_unverified_context() _socket = _default_context.wrap_socket(_socket) _socket.sendall(data) res = _socket.recv(1024) if b"HTTP/1.1" not in res: print("Error detected") print(CL) break except Exception as e: print(e) print("Error detected") print(CL) break |
运行后当发送 CL 等于 2147483647 时服务器没有响应,手动测试结果也一致。
挂载调试器尝试捕获异常信息
发包之后产生段错误,访问 rdi 时遇到非法地址。通过栈回溯分析其调用信息,最终找到了关键函数 read_post_data
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
__int64 __fastcall read_post_data(__int64 a1) { __int64 *v1; // r12 __int64 v2; // rax __int64 v3; // rbx int v4; // eax int v5; // er12 __int64 v6; // rdi __int64 content_length; // rdx int v8; // er12 __int64 v10; // rdx int v11; // er12 v1 = *(a1 + 736); v2 = get_req(*(a1 + 664)); v3 = v2; if ( !*(v2 + 8) ) *(v2 + 8) = pool_alloc(*v1, *(v2 + 24) + 1); // Content-Length v4 = unknow_0(v1, v3 + 32, 8190LL); v5 = v4; if ( v4 ) { if ( v4 < 0 ) { if ( unknow_1(*(a1 + 616)) - 1 <= 4 ) return 0LL; } else { v6 = *(v3 + 16); content_length = *(v3 + 24); if ( v6 + v4 > content_length ) v5 = *(v3 + 24) - v6; if ( content_length > v6 ) { memcpy((*(v3 + 8) + v6), (v3 + 32), v5); v10 = *(v3 + 24); v11 = *(v3 + 16) + v5; *(v3 + 16) = v11; if ( v11 < v10 ) return 0LL; } else { v8 = *(v3 + 16) + v5; *(v3 + 16) = v8; if ( v8 < content_length ) return 0LL; } } } return 2LL; } |
这个函数负责从 POST 请求体中读取输入,其基本逻辑:首先获取到用户提交的 Content-Length 值,传入 pool_alloc 函数中分配内存空间,之后使用 memcpy 将用户数据拷贝到刚刚分配的内存中。
问题就出在 pool_alloc 参数上面,查看汇编指令
1 2 3 4 5 |
mov eax, [rax+18h] mov rdi, [r12] lea esi, [rax+1] movsxd rsi, esi call pool_alloc |
rax 为用户请求结构体指针,偏移位置 0x18 存放了 CL 值。先将 CL 放在 eax 寄存器中,使用 lea 指令将其加一后放在 esi 寄存器,再用 movsxd 扩展为 64 bit 值。结合调试信息就可以看到程序为何崩溃。
在 fuzz 脚本中传入 CL = 2147483647,换成 hex 为 0x7fffffff,经过上面的运算当传入 pool_alloc 时寄存器情况:
pool_alloc 函数的伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
void *__fastcall sub_164E590(__int64 a1, size_t a2) { _QWORD *v2; // rax char *v3; // r8 unsigned __int64 v4; // rbx unsigned __int64 v7; // rdi __int64 v8; // rax v2 = *(a1 + 8); v3 = v2[2]; if ( a2 ) { v4 = 8LL * (((a2 - 1) >> 3) + 1); if ( &v3[v4] > *v2 ) { v7 = dword_A8AC5A4 - 25; if ( v7 < v4 ) v7 = 8LL * (((a2 - 1) >> 3) + 1); v8 = malloc_block(v7); *(*(a1 + 8) + 8LL) = v8; *(a1 + 8) = v8; v3 = *(v8 + 16); *(v8 + 16) = &v3[v4]; } else { v2[2] = &v3[v4]; } } else { v3 = 0LL; } return memset(v3, 0, a2); } |
传入数据参与补齐运算,然后判断在 v3 数组中对应位置是否存在内容,不存在则直接调用 memset 返回,而当调用 memset 时参数情况:
length 部分变成一个非常大的数值,这样会导致 memset 访问到非法内存使程序崩溃。
考察漏洞根本原因,在调用 pool_alloc 函数时使用 32 位数值 + 1 拓展成 64 位的方法,这里存在整数溢出。那么我们可以构造特殊的 CL 值,比如 0x1b00000000,经过运算拓展之后会变成 0x1,在 pool_alloc 内部调用 memset 时情况:
缓冲区是位于 heap 的一块较小内存,而 size 已经变成 0x1。
这样 pool_alloc 返回了一块较小的堆内存,假设此时我们在 POST 请求体中构造了超长的数据,那么在后续的 memcpy 阶段就会导致堆内存溢出。
某些情况下能够得到如下 crash
利用分析
对于 FortiGate 堆溢出的利用,DEVCORE 曾介绍过思路:https://devco.re/blog/2019/08/09/attacking-ssl-vpn-part-2-breaking-the-Fortigate-ssl-vpn/
传统堆溢出利用需要结合堆相关的管理逻辑,通过精心控制堆块排布来控制程序执行流。但正如 DEVCORE 文章和我们 fuzz 结果显示,在 FortiGate 上堆溢出会覆盖堆中某些关键结构体中的数据,具体来说是 HTTP 请求的 SSL 结构体指针。在触发漏洞之前先发送很多正常的 HTTP 请求,这样在堆中就会留下很多 SSL 结构,再触发堆溢出去覆盖这些结构体,当程序调用被覆盖的结构体中 handshake_func 指针时,我们就能直接劫持程序控制流。
观察崩溃现场,rdx 寄存器指向可控内存,我们可以在程序中找到 push rdx ; pop rsp
的 gadget,将 stack 迁移到可控内存中,将堆溢出转换成 ROP,直接执行 system(‘cmd’) 即可。
补丁
新版的 read_post_data 调用 pool_alloc 时代码
1 2 3 4 |
mov rax, [rax+18h] mov rdi, [r12] lea rsi, [rax+1] call pool_alloc |
不再使用 32 位寄存器拓展,并且分配内存时会检查 size 大小。
参考文章/拓展阅读
DEVCORE 关于 FortiGate 堆溢出漏洞利用的文章。
2023 年 1 月 11 日,Fortinet 官方发布了关于积极利用该漏洞的组织,以及他们所使用工具的分析文章。
原文始发于CataLpa:CVE-2022-42475