On May 13, 2025, FortiGuard Labs published an advisory detailing CVE-2025-32756, which affects a variety of Fortinet products:
- FortiCamera
- FortiMail
- FortiNDR
- FortiRecorder
- FortiVoice
In their advisory, FortiGuard Labs states that Fortinet has observed this issue being exploited in the wild. The next day, May 14, the vulnerability was added to the CISA KEV catalog.
The vulnerability is described in the advisory as a stack-based buffer overflow in the administrative API that can lead to unauthenticated remote code execution. Given that it’s being exploited in the wild, we figured we’d take a closer look. If you’d rather run the test instead of reading this write-up, coverage is already available in NodeZero.
Looking for Clues
For our reversing efforts, we chose to look at a patched and unpatched version of FortiMail. The Indicators of Compromise (IOCs) listed in the advisory give us some hints about where to begin.
The log output displayed here tells us a couple of important things:
- We’re looking for a way to execute the admin.fe cgi binary
- The web server is using mod_fcgid, which makes our lives a bit easier when attempting to exploit the target, as it’s unlikely that failed attempts will crash the entire httpd process and lock us out of the application.
From the web server configuration file (httpd.conf), we find our entry point:
ScriptAlias /module/ "/migadmin/www/fcgi/"
A quick curl request validates that we can hit the admin.fe endpoint:
# curl -k -L -v https://REDACTED/module/admin.fe
< HTTP/1.1 200 OK
< Date: Tue, 20 May 2025 23:17:44 GMT
< Cache-Control: no-cache
< Strict-Transport-Security: max-age=31536000; includeSubDomains
< Set-Cookie: APSCOOKIE_ffbe3e4d0e3350075e9c91f574e799cc=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: ParamStr=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: mTime=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: logLevel=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: logType=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: logStartline=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: logDomain=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: totalLineNumber=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: SearchResultFile=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Vary: Accept-Encoding
< X-XSS-Protection: 1; mode=block
< X-Frame-Options: SAMEORIGIN
< X-Content-Type-Options: nosniff
< Content-Security-Policy: script-src 'self'; object-src 'none'; frame-ancestors 'self' https://*.fortimailcloud.com/ https://fortimail.forticloud.com/
< Transfer-Encoding: chunked
< Content-Type: text/plain
<
{"errorType": 7,"errorMsg": "Failed: Access denied","reqAction": 0,"totalRemoteCount": 0,"collection": "[]"}
Unfortunately, when attempting to diff admin.fe between patched and unpatched versions… we discovered that the binaries were identical. This means the vulnerability is likely in a shared library, so it’s time to crack things open.
Getting Our Hands Dirty

Oh. Gross. A boost library… which likely means C++… Instead of beating our heads against the wall for the next several hours, we asked a ChatGPT for some help via the Ghidra-MCP bridge and Github Copilot.


After taking a closer look at the function recommended to us, we spot a familiar string: APSCOOKIE. If you refer to our earlier test with curl, you’ll notice that one of the returned cookies was this very value.
If you play with the admin web interface for a little bit, you’ll start to see the occasional requests to the admin.fe endpoint that contain this APSCOOKIE value, which appears to be used for session management.

After decoding this cookie value we get:
Era=0&Payload=qCStu1vT3v+Y++5pCCs9M/CxxddCRrC8SHg+9cfRCA42GU7Cf+8p3iBFSl/4vHteSGePZgk7KGMb8kzRR5c2boDUfiiD65jkByiD3DuRCj1NJR7ESpZQIZlOffSxykRbCTp5l3InoU+q6psG+ve+IRDk9za5K0No9T5RNxCwZxM=&AuthHash=kz4cHPsgudYxy4PPp123FUto=&
The APSCOOKIE has the following URL-encoded fields:
- Era
- Payload
- AuthHash
Those sound like great values to start grepping for.
$ grep -rl "Era" ./762 | xargs grep -rl "Payload" | xargs grep -rl "AuthHash"
rootfs/lib/libhttputil.so
$ diff 762/rootfs/lib/libhttputil.so 763/rootfs/lib/libhttputil.so
Binary files 762/rootfs/lib/libhttputil.so and 763/rootfs/lib/libhttputil.so differ
Given that there is only one file with these values and it differs between patched and vulnerable versions of the products, it’s likely we’ve found the culprit.

After loading up both versions in Ghidra, we can see that these strings are all referenced within the function cookieval_unwrap(). We decided to give our brains a break and see how far our little AI helper can get us.

Not too bad for a first response. Let’s keep going.

