The FortiGate Backdoor That Wasn't A Backdoor (CVE-2024-55591)

When authentication is just a really aggressive suggestion

The FortiGate Backdoor That Wasn't A Backdoor (CVE-2024-55591)

The Vulnerability

CVE-2024-55591 is what happens when multiple security controls fail simultaneously in a spectacular chain reaction. Fortinet published this as an authentication bypass vulnerability affecting their FortiOS and FortiProxy products, but calling it just an “authentication bypass” is like calling a plane crash “unscheduled ground contact.” This is actually four distinct vulnerabilities holding hands and skipping through your perimeter defenses together.

The vulnerability allows remote, unauthenticated attackers to gain super-admin privileges on FortiGate firewalls and FortiProxy devices by exploiting weaknesses in the Node.js WebSocket module that handles CLI console access. And before you ask, yes, this was being actively exploited in the wild since November 2024, weeks before Fortinet published their advisory.

Timeline

The exploitation timeline tells a familiar story of a zero-day living its best life in the wild before anyone noticed:

Affected Versions & Patch Status

FortiOS:

FortiProxy:

The unaffected version list is interesting. FortiOS 7.2 and 7.4 dodged this bullet entirely, which suggests the vulnerable code was introduced in the 7.0 branch and wasn’t carried forward. This happens when someone rewrites functionality “the right way” in a newer version without realizing the old version was a security disaster.

Forensic Data Collection

Proof of Concept Information

Multiple public proof-of-concept exploits exist for this vulnerability:

watchTowr Labs PoC:

The existence of reliable, working public exploits means that any script kiddie with Python installed can now compromise vulnerable FortiGate instances. The barrier to entry is approximately “can run python script.py” which is only slightly more challenging than breathing.

Technical Analysis: How The Exploit Actually Works

This is where things get interesting. Let’s walk through the vulnerability chain step by step, because this isn’t a single flaw but rather four security controls that all decided to take a vacation at the same time.

The Node.js Architecture

FortiOS uses a Node.js application to handle management interface functionality. The relevant code lives in /node-scripts/index.js, a sprawling 53,642-line JavaScript file that makes webpack bundles look organized. Within this mess is the code that handles the jsconsole feature, a WebSocket-based web console that provides CLI access through the management interface.

When you click the CLI button in the FortiGate web GUI, the following happens:

  1. Your browser establishes a WebSocket connection to the Node.js server
  2. The Node.js server establishes a Telnet connection to localhost port 8023 (the actual CLI process)
  3. The Node.js server acts as a proxy between your WebSocket and the Telnet CLI
  4. Authentication magic happens (or in this case, doesn’t happen)

Vulnerability Chain Component 1: Unauthenticated WebSocket Creation

The first problem is in the dispatch() function that handles WebSocket routing. When a request comes in to /ws/cli/, the code attempts to get a valid session:

async dispatch() {
    const {session, isCsfAdmin} = await this._getSession();
    if (!session) {
        this._logError('Authorization failed. Closing websocket.');
        this.ws.send('Unauthorized');
        this.ws.close();
        return null;
    }
    // ... continue with authenticated WebSocket setup
}

This looks fine at first glance. No session? No WebSocket. Unfortunately, the _getSession() function has some interesting ideas about what constitutes authentication.

Vulnerability Chain Component 2: The Magic local_access_token Parameter

The _getSession() function tries several methods to establish a valid session. If normal authentication methods fail, it falls back to _getAdminSession(), which is where things go sideways:

async _getAdminSession(request, options = {}) {
    const { headers, url } = request;
    const query = querystring.parse(url.replace(/.*\?/, ''));
    const localToken = query.local_access_token;
    // ... other authentication methods ...
    else if (localToken) {
        authParams[authParams.length - 1] += `?local_access_token=${localToken}`;
        authParamsFound = true;
    }
    if (!authParamsFound) {
        return null;
    }
    try {
        return await new ApiFetch(...authParams);
    }
}

