← andrd3v

Detecting task_for_pid and pid_for_task via CS_INVALID_ALLOWED. First in world public detection method pid_for_task.

· by andrd3v · macOS arm64 · Repository
TL;DR: When any process obtains your Mach task port (via task_for_pid or the pid_for_task workaround), XNU sets the CS_INVALID_ALLOWED flag on your process. You can read it with csops() from userspace. Unlike TASK_EXTMOD_INFO, this catches both methods. pid_for_task detect. Userspace detection of previously granted Mach task-port access, covering both task_for_pid() and task enumeration followed by pid_for_task().

The problem

There is an existing way to detect task_for_pid calls on your process: TASK_EXTMOD_INFO (flavor 0x13 via task_info()) provides a task_for_pid_count counter. But it is blind to pid_for_task. If someone uses Jonathan Levin's technique to enumerate all task ports via processor_set_tasks and match by PID, the counter stays at zero. You never know it happened.

The solution

When a process obtains another process's Mach task port, whether through task_for_pid() or the pid_for_task() workaround, the XNU kernel sets the CS_INVALID_ALLOWED (0x20) flag on the target process's code signing flags.

This flag can be read from userspace via csops(pid, CS_OPS_STATUS). It returns the full code signing flags, and bit 0x20 tells you if anyone grabbed your task port by any method.

• • •

Detection code

The detection is a single function. Call csops with CS_OPS_STATUS (op 0) to get the code signing flags, then check for CS_INVALID_ALLOWED:

// from xnu/osfmk/kern/cs_blobs.h
#define CS_INVALID_ALLOWED  0x00000020

extern int csops(pid_t pid, unsigned int ops,
                 void *useraddr, size_t usersize);

int detect_tfp_or_pft()
{
    uint32_t cs_flags = 0;
    csops(getpid(), 0, &cs_flags, sizeof(cs_flags));

    if (cs_flags & CS_INVALID_ALLOWED)
        return 1;
    return 0;
}

That's it. Returns 1 if someone has obtained your task port, 0 if clean. Works regardless of whether task_for_pid or pid_for_task was used.

PoC

Three binaries to demonstrate:

The target (test.c)

#include <stdio.h>
#include <unistd.h>

#define CS_INVALID_ALLOWED  0x00000020

extern int csops(pid_t pid, unsigned int ops,
                 void *useraddr, size_t usersize);

int detect_tfp_or_pft()
{
    uint32_t cs_flags = 0;
    csops(getpid(), 0, &cs_flags, sizeof(cs_flags));
    printf("cs_flags=0x%x\n", cs_flags);

    if (cs_flags & CS_INVALID_ALLOWED)
        return 1;
    return 0;
}

int main()
{
    printf("pid %d\n", getpid());
    while (1)
    {
        int a = detect_tfp_or_pft();
        printf("%d\n", a);
        sleep(5);
    }
    return 0;
}

task_for_pid caller (tfp.c)

#include <stdio.h>
#include <stdlib.h>
#include <mach/mach.h>

int main(int argc, char **argv)
{
    pid_t pid = atoi(argv[1]);
    task_t task;
    kern_return_t kr = task_for_pid(mach_task_self(), pid, &task);
    if (kr != KERN_SUCCESS) return -1;
    printf("tfp %d", task);
    return 0;
}

pid_for_task workaround (pft.c)

#include <stdio.h>
#include <stdlib.h>
#include <mach/mach.h>

// from https://newosxbook.com/articles/PST2.html
mach_port_t task_for_pid_workaround(int Pid)
{
    host_t myhost = mach_host_self();
    mach_port_t psDefault;
    mach_port_t psDefault_control;

    task_array_t tasks;
    mach_msg_type_number_t numTasks;

    kern_return_t kr;

    kr = processor_set_default(myhost, &psDefault);
    kr = host_processor_set_priv(myhost, psDefault,
                                &psDefault_control);
    if (kr != KERN_SUCCESS) {
        fprintf(stderr,
                "host_processor_set_priv failed with %x\n", kr);
        return MACH_PORT_NULL;
    }

    kr = processor_set_tasks(psDefault_control,
                            &tasks, &numTasks);
    if (kr != KERN_SUCCESS) {
        fprintf(stderr,
                "processor_set_tasks failed with %x\n", kr);
        return MACH_PORT_NULL;
    }

    for (int i = 0; i < numTasks; i++) {
        int pid;
        pid_for_task(tasks[i], &pid);
        printf("TASK %d PID :%d\n", i, pid);
        if (pid == Pid) return tasks[i];
    }

    return MACH_PORT_NULL;
}

int main(int argc, char **argv)
{
    int pid = atoi(argv[1]);
    mach_port_t task = task_for_pid_workaround(pid);
    if (task == MACH_PORT_NULL) return -1;
    printf("pft %d\n", task);
    return 0;
}

Build

clang -o test test.c
clang -o tfp tfp.c
clang -o pft pft.c

Test 1: task_for_pid

$ ./test pid 10392 cs_flags=0x22020201 0 <-- clean cs_flags=0x22020201 0 $ sudo ./tfp 10392 <-- task_for_pid called tfp 2563 cs_flags=0x22020221 1 <-- DETECTED

After task_for_pid is called, the CS_INVALID_ALLOWED bit flips from 0 to 1 in the target's code signing flags. The flag value changes from 0x22020201 to 0x22020221.

Test 2: pid_for_task workaround

$ ./test pid 10503 cs_flags=0x22020201 0 <-- clean $ sudo ./pft 10503 <-- pid_for_task workaround So far so good TASK 0 PID :1673 TASK 1 PID :1706 TASK 2 PID :37262 ... TASK 12 PID :10503 pft 4099 cs_flags=0x22020221 1 <-- DETECTED

Same result. Even though pid_for_task bypasses the task_for_pid_count counter in TASK_EXTMOD_INFO, the kernel still sets CS_INVALID_ALLOWED on the target. The detection works for both methods.

Why this matters

If you are building anti cheat, you probably already check TASK_EXTMOD_INFO. But that only catches task_for_pid. Anyone using the pid_for_task workaround walks right past it undetected.

Notes

#task_for_pid #pid_for_task #detect-task_for_pid #detect-pid_for_task #task_for_pid-detection #pid_for_task-detection #CS_INVALID_ALLOWED #csops #macOS #XNU #anti-debug #anti-tamper #anti-cheat #anti-debug-macOS #mach-task-port #mach-task-port-detection #code-signing #code-signing-flags #process-injection-detection #detect-process-injection #TASK_EXTMOD_INFO #task_for_pid_count #processor_set_tasks #macOS-security #arm64 #reverse-engineering #csops-CS_OPS_STATUS #detect-debugger-macOS #macOS-anti-cheat
andrd3v · cs_blobs.h · pid_for_task (J. Levin)