Author Lucas Laise
Category Vulnerability
Tags 2026, windows, pentest, vulnerability, vpn, named-pipe, Cato Networks, exploit, LPE, CVE-2026-10739
Local privilege escalation in the Cato VPN Client through the split-tunnel upload flow, from named pipe to SYSTEM delete and Windows Installer rollback.
Introduction
During a Purple Team engagement, we had to list and rank risky components across the network. One of them caught our attention: Cato Client, a VPN client program. It was installed everywhere. It runs privileged services. It talks to a GUI. It handles network configuration. From an attacker perspective, this is exactly the kind of software you want to understand. However, saying "this looks risky" is not enough. There is nothing better than PoC||GTFO. So the question was simple: can we find a real bug and turn it into something useful? This is how it started. And then my teammate sent me this message:
💬 YV: "I have a new target for you. It has everything you like: named pipes, protobuf and .NET."
He knows me well. That sounds like a fun challenge.
This writeup covers a local privilege escalation in Cato Client. The bug starts in the split-tunnel upload feature, goes through a named pipe, and ends up with a SYSTEM shell.
🧨 CVE-2026-10739
Cato Networks SDP Client for Windows is vulnerable to Local Privilege Escalation.
Confirmed on versions
6.2.0and6.4.6on Windows. From vendor:any version before 6.12.6
Cato & Split Tunnel
Cato Client is the endpoint client used to connect users to Cato Cloud.
One feature is split tunneling. Cato documentation explains that administrators can let users upload a text file to decide which IP ranges are included or excluded from the encrypted tunnel. The file contains an include or exclude mode, followed by IP ranges and masks.
In the client UI, this feature appears as a local upload for the split tunnel configuration.

Cato Client settings - split tunnel file upload.
A minimal valid .ccst file looks like this:
include
10.10.10.0/24
You can find more information about the split tunnel here.
Walkthrough
Discovery
The first useful observation came from a normal upload.
When a valid CCST file is uploaded, the client accepts it and the (privileged) backend creates split-tunnel state (.stp) under ProgramData.

Valid CCST upload.
So I tried the opposite: upload a file with invalid CCST content.
The parser rejects it. That part is normal. But the cleanup path is more interesting: the generated split-tunnel file is deleted.

Invalid CCST upload. The generated file is cleaned up.
At this point, it is not yet a vulnerability. A privileged process deleting its own temporary file is not enough. Moreover, the destination directory has sane permissions. A limited user cannot just drop or replace things there:
PS C:\ProgramData\CatoNetworks\SDPClient> icacls .\ST\
.\ST\ AUTORITE NT\Système:(I)(F)
AUTORITE NT\Système:(I)(OI)(CI)(IO)(M,WDAC,WO,GR,GW,DC)
BUILTIN\Administrateurs:(I)(F)
BUILTIN\Administrateurs:(I)(OI)(CI)(IO)(M,WDAC,WO,GR,GW,DC)
BUILTIN\Utilisateurs:(I)(R)
BUILTIN\Utilisateurs:(I)(OI)(CI)(IO)(R,GR)
As there was no easy win in the filesystem permissions, the primitive was not "just write a symlink in ProgramData and win".
The next question was: how does the low-privileged GUI ask the SYSTEM process to parse and delete those files?
Named pipe time
Our internal named pipe tool showed the answer: local IPC over a named pipe.

