Welcome back to our Caught in the FortiNet series. In these blog posts, we’re uncovering multiple vulnerabilities in FortiClient and the Endpoint Management System (EMS). When chained together, these vulnerabilities could lead to the compromise of an entire organization. In previous posts, we detailed how an attacker could gain initial access within an organization by exploiting FortiClient, then spreading to other endpoints on the network using a vulnerability in the EMS.

In this last article of the series, we will showcase a vulnerability enabling the attacker to go the last mile. Despite compromising all endpoints, an attacker would still be executing code under the same low-privileged user as FortiClient’s UI, as the vulnerability leverages weaknesses in the Electron framework of the app. However, during our research on FortiClient, we discovered a local privilege escalation affecting macOS machines running FortiClient.

Impact

Though each vulnerability’s impact differs, when chained together, they form a severe threat capable of granting an attacker complete organizational control with minimal user interaction. The vulnerabilities are tracked as:

  • CVE-2025-25251: fixed in FortiClientMac 7.4.3 and 7.2.9. Fix is also being backported to 7.0
  • CVE-2025-31365: fixed in FortiClientMac 7.4.4 and 7.2.9
  • CVE-2025-22855: fixed in FortiClient EMS 7.4.3
  • CVE-2025-22859: fixed in FortiClient EMS 7.4.3; only EMS 7.4 (Linux-based) is affected by this issue
  • CVE-2025-31366: fixed in FortiOS and FortiProxy versions 7.6.3 and 7.4.8

In this last part of the series, we will focus on CVE-2025-25251, which affects FortiClient on macOS. This vulnerability allows an attacker who already have execute code capabilities on the victim’s machine to escalate their privileges to root.

Technical Details

As we’ve covered in previous posts, FortiClient is built upon the Electron framework, which enables convenient cross-platform development and provides a web-based graphical user interface (GUI). This Electron GUI runs as a process under the permission of the logged-in user.

When an attacker exploits CVE-2025-22855 (which we discussed in Part 1), the arbitrary code they execute inherits the same permissions as the exploited process, which means it runs under the current user’s privileges. However, FortiClient is powerful software that is capable of enabling VPN connections, running system scans, installing certificates, and more. All of these operations require elevated (root) permissions. So, how does FortiClient achieve this when this process is only running with user privileges?

The Electron UI, while being the visible interface of the application, is only the tip of the iceberg. Beneath it, multiple processes and services run in the background, each with different responsibilities and permissions. This design adheres to the principle of least privilege, separating permission levels and granting only the necessary permissions for each function. The elevated processes, often referred to as “helper tools” and commonly registered as LaunchDaemons, facilitate specific actions that require root access. Since the UI itself doesn’t require root, it can run with the current user’s permissions.

But when separating components, developers must ensure they still work together seamlessly. This is achieved using XPC (macOS Interprocess Communication):

Apple provides developers with the option to create XPC services, which expose specific functionalities. A client process can initiate an XPC request to a service registered on the machine, thereby triggering particular application logic. Crucially, any process on the machine can act as a “client”, initiating a request to any available service currently running. This means it is the sole responsibility of the listener service to authorize the client.

One common method developers use to authenticate and authorize the client process is by verifying its code signature. This is a default requirement on macOS for any executable to run. Within this signature, there’s a value called the Team Identifier, which serves as a unique ID for the developer of the software. By using this, an application can ascertain which team developed a given executable and confirm that its code has not been tampered with.

However, when we examined FortiClient’s privileged executables and their corresponding XPC verification mechanisms, we discovered a shared vulnerable practice that enables attackers to bypass this crucial security check.

PID reuse (CVE-2025-25251)

The main handler of XPC requests starts at the shouldAcceptNewConnection function. Here, Fortinet first retrieves the Process Identifier (PID) of the client’s process and then passes it to the isValidPid function:

1
2
3
4
5
6
7
bool ServiceDelegate::listener:shouldAcceptNewConnection:
(ID param_1,SEL param_2,ID param_3,ID param_4)
{
//...
auVar5 = _objc_msgSend$processIdentifier();
bVar1 = _objc_msgSend$isValidPid:(param_1,auVar5._8_8_,auVar5._0_8_);
//...

Within isValidPid, the _proc_pidpath function is used to retrieve the executable path associated with the client’s PID.

1
2
3
4
5
6
bool ServiceDelegate::isValidPid:(ID param_1,SEL param_2,int param_3)
{
//...
_proc_pidpath((int)uVar3,local_439,0x401);
Var1 = _verifySignature(local_439);
//...

This path is then sent to the _verifySignature function, which extracts the executable’s code signature and compares its Team ID against a hardcoded Fortinet Team ID.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ulong _verifySignature(ulong param_1)
{
//...
if ((param_1 != 0) && (param_1 = _CFStringCreateWithCString(0,param_1,0x8000100), param_1 != 0)) {
local_38 = 0;
lVar2 = _CFURLCreateWithFileSystemPath(0,param_1,0,0);
if (lVar2 == 0) {
_CFRelease(param_1);
param_1 = 0;
}
else {
iVar1 = _SecStaticCodeCreateWithPath(lVar2,0,&local_38);
uVar4 = 0;
if (iVar1 == 0) {
local_40 = 0;
iVar1 = _SecCodeCopySigningInformation(local_38,2,&local_40);
uVar4 = 0;
if ((iVar1 == 0) && (local_40 != 0)) {
team_id = _CFDictionaryGetValue
(local_40,*(undefined8 *)PTR__kSecCodeInfoTeamIdentifier_10004c288);
if ((team_id == 0) || (lVar3 = _CFStringCompare(team_id,&cf_AH4XFXJ7DK,0), lVar3 != 0)) {

While at a glance, comparing the client’s executable signature to Fortinet’s team ID appears to be robust, the way they have implemented it is susceptible to a race condition. An attacker can initiate an XPC request from their malicious client process. Immediately after sending the request, they can use posix_spawn to switch the executable associated with their client’s PID to a legitimate Fortinet executable.

If this switch occurs before the listener service fetches the process path from the PID, then the executable that undergoes the signature check will be the legitimate Fortinet executable. Attackers can increase the reliability of this race condition by forking multiple processes and sending numerous XPC messages. This tactic enqueues the messages, slowing down the listener’s verification process and extending the time window for the attacker to successfully perform the executable swap.

From vulnerability to impact

This vulnerability allows an attacker, who has already achieved code execution on a victim’s machine, to execute arbitrary XPC requests on FortiClient’s privileged services. By itself, this doesn’t immediately imply any impact, as the attacker’s capabilities are limited to the functionality exposed by the XPC services. To execute code with the XPC service’s permissions (root), attackers must identify what functions they can invoke and determine if these functions can be leveraged for further exploitation.

In our search for such functions, we discovered the runTool function within the fctservctl2 service. This function offers multiple purposes, determined by the ID provided. Specifically, an interesting code block caught our attention under ID 11:

1
2
3
4
5
6
7
8
9
10
pFVar3 = _fopen(pcVar2,"r");
//...
pFVar5 = _fopen(local_520,"w");
//... some kind of magic ...
_fwrite(abStack_105a0,(long)iVar1,1,pFVar5);
//...
_fchmod(iVar1,uStack_e8._4_2_);
//...
_fchown(iVar1,local_f0._4_4_,(gid_t)uStack_e8);
_unlink(pcVar2);
  1. This code first reads a file from a path provided in the XPC request.
  2. Creates a new file.
  3. Performs some manipulation on the content of the original file.
  4. Writes the modified content to the new file. 
  5. Then updates the file permissions and owner. 
  6. Finally, it deletes the original file that was read.

While this sequence of operations might seem unusual at first glance, it makes perfect sense when we understand the function’s purpose. FortiClient includes a feature that scans files for malware on the machine. If a malicious file is detected, FortiClient quarantines it by moving it to a restricted folder (/Library/Application Support/Fortinet/FortiClient/data/quarantine_sandbox/). It also modifies the file’s content, permissions, and owner to prevent it from being accessed and executed. A common practice among antivirus software.

This specific runTool:11 XPC request is designed to unquarantine a file. It restores all metadata and content of a quarantined file and moves it to a destination defined in the XPC request. If an attacker can create a fake quarantined file and then exploit the PID reuse vulnerability to initiate the unquarantine process, they would effectively achieve an arbitrary file write with root privileges.

However, there’s a small hurdle: legitimate quarantined files are stored within a folder that requires elevated permissions to access. We noticed that when sending a file name in the XPC message, attackers can traverse back and point to any file on the system.

1
2
3
4
5
6
7
int arg1 = 11;
NSDictionary *arg2 = @{
@"FileName":@"../../../../../../../../../../Users/user/Desktop/fake_quarantined.txt",
@"sandbox":@0,
@“DestDir”:@"/"
};
[xpcConnection.remoteObjectProxy runTool:arg1 arguments:arg2 withReply:^(int arg3){}];

From this root-level arbitrary file write, there are numerous options to achieve code execution. But first, we have to reverse engineer the quarantine file format:

  • 0xc0 (192) bytes, which consists of:
    • HEADER_BYTES: 0x3209
    • 40 bytes PADDING1
    • FILENAME length (max 0x400)
    • UNKNOWN length (max 0x80, we are not sure what this is used for)
    • OWNER (8 bytes, used for chown)
    • PERMISSION (8 bytes, used for chmod)
    • PADDING2 (to fit the 0xc0 size)
  • FILENAME
  • UNKNOWN
  • 0xab XOR-ed file content

Using this, attackers can create a simple script that generates a fake quarantined file. Then, one of the simplest methods an attacker could use is to overwrite a daily periodic script, located at /private/etc/periodic/daily/999.local, which is executed daily as root. In the following screenshot, we can see how the file has been changed

On a different terminal, attackers will set up a reverse shell listener and will wait for the daily script to run. After its execution, they will be granted root privileges:

Patch

While our research uncovered several FortiClient code execution vulnerabilities. We decided to focus on the simplest, ‘one-click outdated Electron’ method, due to its simplicity and minimal user interaction. All discovered methods ultimately lead to the same result.

The vulnerabilities we discovered are fixed in the following versions:

  • CVE-2025-25251: fixed in FortiClientMac 7.4.3 and 7.2.9. Fix is also being backported to 7.0.
  • CVE-2025-31365: fixed in FortiClientMac 7.4.4 and 7.2.9
  • CVE-2025-22855: fixed in FortiClient EMS 7.4.3
  • CVE-2025-22859: fixed in FortiClient EMS 7.4.3; only EMS 7.4 (Linux-based) is affected by this issue. 
  • CVE-2025-31366: fixed in FortiOS and FortiProxy versions 7.6.3 and 7.4.8

We urge customers to update their affected Fortinet products to the fixed versions.

Timeline

Date Action
2024-11-20 We report all issues to Fortinet
2024-11-29 Fortinet acknowledges the receipt of the report
2024-12-18 Fortinet confirms the issues are being worked on
2025-01-28 CVE-2025-22855 and CVE-2025-22859 are assigned
2025-03-05 CVE-2025-25251 is assigned
2025-03-28 CVE-2025-31366 and CVE-2025-31365 are assigned
2025-04-08 CVE-2025-22855 is published
2025-04-08 Fortinet shares the CVSS scoring with us
2025-04-08 We request further clarification about the scoring
2025-04-10 Fortinet shares further CVSS details with us
2025-04-11 We provide our feedback regarding the CVSS scoring
2025-05-13 CVE-2025-22859 and CVE-2025-25251 are published

Summary

In this post, we have taken a deeper look into the inner workings of FortiClient and EMS, how they communicate, and what a malicious client could exploit. Using the vulnerability covered in this article, attackers who are authenticated to an EMS can traverse back the upload directory and create arbitrary files on the server with a limited name. We covered a technique attackers can use to overcome this limitation and achieve stored XSS in Apache httpd.

The impact of this vulnerability, when exploited, is the ability to force all the endpoints managed by the EMS to connect to a malicious EMS. This, combined with other vulnerabilities we uncovered, could potentially lead to remote code execution on every endpoint machine within an organization.

In the next blog post, we will go back to focusing on FortiClient and understand more details about its inner workings and what an attacker can exploit further.

We would like to thank the Fortinet PSIRT for their collaboration and responsiveness in addressing these findings.