Detecting task_for_pid and pid_for_task via CS_INVALID_ALLOWED. First in world public detection method pid_for_task.
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:
- test.c is the target process. It polls
csops()every 5 seconds and prints1ifCS_INVALID_ALLOWEDis set. - tfp.c calls
task_for_pid()on the target. - pft.c uses the
pid_for_task()workaround (Jonathan Levin's technique viaprocessor_set_tasks).
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
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
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
- Tested on macOS 27.0 beta 2, arm64.
CS_INVALID_ALLOWEDis defined in xnu/osfmk/kern/cs_blobs.h.- The
pid_for_taskworkaround is documented by Jonathan Levin.