CVE-2023-27997 – Forensics short notice for XORtigate

Taking this into account, we first tried to locate the execution of different exploits in the equipment’s GUI. Some crash of the /bin/sslvpnd process helps to identify a few, but as explained in the article (and tested), the vulnerability can be exploited without crashing the /bin/sslvpnd process.

In order to obtain interesting traces, two approaches were used:

1) Use of the exploit to take control of the equipment in order to collect system evidence and RAM:

This step will not be detailed here. However, it is important to note that the exploitation of the CVE allows the entire device to be compromised, without leaving any trace on the disk, and allows the attacker to modify any evidence if he wishes. Likewise, we will not go into the various ways of setting up persistence.

2) Use of the equipment command line interpreter:

We tried that way because it fits with our test environment, you may access these logs in a different way.

# Set up the logs on your prompt
CLI > diagnose debug enable

# Set up the configuration
diagnose debug application sslvpn 0xflag_you_want_in_hexa

The configurations set up to identify the first traces of CVE execution are the following

sslvpn debug level is 2167679 (0x21137f)

Log Level:
Emergency             0x00000001 : enable
Alert                 0x00000002 : enable
Critical              0x00000004 : enable
Error                 0x00000008 : enable
Warning               0x00000010 : enable
Notice                0x00000020 : enable
Information           0x00000040 : enable
Debug                 0x00000080 : disable

Log Module:
SSL Information       0x00000100 : enable
HTTP proxy            0x00000200 : enable
RADIUS Frame IP       0x00000400 : disable
Mod gzip              0x00000800 : disable
Authentication        0x00001000 : enable
FTP                   0x00002000 : disable
SMB                   0x00004000 : disable
SFTP                  0x00008000 : disable
HTTP request          0x00010000 : enable
DNS                   0x00020000 : disable
DTLS state            0x00040000 : disable
DTLS tunnel           0x00080000 : disable
LibPPP                0x00100000 : disable
WebSocket             0x00200000 : enable
Telnet                0x00400000 : disable
SSH                   0x00800000 : disable
RDP                   0x01000000 : disable
VNC                   0x02000000 : disable


sslvpn debug level is 2167807 (0x2113ff)

Log Level:
Emergency             0x00000001 : enable
Alert                 0x00000002 : enable
Critical              0x00000004 : enable
Error                 0x00000008 : enable
Warning               0x00000010 : enable
Notice                0x00000020 : enable
Information           0x00000040 : enable
Debug                 0x00000080 : enable

Log Module:
SSL Information       0x00000100 : enable
HTTP proxy            0x00000200 : enable
RADIUS Frame IP       0x00000400 : disable
Mod gzip              0x00000800 : disable
Authentication        0x00001000 : enable
FTP                   0x00002000 : disable
SMB                   0x00004000 : disable
SFTP                  0x00008000 : disable
HTTP request          0x00010000 : enable
DNS                   0x00020000 : disable
DTLS state            0x00040000 : disable
DTLS tunnel           0x00080000 : disable
LibPPP                0x00100000 : disable
WebSocket             0x00200000 : enable
Telnet                0x00400000 : disable
SSH                   0x00800000 : disable
RDP                   0x01000000 : disable
VNC                   0x02000000 : disable

It has to be noted that we found evidence of the execution on both configurations. Thus, the Log level at « Debug » (0x00000080) may not be necessary to detect the exploit.

Short analysis

Below is an extract of the executions that can be found on the test environment with the 2nd collection approach.

This summary is not an exhaustive analysis, but it does highlight a few patterns.

32-bit environment

The 1st step which appears is the step to obtain the salt. As explained in the blog, salt is a random value created by the server, which can be retrieved by issuing a GET request to /remote/info.

The second request that appears /remote/hostcheck_validate corresponds to the set up of the heap.

32bit: first evidence

At the very beginning of the following capture, we noticed the /remote/error request, which is a way found to force the reallocation of the buffer for the HTTP response, in order for « out » to take its place in the heap. « out » is then allocated on the top of the SSL structure.