Cato GUI talks to the service through a named pipe.
The pipe is:
\\.\pipe\cato-VPN
The service-side process observed during the proof was:
C:\Program Files (x86)\Cato Networks\Cato Client\winvpnclient.cli.exe
and it runs as:
NT AUTHORITY\SYSTEM
Nice. A low-privileged process asks a SYSTEM process to parse a local file and delete cleanup artifacts.
But direct access failed. We couldn't interact directly with the pipe. Sending commands from a random process to the pipe did not work.
With the help of Ghidra, we can see the named-pipe server checks the process talking to it:
GetNamedPipeClientProcessId(...);
GetNamedPipeClientSessionId(...);
The interesting part is what happens in the client-connected callback after that.
The callback first checks if IPC certificate validation is enabled. If yes, it resolves the executable path from the connecting PID, then verifies the executable certificate:
cVar2 = FUN_1401e5f20(...); // certificate check enabled?
if (cVar2 != '\0') {
FUN_140767200(local_88, ..., client_pid);
cVar2 = thunk_FUN_140753b80(local_88, expected_cert, ...);
if (cVar2 == '\0') {
"%s: Failed to register client. Could not verify client certificate. Close connection.";
FUN_14076fc50(...); // close pipe
}
}
FUN_140767200 is the part resolving the client image path:
hProcess = OpenProcess(0x1000, 0, client_pid);
QueryFullProcessImageNameW(hProcess, 0, local_248, &local_294);
The verification helper then parses the executable signature and compares certificate material:
FUN_1407589d0(...);
FUN_140758aa0(...);
...
iVar2 = memcmp(_Buf1, expected_cert_material, size);
Conclusion: a random process does not pass this check. The pipe connection needs to originate from a process image signed with the expected Cato certificate, unless certificate checking is disabled by configuration.
Bypassing the certificate check
This part seemed familiar. In the K7 writeup, the patch also tried to block random clients from talking to a privileged named pipe. Manual mapping a payload into a signed/trusted process was enough to get back inside the IPC path.
Same idea here.
The PoC starts a signed Cato process:
C:\Program Files (x86)\Cato Networks\Cato Client\CatoClient.exe
Then it manually maps a DLL payload into it. The DLL connects to:
\\.\pipe\cato-VPN
From the service point of view, the connection comes from a Cato-signed executable, so the certificate check is not a problem anymore.
Protobuf, you said?
The boring part was rebuilding the Protobuf messages required to talk to the service correctly. This is where AI was helpful: not to find the bug, but to automate repetitive message-building work once the protocol fields were known.
The useful part came from the decompiled .NET client/common code. The Cato GUI ships generated Google.Protobuf classes under CatoCommon, and the service communication wrapper shows how the real client builds messages.
From ServiceCommunication.cs:
private void sendUiRegisterCommand()
{
ClientToService clientToService = CreateBasicMessage(ClientToService.Types.Commands.UiRegister);
clientToService.UiRegister = new C2sUiRegister
{
UiProcessId = _processId,
UiSessionId = _sessionId,
UserSidString = _userSidString,
AadUserUpn = _aadUserUpn
};
SendCommand(clientToService);
}
public void UploadSplitTunnelFile(string filePath, bool enabled)
{
ClientToService clientToService = CreateBasicMessage(ClientToService.Types.Commands.SplitTunnelUpload);
clientToService.UploadStFile = new C2sUploadSplitTunnelFile
{
Filepath = filePath,
Enabled = enabled
};
SendCommand(clientToService);
}
The generated protobuf classes then give the exact field layout.
From C2sUiRegister.cs:
public const int UiProcessIdFieldNumber = 1;
public const int UiSessionIdFieldNumber = 2;
public const int UserSidStringFieldNumber = 3;
From C2sUploadSplitTunnelFile.cs:
public const int EnabledFieldNumber = 1;
public const int FilepathFieldNumber = 2;
And ClientToService gives the oneof body fields and command IDs used by the PoC.
From ClientToService.cs:
public enum BodyOneofCase
{
UiRegister = 25,
UploadStFile = 37,
EnableLocalSt = 38,
}
public enum Commands
{
UiRegister = 34,
SplitTunnelUpload = 49,
LocalSplitTunnelEnable = 50,
}
So the actual work was mostly: extract the protobuf structure from the .NET code, identify the command IDs and body fields, then rebuild only those messages in the injected payload. The C payload source code is available here: CatoPipePayload.c.
The bug
After solving the pipe connection issue, the real bug is simple.
As seen before, the .stp file is built using a user SID. However, this value is not derived from the real client token. It can be manipulated by the user inside the named pipe message. The problem: this client-supplied SID is used as part of a filesystem path.
The generated split-tunnel output path looks like this:
<split-tunnel-storage>\ccst_<client supplied SID>.stp
That means the SID is treated as two things at the same time:
- identity material;
- a safe filename component.
It is attacker-controlled, and ..\\ path components are not rejected.
So instead of a real SID, the payload can register with something like this:
\\..\\..\\..\\..\\..\\..\\tmp\\foobar
The service then builds a path that still starts in the split-tunnel directory, but Windows normalizes the traversal and the final write reaches:
C:\tmp\foobar.stp
At this stage we have two related primitives:
- with a valid CCST file: arbitrary file write as SYSTEM, with a forced
.stpsuffix ; - with an invalid CCST file: delete of the generated
.stppath asSYSTEM.
Obviously, the delete is the fun one.
From delete to SYSTEM
The cleanup path is reached when the uploaded CCST file is readable but invalid. To summarize, as a low-privileged user, the flow is:
- signed CatoClient.exe process origin
- cato-VPN named pipe certificate gate passes
- UiRegister with attacker-controlled UserSidString
- UserSidString reused in ccst_
.stp - path traversal escapes the split-tunnel directory
- SplitTunnelUpload points to an invalid CCST file
- parser fails
- cleanup deletes the generated .stp path as SYSTEM
The last "problem" (which is not really a problem) is that we control the path for the delete operation, but the generated target has to end with .stp. Solution? Symlink, of course. We only need to redirect this privileged delete to something useful.
The PoC uses the well-known C:\Config.Msi Windows Installer rollback technique. The idea is documented by ZDI and was also used in previous file/folder delete LPE chains.
The Cato-specific redirection is:
C:\poc -> \RPC Control
\RPC Control\deleteme.stp -> \??\C:\Config.Msi
Then the malicious SID points the generated .stp path to:
C:\poc\deleteme.stp
When Cato cleanup deletes that path as SYSTEM, it reaches:
C:\Config.Msi
Procmon evidence showed:

After that, the Windows Installer rollback chain does the rest and gives code execution as SYSTEM.
The autonomous proof of concept
The final PoC is an autonomous wrapper:
It embeds:
- the MSI rollback payload;
- the rollback files;
- the Cato pipe payload DLL.
Expected flow:
- Prepare the Windows Installer rollback state under
C:\Config.Msi. - Create the
C:\poc\deleteme.stp -> C:\Config.Msiredirection. - Manual-map the embedded Cato pipe DLL into signed
CatoClient.exe. - Trigger split-tunnel upload cleanup with invalid CCST input.
- Wait for
C:\Config.Msito be deleted bywinvpnclient.cli.exeasSYSTEM. - Run the second MSI stage and launch the configured command.
And the result:

First LPE PoC.

SYSTEM shell after the Cato delete and MSI rollback chain.
Full video with a self-contained binary: CatoEOP.mp4
Conclusion
This one was fun because it was not a single obvious bug. The service did have a client check. The directory permissions were not broken. The cleanup delete only happens on invalid .ccst file upload. The named pipe was not open to everyone. But the chain was there:
- signed process origin
- SID path traversal over protobuf
- invalid CCST cleanup
- SYSTEM delete operation
- Symlink redirection
- SYSTEM shell
This is exactly why I like turning risk assessment into exploitation. "This software is installed everywhere and runs privileged code" is a useful warning. But a working 0day LPE demonstration is much better. It proves the risk is not only a line in a report. I also have to say a word about AI in this one. It wasn't useful to find the bug, but it saved me a lot of time, probably a few days, to build a working autonomous PoC based on previous work.
Disclosure timeline
Below we include a timeline of all the relevant events during the coordinated vulnerability disclosure process with the intent of providing transparency to the whole process and our actions.
- 2026-06-02: Quarkslab reported the vulnerability to Cato Networks.
- 2026-06-03: The vendor acknowledged our report.
- 2026-06-15: Quarkslab requested an update, the vendor replied on the same day that the vulnerability was pending remediation by the engineering team.
- 2026-08-06: Quarkslab requested a tentative timeline for remediation.
- 2026-08-06: The vendor informed that the vulnerability was already fixed internally and the rollout was expected at the end of August. Asked Quarkslab to hold off disclosure until then.
- 2026-09-16: Quarkslab asked for a status update and a clear release date too coordinate disclosure.
- 2026-09-29: Quarkslab asked the vendor if a CVE was assigned to the vulnerability.
- 2026-09-30: The vendor informed that CVE-2026-10739 was assigned to the vulnerability.
- 2026-09-30: The vendor published a Security Announcement to customers.
- 2026-10-01: This blog post is published.
References
- Cve.org - CVE-2026-10739
- Cato Networks - CVE-2026-10726 & CVE-2026-10739 that Impacts Windows Client Versions Lower than 6.12.6
- Cato Networks - Routing with the Cato Client / Split Tunnel Policy
- GitHub - CVE-2025-48799 (used as a main reference for PoC automation, thanks!)
- PoC payload source - CatoPipePayload.c
- Quarkslab - K7 Antivirus: Named pipe abuse, registry manipulation and privilege escalation
- Quarkslab - Avira: Deserialize, Delete and Escalate - The Proper Way to Use an AV
- ZDI PoC - FilesystemEoPs / FolderOrFileDeleteToSystem
- ZDI - Abusing Arbitrary File Deletes to Escalate Privilege