Here’s the critical flaw: if you include any value for the local_access_token query parameter, the code sets authParamsFound = true and proceeds to make an API call. There’s no validation of the token. None. Zero. It doesn’t check if it’s valid, if it corresponds to a real session, or if it’s just the string “definitely_a_real_token_trust_me”.

The code essentially says: “Oh, you have a local_access_token parameter? Well, you must be legitimate then. Let me just ask the REST API if you’re cool.” And then it makes a REST API call to localhost.

Vulnerability Chain Component 3: REST API Trusts Localhost (But Shouldn’t)

When the code makes that REST API call to localhost, it’s supposed to validate the session. The problem is that the REST API sees the request coming from 127.0.0.1 (because Node.js is proxying it) and makes a dangerous assumption.

The REST API authentication mechanism for trusted local requests doesn’t properly validate the local_access_token parameter. It sees:

No verification of the token value. No session validation. No authentication. Just vibes.

Vulnerability Chain Component 4: The Race Condition

Here’s where it gets spicy. Even with the authentication bypass, you still need to authenticate to the CLI process over the Telnet connection on localhost:8023. The Node.js code establishes this connection and sends a “login context” that looks like this:

"admin" "admin" "root" "super_admin" "root" "none" [192.168.1.1]:12345 [192.168.1.1]:443

This is the format: login_name admin_name vdom profile admin_vdoms sso_type forwarded_client forwarded_local

The code waits for the CLI to send a greeting message (“Connected.”) before sending this login context. But there’s a race condition: the WebSocket message handler is already active and accepting messages from the client before the greeting is processed.

ws.on('message', msg => cli.write(msg));
cli.setNoDelay().on('data', data => this.processData(data));

This means you can send your own authentication string to the CLI process via WebSocket before the legitimate authentication happens. If you win the race, the CLI authenticates you with whatever privileges you specified.

Exploitation Steps

Putting it all together, here’s how exploitation works:

  1. Establish WebSocket with bypass:
    GET /ws/cli/?local_access_token=anything HTTP/1.1
    Upgrade: websocket
    
  2. Wait for WebSocket upgrade response: The server establishes the WebSocket and creates the Telnet connection to the CLI.

  3. Race the authentication: Immediately send your crafted login context over the WebSocket:
    "attacker" "admin" "attacker" "super_admin" "root" "none" [13.37.13.37]:1337 [13.37.13.37]:1337
    
  4. Win the race: If your authentication string reaches the CLI before the legitimate one, you’re authenticated with super_admin privileges. The CLI doesn’t validate these parameters; it just accepts them.

  5. Execute commands: Now you have full CLI access. You can:
    • Create admin accounts
    • Modify firewall rules
    • Add SSL VPN users
    • Extract configuration
    • Pivot to internal network

The race condition is relatively easy to win. The watchTowr PoC establishes multiple concurrent WebSocket connections and bruteforces the timing until one wins the race. On a typical system, this takes seconds.

Why This Is Actually Four Vulnerabilities

Let’s count them:

  1. Improper session validation: The local_access_token parameter bypasses session checks with any value
  2. Missing authentication in REST API: The localhost REST API doesn’t validate the token
  3. Race condition in WebSocket handling: Message handlers are active before authentication completes
  4. Insufficient validation in CLI authentication: The CLI process accepts authentication parameters without verification

Any one of these issues alone might have been caught. But together, they create a complete authentication bypass that’s both reliable and trivially exploitable.

The CSF Variant (CVE-2025-24472)

The February update added CVE-2025-24472, which exploits a similar vulnerability through the CSF (Security Fabric) proxy requests. This is essentially the same architectural flaw in a different endpoint. The patches for CVE-2024-55591 also address this variant, suggesting Fortinet did a more thorough review after watchTowr’s disclosure.

Forensic Considerations and Limitations