Next, the following requests are related to the « xorverflow » which rewrites the SSL structure. It is important to note that, depending on the payload used, the volume of these requests may vary in the logs. This means that depending on the opponent you are looking for, the behavior can vary in the logs:

  • Standard threat actor: For overflow to be optimal and functional, these requests need to be sent at extremely short intervals. The timing of these requests is therefore more important than their volume (from a forensics/detection perspective).
  • Advanced Threat Actor: If the heap is set up correctly, there is no need to send successive requests. The rewriting of the SSL structure can be perfectly spaced out over time.

32bit: second evidence

One step that is specific to the CVE-2023-27997 exploitation on 32bit is the queries that are made to the web page that echoes back some inputs. It is these requests that we found in the last section of the screen.

The URL is not described here (as in the original blog), but it should be noted that different approaches (or URL requested) may work.

64-bit environment

The 64-bit environment broadly follows the same logic when we look only at the existing traces. First, the salt is retrieved and the /remote/saml/logout that appears after corresponds to the set up of the heap.

64bit: first evidence

As expected, a rewriting of the SSL structure follows with a lot of requests for this payload (364 requests).

64bit: second evidence

Note: I would like to emphasize once again the existence of certain payloads that could, for example, be successful with just 5 requests. Here, the sequence of requests is therefore the point to pay attention to, not the number of requests.

We also noted the presence of the « enc » variable in the request /remote/hostcheck_validate?enc=value, which is targeted in this vulnerability. However, this value is truncated. The hexadecimal value displayed corresponds to the value of the seed. The seed value generally starts with « 0x00 » and must be 8 hexadecimal character long. No trace of payloads has been identified with this approach.

The « enc » variable is encrypted with a keystream generated from a constant, a value provided by the server (the salt, which changes at runtime), and a value provided by the attacker (the seed). As a result, you cannot always « decrypt » the payload, as if the main sslvpnd process had crashed, the salt would have changed.


In addition to the « blue teamers » elements mentioned in the original blog, it is possible to identify the execution of the CVE-2023-27997 in our test environment. From a defensive point of view, the first thing to remember is to apply the editor’s patch.

If you need to carry out a forensic analysis, in the hope that the integrity of evidence has been preserved, we advise the following:

  • Do not rely solely on the application crash of the /bin/sslvpnd process;
  • Take an interest in the sequencing of the requests used in the logs;
  • Question the presence of /remote/logincheck and /remote/hostcheck_validate requests;
  • Investigate the presence of HTTP requests containing the variable « enc » and do not forget the possibility for the attacker to use the POST request;
  • Pay attention to the size of « enc »;
  • Read how this vulnerability works in detail;
  • And most importantly, stay proactive in monitoring the news concerning the investigation of this CVE. This leaflet may be incomplete. Additions and adjustments will be made as real cases come to light.

XORtigate: Pre-authentication Remote Code Execution on Fortigate VPN (CVE-2023-27997)

We’ll describe here the bug and the exploitation process on two architectures, along with a few pointers for blue teamers.

The bug

The bug is located on the web interface that allows users to authenticate to the VPN. This interface is, by design, internet-facing. If we hit the path /remote/hostcheck_validate, we can send an HTTP parameter named enc, through GET or POST. The parameter, which does not seem to be much used now, seems to be an old way for Fortigate to forward HTTP parameters across requests.

The enc parameter is a structure containing a seed, size (2 bytes) and data. Both size and data are encrypted.

enc packet: seed, size, data

The seed, stored as 8 hexadecimal characters, is used to compute the first state of a XOR keystream:

S0=MD5(saltseedGCC is the GNU Compiler Collection.)

salt is a random value created by the server, which can be retrieved by issuing a GET request to /remote/info.

The other states of the keystream are computed like so:

S1=MD5(S0)S2=MD5(S1)Sn+1=MD5(Sn)S=S1S2S3...Sn\begin{matrix} S_1 = MD5(S_0)\\ S_2 = MD5(S_1)\\ S_{n+1} = MD5(S_n)\\ \\ S = S_1 | S_2 | S_3 | … | S_n\\ \end{matrix}