Unfortunately, responses start becoming less and less reliable after this, so let’s focus on some good old-fashioned manual analysis using ChatGPT’s observations as a starting point.
Skimming through the function, cookieval_unwrap() appears to be dedicated to performing the base64 decoding of each ASPCOOKIE field and writing it back to the input buffer. Since Era is expected to be a single digit, let’s focus our efforts on Payload and AuthHash. We’ll skim through the decompilations of the patched and unpatched versions of the function to track down references to each of these values.
----------
Unpatched:
----------
size_t input_size;
size_t __size;
uchar *AuthHash;
uchar *Payload;
long output_buffer [2];
out_00 = (uchar *)malloc(__size);
iVar2 = __isoc99_sscanf(param_1,"Era=%1d&Payload=%m[^&]&AuthHash=%m[^&]&",&Era,&Payload, &AuthHash);
input_size = strlen((char *)AuthHash);
__size = strlen((char *)Payload);
iVar3 = EVP_DecodeUpdate(ctx,(uchar *)output_buffer,&output_size,AuthHash,(int)input_size);
iVar2 = EVP_DecodeUpdate(ctx,out_00,&local_94,Payload,iVar2);
----------
Patched:
----------
size_t input_size;
size_t __size;
uchar *AuthHash;
uchar *Payload;
long output_buffer [2];
out_00 = (uchar *)malloc(__size);
iVar2 = __isoc99_sscanf(param_1,"Era=%1d&Payload=%m[^&]&AuthHash=%m[^&]&",&Era,&Payload, &AuthHash);
input_size = strlen((char *)AuthHash);
__size = strlen((char *)Payload);
input_size = strlen((char *)AuthHash);
if (input_size < 0x1e) {
iVar3 = EVP_DecodeUpdate(ctx,(uchar *)output_buffer,&output_size,AuthHash,(int)input_size);
iVar2 = EVP_DecodeUpdate(ctx,out_00,&local_94,Payload,iVar2);
The key difference between the patched and unpatched function appears to be a size check for the size of the user-supplied AuthHash value. In the original version, we can see that AuthHash is decoded and written to the output buffer, which is only big enough to hold 16 bytes. In the patched version, we can see that the added size check limits the amount of data that a user can send in this value. Essentially, we now know that the memcpy() pointed out by ChatGPT isn’t entirely correct, and the real overflow occurs because of a call to EVP_DecodeUpdate() that writes beyond the bounds allocated for the decoded AuthHash value.
Now we know where the overflow occurs, but how much control does it give us? Let’s take a look at the stack allocation starting with our output buffer:
RSP+0x50 : local_78 (16 bytes) <- Start of overflow
RSP+0x60 : local_68 (4 bytes) <- Overwritten
RSP+0x70 : local_58 (16 bytes) <- Overwritten
RSP+0x80 : local_48 (16 bytes) <- Overwritten
RSP+0x90 : saved RBX <- Overwritten ---v
RSP+0x98 : saved RBP <- Overwritten
RSP+0xA0 : saved R12 <- Overwritten These are saved in the
RSP+0xA8 : saved R13 <- Overwritten function prologue
RSP+0xB0 : saved R14 <- Overwritten
RSP+0xB8 : saved R15 <- Overwritten ---^
RSP+0xC0 : return address (RIP) <- Overwritten
As execution continues, these stack values remain untouched until they are used again by the call to memcpy(). The call to memcpy() happens to use values we already control, which might be useful in crafting a working exploit, but isn’t necessary to delve into for now since we already control the value that will be written to RIP in the function epilogue.
Let’s start sending some garbage data and see what happens! Since AuthHashl needs to be valid base64, let’s send a bunch of NULL characters properly encoded.
AuthHash%3DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%3D%3D

Looks promising. After a bit of trial-and-error with some cyclic payloads, we’re able to determine the following:


💥 Given how rare and antiquated simple memory corruptions issues like this have become in the modern age, it was a nice change of pace to revisit some good old-fashioned 90s-era hacking techniques.
Conclusion
Given that this issue is under active exploitation and there are many vulnerable instances on the open internet, we’ve elected not to publish an exploit beyond this simple proof of concept. FortiGuard Lab’s advisory contains plenty of information regarding valuable Indicators of Compromise as well as detailed mitigation information. Given the ease of exploitation, we recommend all users update or apply mitigations as soon as possible.
Coverage for this issue has been added to NodeZero and is available now.

Start a NodeZero trial and test like an attacker.
Validate your Fortinet defenses against real-world RCE threats.