Now for the part that keeps incident responders awake at night: what forensic evidence does this attack leave behind, and what doesn’t it leave?

What You Might Find

1. Authentication Logs (If You’re Lucky)

Successful exploitation generates logs in the FortiGate system logs that look like legitimate admin activity, because from the CLI’s perspective, it is legitimate admin activity:

type="event" subtype="system" level="information" vd="root" 
logdesc="Admin login successful" sn="[SERIAL]" user="admin" 
ui="jsconsole" method="jsconsole" srcip=1.1.1.1 dstip=1.1.1.1 
action="login" status="success" reason="none" profile="super_admin"

Notice the ui="jsconsole" field. In legitimate use, this is normal. During exploitation, this is your smoking gun. But here’s the problem: the srcip and dstip fields are completely attacker-controlled. Fortinet’s advisory lists these commonly observed IPs:

These aren’t real source IPs. They’re arbitrary values the attacker injects during authentication. The actual attack traffic comes from wherever the attacker is, but you won’t find that in these logs.

2. Admin Account Creation

Post-exploitation, attackers typically create persistent access by adding admin accounts:

type="event" subtype="system" level="information" vd="root" 
logdesc="Object attribute configured" user="admin" 
ui="jsconsole(127.0.0.1)" action="Add" cfgpath="system.admin" 
cfgobj="vOcep" cfgattr="password[*]accprofile[super_admin]vdom[root]" 
msg="Add system.admin vOcep"

The username vOcep is randomly generated by the attacker. Fortinet’s IoC list includes examples: Gujhmk, Ed8x4k, G0xgey, Pvnw81, Alg7c4, watchTowr, fortinet-support. Some attackers have a sense of humor.

3. SSL VPN User Creation/Modification

Attackers commonly create local users and add them to SSL VPN groups for persistent remote access:

type="event" subtype="system" level="information" vd="root" 
logdesc="Object attribute configured" user="admin" 
ui="jsconsole(127.0.0.1)" action="Add" cfgpath="user.local" 
cfgobj="[RANDOM_USERNAME]"

Followed by user group modifications to grant VPN access.

4. Configuration Changes

Firewall policy modifications, address object creation, and other configuration changes will appear in logs, all attributed to jsconsole activity.

What You Won’t Find

1. Initial Attack Traffic

The WebSocket upgrade request that initiates exploitation is likely not logged by default FortiGate logging. Unless you have:

You won’t see the initial exploitation attempt. The first indication of compromise is typically the successful authentication log, which looks like legitimate admin activity.

2. Real Attacker IP Addresses

The IP addresses in the authentication logs are attacker-supplied fiction. The actual source IP of the attack requires:

3. Failed Exploitation Attempts

Failed race condition attempts might generate brief WebSocket connections that quickly close, but these likely aren’t logged as failed authentication attempts. The authentication failure happens at the CLI layer, which doesn’t know about the WebSocket connection.

4. Pre-Exploitation Reconnaissance

Standard port scans and version fingerprinting of the management interface leave minimal traces. The watchTowr detection script, for example, just attempts to establish a WebSocket connection and checks the response, which is barely distinguishable from legitimate browser activity.

What We’ve Observed In The Wild

Based on Fortinet’s advisory and Arctic Wolf’s analysis, real-world exploitation follows a consistent pattern:

Phase 1: Initial Access

Phase 2: Persistence

Phase 3: Lateral Movement

Phase 4: Post-Exploitation

The real attacker IP typically only becomes visible during Phase 3 when they connect via SSL VPN. By this point, they have persistent access and you’re already compromised.

These are the real attacker IPs observed in VPN connections and post-exploitation activity, not the spoofed values in the initial authentication logs.

Forensic Response Recommendations

If you suspect compromise:

  1. Immediately review jsconsole authentication logs for logins you can’t account for, especially with suspicious IP addresses (1.1.1.1, 8.8.8.8, 127.0.0.1, etc.)
  2. Audit admin accounts for recently created users with random-looking usernames
  3. Check SSL VPN user lists for unauthorized local users or unexpected group memberships
  4. Review firewall policy changes made via jsconsole during the suspected compromise window
  5. Examine SSL VPN connection logs for connections from unknown IPs using recently created users
  6. Extract full configuration backup for offline analysis of all changes
  7. Correlate timing between jsconsole activity and external SSL VPN connections to identify the real attacker IP
  8. Assume compromise if: You find admin accounts you didn’t create, SSL VPN users you don’t recognize, or any jsconsole activity during off-hours from accounts that should have been idle

The harsh reality: if your FortiGate was vulnerable and exposed, and you can’t definitively prove it wasn’t exploited, you should probably assume it was.

Detection and Hunting

Let’s talk about detection strategies, both for identifying vulnerable systems and hunting for evidence of exploitation.

Network-Based Detection

WebSocket Connection Monitoring

The most reliable indicator during exploitation is the WebSocket upgrade request to /ws/cli/ with a local_access_token parameter:

GET /ws/cli/?local_access_token=[ANYTHING] HTTP/1.1
Host: [target]
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: [base64]
Sec-WebSocket-Version: 13

If you have:

Look for WebSocket upgrade requests to /ws/cli/ that:

SOC Prime has released Sigma rules for detecting these patterns in web server logs. The rule looks for:

Vulnerability Scanning

The watchTowr detection script can identify vulnerable instances without exploitation:

python fortios-auth-bypass-check.py --target [IP] --port 443

This script:

  1. Attempts WebSocket upgrade to /ws/cli/
  2. Checks if upgrade succeeds without authentication
  3. Does not attempt exploitation
  4. Returns true/false for vulnerability status

Organizations can use this to inventory their FortiGate fleet and prioritize patching. Network security teams can use it to identify rogue or forgotten FortiGate instances.

Host-Based Detection (FortiGate Logs)

Suspicious jsconsole Activity

Hunt for authentication events with jsconsole as the UI:

ui="jsconsole" AND (srcip="1.1.1.1" OR srcip="8.8.8.8" OR srcip="13.37.13.37" OR srcip="127.0.0.1" OR srcip="2.2.2.2")

These spoofed IPs are common attacker choices. Legitimate jsconsole activity typically shows the actual management interface IP.

Also hunt for jsconsole activity:

Admin Account Anomalies

Look for account creation events:

action="Add" AND cfgpath="system.admin" AND ui="jsconsole"

Filter for:

SSL VPN User Activity

Search for local user creation or modifications:

cfgpath="user.local" AND action="Add" 
cfgpath="user.group" AND action="edit"

Followed by SSL VPN connections from these users:

type="event" subtype="vpn" logdesc="SSL VPN tunnel up"

The VPN connection logs will contain the real attacker IP address.

Remediation and Workarounds

Let’s talk about how to actually fix this mess and what to do if you can’t patch immediately.

Primary Remediation: Patch

FortiOS:

FortiProxy:

Use Fortinet’s upgrade tool at https://docs.fortinet.com/upgrade-tool to determine the correct upgrade path. Don’t skip versions unless the upgrade tool explicitly says it’s safe.

Important: The patches for CVE-2024-55591 also fix CVE-2025-24472 (the CSF variant), so you’re killing two vulnerabilities with one firmware upgrade.

Closing Assessment

This vulnerability is bad. It’s being exploited in the wild. It’s trivial to exploit with public proof-of-concept code. It affects tens of thousands of exposed devices. It compromises perimeter security, which is the worst kind of compromise.

If you run FortiGate, patch now. If you can’t patch immediately, implement the workarounds now. Then hunt for evidence of compromise. Then update your security architecture to never expose management interfaces to the internet again.

And if you find evidence of compromise, don’t just patch and move on. Assume lateral movement, hunt throughout your environment, and respond as if your perimeter was completely bypassed. Because it was.

References