The keystream S can be xored to the rest of the enc payload, the size and the ciphertext, to decrypt them. It is sent as an hexadecimal string.

The decryption method looks like this (simplified code):

int parse_enc_data(char *in)
    int in_len = strlen(in);
    int given_len;
    int xored_given_len;

    if(in_len & 1)
        return 1;

    compute_key_zero(salt, in, 8, md5); // [1] Computes key from salt, seed

    out = alloc_block(*pool, (in_len >> 1) + 1); // [2] Allocate a buffer
    unhex(out, in); // [2] Hexa-decode in to out

    if (out[0]) // first byte of seed must be 0x00
        ap_log_rerror((__int64)a1, 8LL, (__int64)"invalid encoding method %d\n", needs_null);
        return 1;

    // [3] Decrypt given length
    xored_given_len = *((_WORD *)out + 2);
    given_len = (unsigned __int8)(xored_given_len ^ md5[0]);
    BYTE1(given_len) = md5[1] ^ HIBYTE(xored_given_len);

    if ( inlen - 5 <= given_len ) // [4] Verify bounds
        ap_log_rerror(a1, 8LL, "invalid enc data length: %d\n", given_len);
        return 1LL;

    // [5] Decrypt: xor every input from byte 6 (4 bytes for seed, 2 bytes for length)
    p = &out[6];
    if (given_len)
        int i = 0LL;
        while (i < given_len)
            p[i] ^= md5[(i + 3) % 16];
            if ((i + 3) % 16 == 0) // Current state is exhausted: compute new
                MD5_Update(md5_ctx, md5, 16LL);
                MD5_Final(md5, md5_ctx);
    out[6 + given_len] = 0; // [6] Append null byte

    add_kvp_to_hashmap(a1->params, out); // [7] Process plaintext

    // Allocated buffers get freed at the end of the HTTP exchange
    return 0;

The function behaves like so:

  • Compute an MD5 (16 bytes), which is the first state of the key from the salt and the seed (first 8 chars of in)
  • Allocate a buffer of size in_len / 2 + 1, out, and hexadecimal-decoded input into it
  • Compute the length given by the user, given_len, by xoring the first two bytes of the payload with the first two of the key
  • Bound check: verify that the given length is not greater than the size of the buffer
  • Decrypt the whole string in place: XOR the first 14 bytes, then compute a new state
  • use it to XOR the 16 next bytes, and repeat.
  • Put a NULL byte at the end of the decrypted data
  • Add decrypted values to the hashmap containing HTTP input params

The bug is easy to spot: when the program checks that the given length is not greater than the length of the sent payload, it compares in_len to given_len. But while the former describes the length of the payload in hexadecimal (e.g. '41424343'), the latter describes its size in raw bytes (e.g ‘ABCD’). As a result, given_len can be twice as big as it should be.

This bug allows us to apply the decryption process to not only to the ciphertext in out, but also to the memory that comes after.

This makes for a funny bug: instead of just overwriting bytes in the heap, we get to XOR them with some MD5!

Exploit theory

The bug allows us to allocate a chunk of arbitrary size N, out, and then XOR bytes after the buffer with a keystream of MD5 hashes, for which we partially control the key. We control the size of the allocated buffer, and the size of the XOR overflow. In addition, the last byte of the overflow gets nulled.

Hereafter, we name BiB_i the ith byte in memory, and Ki the ith byte of the keystream. Therefore, triggering the bug with a length of L applies:


We « control » the MD5 hashes because we partially control the bytes used to create the first one:
{S0=MD5(saltseed”GCC is the GNU Compiler Collection.”)Sn+1=MD5(Sn)Sn=K8×ntoKn×8+7\begin{cases} S_0 = MD5(salt | seed | \text{ »GCC is the GNU Compiler Collection. »})\\ S_{n+1} = MD5(S_n)\\ S_n = K_{8 \times n} \enspace \text{to} \enspace K_{n \times 8+7} \end{cases}

It’s easy to enforce the value of some bytes of the keystream by bruteforcing with the seed. We can’t, however, hope to control all of it.

First idea

The first thing that comes to mind, is xoring the LSB of some address to change its position. Something like this:

00 C9 12 32 BB 7F 00 00 (0x7FBB3212C900) [Original pointer]
30 63 00 00 00 00 00    (0x000000006330) [Part of keystream]
----------------------- XOR
30 AA 12 32 BB 7F 00 00 (0x7FBB3212AA30) [Modified pointer]

However, this is costly: we need to find an MD5 hash which starts with 306300000000. This hash is the hash of another one, which is the hash of another one, etc. If the distance from the hash to the pointer we want to modify is 0x1000 for instance, this would be the 256th state of the key stream. Not to hard to compute once, but to bruteforce…

Even if we manage to get such a hash, the data previous to this modified pointer would get garbled, because it’d be XORed with previous hashes, whose contents we cannot choose.

jemalloc: some allocator

As it sounded pretty hard to get anything working, we took a look at the underlying allocator, to see if there were any way to leverage the bug.

The underlying heap, jemalloc, was unknown to us at the time. We were in a hurry (understand: I ragequit when trying to make shadow work) and not looking to acquire a deep understanding of it. Here’s what we learned about it:

  • Heap metadata is stored independently; you can safely overflow from one chunk (region) to another
  • You can easily get contiguous allocations (after filling holes)
  • There is some kind of LIFO mechanism on allocations of the same size: freeing a chunk of size and allocating the same size yields the same pointer.

The last point actually makes the exploitation very much easier: we can allocate the buffer we overflow from, out, at the same address, repeatedly.

A powerful primitive

Triggering the bug once makes it look like a bad primitive.

However, since we can consistently allocate the out buffer at the same spot in memory, several times, the primitive becomes very good. Indeed, we know that applying the same XOR twice to a value leaves it unchanged:

Bi Ki Ki = Bi B_i \oplus K_i \oplus K_i = B_i

If we were to trigger the bug twice, with exactly the same parameters (seed and length), and if the two out buffers were allocated at the same memory address, each byte in the overflow would get XORed twice with the same value. We’d have completely unchanged memory, except for one byte which would be set to NULL. An improvement: a way to set a byte to zero, with no side effects.

Moreover, triggering the bug twice with the same seed, only the first time setting the length of the overflow to L, and the second time to, yields even better results.

On the first iteration, we would have completely messed up data until the byte at offset LL, which would be NULL. On the second iteration, every byte would get xored with the key twice, and thus become its old self again, except for BLB_L, which would take the value of KLK_L, as 0KL=KL0 \oplus K_L = K_L. Byte L+1L+1 would become NULL. We’d have:

{ Bi unchanged with i [ 0 , L 1 ] BL = KL BL+1 = 0 } \begin{cases} B_i \enspace \text{unchanged with} \enspace i \in [0, L-1]\\ B_L = K_L\\ B_{L+1} = 0 \end{cases}

So, to give byte LL an arbitrary value VV, we would have to set byte L+1L+1 to VV, and all other bytes would get their old value xored with VV twice:

{ Bi = ( ( Bi VKL ) VKL ) with i [ 0 , L 1 ] BL+1 = V } \begin{cases} B_i = ((B_i \oplus (V \oplus K_L)) \oplus (V \oplus K_L)) \enspace \text{with} \enspace i \in [0, L-1]\\ B_{L+1} = V \end{cases}

So, to give byte L an arbitrary value X, we can just compute a keystream such that KL = X, and apply the primitive twice, once with length L, and once with length L + 1.

Here’s an example: we want to set B5000 to 0x50. We compute a seed such that K5000 = 0x50. This is the 8th byte of the 250th state, so S250[8] = 0x50. If for instance the salt is e0b638ac, we can use the seed 00690000 to get:

S250 = 917be512329176985041ff4c5d50126e

We then apply the primitive with length 4999. We get the following:

Applying primitive for offset 4999

We apply it a second time, with length 5000:

Applying primitive for offset 5000

Improving efficiency

We can actually do better, and overwrite several bytes in a row, with one request per byte! Say we want to write ABC\0 in memory, LL bytes after the overflowed buffer. We first compute a seed s0s_0 such that KL+2s0=CK^{s_0}_{L+2} = `C’.

Then, we compute a seed s1s_1 such that:

Using the primitive 4 times, we managed to write ABC in memory! This technique has, however, a huge let-off, as it messes up everything that comes before the modified memory. For the rest of the blogpost, we’ll stick to the initial technique, which requires 2 requests to set one byte.

Exploit practice

Now that we have a decent primitive, we need to find something to overwrite.

Target of choice: SSL

In 2019, Meh Chang and Orange Tsai exploited a heap overflow on the same binary, and chose to modify a structure named SSL. This kind of structure is allocated whenever a client connects to a worker process of sslvpnd, and gets destroyed when the client or the server closes the socket.

This is a perfect target for us.

First, because our primitive requires us to trigger the bug multiple times, and we need to attack a heap structure that persists accross HTTP requests.

Second, because the structure contains a callback handler, handshake_func. Due to the binary not being PIE, we can modify the value of this function pointer, and force the socket to perform an SSL handshake. From there, a standard stack pivot into whatever gets us a nodejs shell (there’s no sh!).

To setup the heap, we’d need to have an empty chunk right before some SSL structure associated with a socket we control.

Attacking on 64 bits

Our test environment was a VM running on Intel 64 bits. The SSL structure has a size of 0x1db8 bytes, so it is allocated in a 0x2000-byte region.

Right before the allocation of the SSL structure, a buffer of size 0x2000 also gets allocated, in order to store the raw HTTP request sent by the client. With an unfragmented heap, the buffer sits on top the SSL structure in memory. This is where we’d like to have out! Luckily, if a client sends a request which is larger than 0x2000 bytes, the program reallocates the buffer, leaving the region empty. As the allocator is LIFO, and we control the size of out, this makes for a very clean exploit:

Create lots of sockets to the HTTPd service (fills gaps):

SSL structures get created, contiguous to request buffers
Send a huge HTTP request on the last socket, to force the buffer to be reallocated:

HTTP request buffer gets reallocated
Use another socket to exploit the vulnerability; out gets allocated right where we need it:

out is allocated on top of the SSL structure
As the sslvpnd binary is huge, the ROP chain is not too hard to build. It is left as an exercise to the reader.

Note: schemas greatly inspired from this one.

Attacking on 32 bits

We discovered a while later that there were Fortigate builds for 32-bit architectures, such as ARM. Our redteam target was running on such a processor. In 32 bits, the SSL structure is basically half the size as it is in 64 (it is mostly made of pointers), so it gets allocated in regions of size 0x1000. The heap setup aforementioned does not work.

We are, however, extremely lucky: in addition to the 0x2000 buffer, the program allocates a 0x1000 buffer to store… the HTTP response. Which also gets reallocated, when too big. Without too much trouble, we were able to find a web page that echoes back some input, resulting in a 32-bit exploit:

Create lots of sockets to the HTTPd service (fills gaps)
Take the last connexion, and a request with a huge POST parameter
The parameter gets echoed, forcing the reallocation of the HTTP response buffer
Use another socket to exploit the vulnerability: the overflowed buffer gets allocated right were we need it.
ROPchain left as an exercice, again.

A few notes for red teamers
We’re withholding the notes until a later date.

A few notes for blue teamers
We believe that providing as much details as we can on the vulnerability will allow you to better understand and protect against it.

Crash or no crash ?
As with all binary exploits, a crash is possible. You might notice in your logs crashes of the /bin/sslvpnd process. However, with proper exploits, you will not detect any.

The bug is exploitable by issuing GET/POST requests to any of the two URLs: /remote/hostcheck_validate, and /remote/logincheck. Simple exploits will require several HTTP requests in quick succession to any of these URLs. It is however possible to make the exploit slower, by carefully setting up the heap.

It is possible for the attacker to live exclusively in memory, without modifying anything on the disk. Although reboots might remove in-memory payloads, there are ways to attain persistence on the appliance. The best way to protect yourself is to PATCH.

The video shows the first exploit we built, targeting x64: