corCTF 2024 Writeups


I still can’t believe our first win as .;,;. was on corCTF of all CTFs! That’s actually insane.

Really really happy with how we did, and I can’t believe this is how we qualify for DiceCTF finals 😂. Anyway, here are writeups for the challenges I did. Even though one of them is forensics actually they were all reverse engineering.

forensics/schitty-challenge

author: plastic

26 solves / 216 points

We managed to capture the terminal output of a user while trying to obfuscate a shell script.

flag | file_information.c | terminal_output.txt

We get an ELF file, a short C File, and finally some terminal output.

file_information.c
#include <stdio.h>     // printf
#include <stdlib.h>    // malloc and free
#include <string.h>    // memcpy
#include <sys/stat.h>  //statf

int file_info(char \* file)
{
struct stat statf[1];
struct stat control[1];

        if (stat(file, statf) < 0)
                return -1;

        /* Turn on stable fields */
        memset(control, 0, sizeof(control));
        control->st_ino = statf->st_ino;
        control->st_dev = statf->st_dev;
        control->st_rdev = statf->st_rdev;
        control->st_uid = statf->st_uid;
        control->st_gid = statf->st_gid;
        control->st_size = statf->st_size;
        control->st_mtime = statf->st_mtime;
        control->st_ctime = statf->st_ctime;

    // Print readable file information
    printf("\nInode Number: %lu\n", control->st_ino);
        printf("Device Number: %lu\n", control->st_dev);
        printf("Device ID: %lu\n", control->st_rdev);
        printf("User ID: %u\n", control->st_uid);
        printf("Group ID: %u\n", control->st_gid);
        printf("File Size: %ld\n", control->st_size);
        printf("Last Modification Time: %ld\n", control->st_mtime);
        printf("Last Status Change Time: %ld\n\n", control->st_ctime);

        return 0;

}

int main(int argc, char \*\* argv)
{
file_info(argv[1]);
return 1;
}
terminal_output.txt
fart@fartbox:~$ uname -a
Linux fartbox 6.5.0-28-generic #29~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Thu Apr  4 14:39:20 UTC 2 x86_64 x86_64 x86_64 GNU/Linux
fart@fartbox:~$ shc -C
shc Version 4.0.3, Generic Shell Script Compiler
shc GNU GPL Version 3 Md Jahidul Hamid <jahidulhamid@yahoo.com>
shc Copying:

    This program is free software; you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation; either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program; if not, write to the Free Software
    @Neurobin, Dhaka, Bangladesh

    Report problems and questions to:http://github.com/neurobin/shc

    Md Jahidul Hamid <jahidulhamid@yahoo.com>

fart@fartbox:~$ shc -U -H -v -o flag -f flag.sh && rm flag.sh.x.c && rm flag.sh
shc shll=sh
shc [-i]=-c
shc [-x]=exec '%s' "$@"
shc [-l]=
shc opts=
shc: cc   flag.sh.x.c -o flag
shc: strip flag.sh.x
shc: chmod ug=rwx,o=rx flag.sh.x
fart@fartbox:~$ ./file_information /bin/sh

Inode Number: 42467530
Device Number: 2050
Device ID: 0
User ID: 0
Group ID: 0
File Size: 125688
Last Modification Time: 1648043363
Last Status Change Time: 1686442713

fart@fartbox:~$

The C file isn’t really useful. It’s mostly just there to show that the ./file_information command run in the terminal output prints the results of the stat function on the original /bin/sh.

On the other hand, the terminal output tells us a lot. First, it tells us that the flag binary was generated from running shc on a shell script called flag.sh. The goal of the challenge is probably to recover this shell script to get the flag.

shc is a shell script compiler. It takes a shell script, and “compiles” it into a binary. However, by “compile”, it actually means encrypting the original script and embedding it into the binary.

In this case, we have a few flags that change the behavior of shc:

  • -U tells shc to create an untraceable (no strace/ptrace) binary
  • -H tells shc to create a “hardened” binary, which only works for the default shell and supposedly makes it harder to reverse engineer.
  • -v is for verbose, and -o and -f are for output and input files respectively.

rev in disguise

If we toss the binary into a decompiler, we see a fairly interesting main function:

int32_t main(int32_t argc, char** argv, char** envp)
    sub_32eb()
    sub_366b(*argv)
    argv[1] = sub_37b8(argc, argv)
    char const* const r12

    if (argv[1] == 0)
        r12 = "<null>"
    else
        r12 = argv[1]

    char* rbx_1

    if (*__errno_location() == 0)
        rbx_1 = &data_4102
    else
        rbx_1 = strerror(errnum: *__errno_location())

    void* const rax_17

    if (*__errno_location() == 0)
        rax_17 = &data_4102
    else
        rax_17 = &data_49d8

    fprintf(stream: stderr, format: "%s%s%s: %s\n", *argv, rax_17, rbx_1, r12, argv)
    return 1

If we try running it, we fail a check in sub_32eb, the first function:

$ ./flag
Operation not permitted
Killed
int64_t sub_32eb()
    void* fsbase
    int64_t rax = *(fsbase + 0x28)
    prctl(option: 4, 0)
    prctl(option: 0x59616d61, 0xffffffff)
    int32_t rax_3 = getppid()
    int64_t s
    __builtin_memset(&s, c: 0, n: 0x100)
    sub_31cf(rax_3, &s)

    if (strcmp(&s, "bash") != 0 && strcmp(&s, "/bin/bash") != 0 && strcmp(&s, &data_4918) != 0 && strcmp(&s, "/bin/sh") != 0 && strcmp(&s, "sudo") != 0 && strcmp(&s, "/bin/sudo") != 0 && strcmp(&s, "/usr/bin/sudo") != 0 && strcmp(&s, "gksudo") != 0 && strcmp(&s, "/bin/gksudo") != 0 && strcmp(&s, "/usr/bin/gksudo") != 0 && strcmp(&s, "kdesu") != 0 && strcmp(&s, "/bin/kdesu") != 0 && strcmp(&s, "/usr/bin/kdesu") != 0)
        puts(str: "Operation not permitted")
        kill(zx.q(getpid()), 9)
        exit(status: 1)
        noreturn

    if (rax == *(fsbase + 0x28))
        return rax - *(fsbase + 0x28)

    __stack_chk_fail()
    noreturn

All we need to do is patch out the call to this function and the binary will continue.

Here’s what it does after ignoring the first function:

$ ./flag_patched
sh: 1: .����ޙC��Wjm�b˪�#��s��E۶$�Бt�: not found
sh: 2: Syntax error: word unexpected (expecting ")")

Clearly something is going wrong in the decryption here. Thankfully, we have a cool trick up our sleeve that means we don’t need to reverse anything else!

who actually wants to rev though

Yep! We can use our good ol’ friend LD_PRELOAD to intercept the library calls used by the binary. I won’t go over the technique since there’s lots of better tools to learn it elsewhere, but essentially it let’s use provide our own definition for functions like execve and stat that the binary uses.

Additionally I’ll skip over the process of enumerating each and every function since only a few are useful. Here’s the final code I ended up using:

ld_preload.c
#define _GNU_SOURCE
#include <stdio.h>     // printf
#include <sys/stat.h>  // statf
#include <dlfcn.h>

int stat(const char _**restrict** \_\_file, struct stat _**restrict** **buf) {
int (_real_stat)(const char _**restrict\_\_ **file, struct stat \***restrict\_\_ **buf) = dlsym(RTLD_NEXT, "stat");
if (strcmp(**file, "/bin/sh") == 0) {
**buf->st_ino = 42467530;
**buf->st_dev = 2050;
**buf->st_rdev = 0;
**buf->st_uid = 0;
**buf->st_gid = 0;
**buf->st_size = 125688;
**buf->st_mtime = 1648043363;
**buf->st_ctime = 1686442713;
printf("stat: %s\n", **file);
return 0;
}
return real_stat(**file, \_\_buf);
}

int execvp(const char *file, char *const argv[]) {
int (*real_execvp)(const char *file, char \*const argv[]) = dlsym(RTLD_NEXT, "execvp");
printf("execvp: %s\n", file);
for (int i = 0; argv[i] != NULL; i++) {
printf("arg %d: %s\n", i, argv[i]);
}
return real_execvp(file, argv);
}

int system(const char *command) {
int (*real_system)(const char \*command) = dlsym(RTLD_NEXT, "system");
printf("system: %s\n", command);
return real_system(command);
}

FILE* fopen(const char *filename, const char _mode) {
FILE_ (*real_fopen)(const char *filename, const char \*mode) = dlsym(RTLD_NEXT, "fopen");
printf("fopen: %s %s\n", filename, mode);
return real_fopen(filename, mode);
}

// gcc -shared -fPIC -o ld_preload.so ld_preload.c

At some point in the binary, it uses the results of the stat function on /bin/sh to help decrypt the shell script. However, since we know the outputs of this function from the terminal output, we can override this specific call to return the proper inputs to successfully decrypt the shell script.

Putting it all together, we can compile the above code to a shared object, and run the patched flag binary with LD_PRELOAD properly set, and we get the flag!

$ gcc -shared -fPIC -o ld_preload.so ld_preload.c
$ LD_PRELOAD=./ld_preload.so ./flag_patched
stat: /bin/sh
fopen: /tmp/shc_x.c w
fopen: /tmp/shc_x.c w
system: cc -Wall -fpic -shared -o /tmp/shc_x.so /tmp/shc_x.c -ldl
system: cc -Wall -fpic -shared -o /tmp/shc_x.so /tmp/shc_x.c -ldl
fopen: /tmp/cc76RMn7.s w
fopen: /tmp/ccN7B49z.s w
fopen: /tmp/shc_x.c r
fopen: /tmp/shc_x.c r
... [truncated]
system: #!/bin/sh

# corctf{shell_script_compilers_are_not_safe_rc4_is_dumb_this_challenge_sucks}
echo "corctf{not_the_flag}"

corctf{not_the_flag}

The final shell script has the flag as a comment, meaning we had to actually retrieve the whole script, but the system call sees it all anyway, so we’re good to go!

rev/cormine

Note: This is a 2-part challenge, but my solution solves both parts so I’ll just be writing as one challenge. Both challenges use the same binary.

author: clubby, strellic

8 solves / 362 points

Crusaders of Rust Gaming Division is proud to present our first totally original title: corMine! Please enjoy exclusive access to this alpha test!

Controls:

  • WASD - Movement
  • Space - Jump
  • F9 - Save level to game.cms
  • Esc - Release/grab mouse
  • Hold Esc - Quit

Remember, if you spot any bugs, no you didn’t. ./cormine load cormine1.cms

Troubleshooting: If you’re unable to run the challenge, try the below steps.

  • If using Wayland, don’t!
  • Try export WGPU_BACKEND=vulkan before the challenge

cormine | cormine1.cms | cormine2.cms

As a warning, this challenge is a Rust Bevy binary, meaning it is HUGE. I won’t pasting whole decompilations, so it might be a bit hard to find exactly where I’m talking about (I’ll give addresses though). also binja op

Saving the level

First things first, obviously the flag is somehow stored inside our given .cms files. We don’t want to look for this logic just yet, since the binary is really big, but there is a trick we can use.

Since we know F9 saves the level to game.cms, it must run the same logic as loading a save, just in reverse.

Good thing for us, searching for the string game.cms gives us pretty close to our target location.

0x1db8140 int64_t <bevy_ecs::system::function_system::FunctionSystem<Marker,F> as bevy_ecs::system::system::System>::run_unsafe::hf653820ccd9abe7c(void* arg1, void* arg2) {
    ...
    __builtin_strncpy(dest: &str, src: "game.cms", n: 9)
    uint32_t (* buf_1)[0x40] = &str
    core::ffi::c_str::CStr::from_bytes_with_nul::h4e10a7c7207d9b50()
    void*** rbx_6
    
    if (buf != 0)
        rbx_6 = anyhow::error::<impl cor...yhow::Error>::from::he4dc447bd7a8c078(&str_"file name contained an unexpected NUL byte")
    else
        std::sys::pal::unix::fs::File::open_c::h1e3e50edcc9152f4()
        struct RustStringSlice* var_2a0
        
        if (var_2a8.d != 0)
            rbx_6 = anyhow::error::<impl cor...yhow::Error>::from::he4dc447bd7a8c078(var_2a0)
        else
            int32_t fd = var_2a8:4.d
            str[0] = rsi_2.d
            uint64_t nbytes_4 = 4
            
            while (true)
                uint64_t nbytes = 0x7fffffffffffffff
                
                if (nbytes_4 u< 0x7fffffffffffffff)
                    nbytes = nbytes_4
                
                ssize_t rax_42 = write(fd, buf: buf_1, nbytes)
                ...

We can see that a file is being opened, presumably game.cms, and then later written to.

Scrolling a bit further down, we see something very intimdating: Lots of multiplication and addition by 2 constants

At first glance, this is definitely some inlined operation. Thankfully, if we extract the two constants 0x5851f42d4c957f2d and -0x5e89ab1b9041e80d, we can google their numerical values 6364136223846793005 and 11634580027462260723, and we find this result:

rust random seed_from_u64 source

That means this function must be creating some PRNG with a seed. Problem is, what’s the PRNG or the seed?

Slide to the left…

Scrolling down just a tiny bit more, we see two function calls: rand_chacha::guts::init_chacha rand_chacha::guts::refill_wide_6

We can probably guess that the we have some ChaCha based PRNG, and by doing a quick search for the rand_chacha crate and with the information of the second function call having an argument of 6, we can assume that we are seeding a ChaCha12Rng. If you’re aware of how the SALSA/ChaCha family of PRNGs work, this makes sense since the argument being 6 is the number of odd/even rounds, which means 12 rounds total, so ChaCha12Rng.

…and slide to the right

Now, we could keep going and see how the PRNG is used to help save the file, but remember, our goal is to load the file, not save it. So, let’s look at other places these functions are called.

As you can see from the amazingly labeled diagram, there’s just one other place where the function is called 4 times, and that’s where the save is being loaded. cormine_save_and_load

Going to the load calls, we can scroll up to see where it opens and first reads from the file. This function is really long and complex, so here’s a basically condensed version of the important bit:

0x1d98f60 int64_t <bevy_ecs::system::function_system::FunctionSystem<Marker,F> as bevy_ecs::system::system::System>::run_unsafe::h06b810d262677ad4(void* arg1, void* arg2) {
    uint32_t tmp_buf[0x40]
    ...
    uint64_t nbytes = 4
    uint32_t (* buf_1)[0x40] = &tmp_buf

    ssize_t rax_11 = read(fd /* our save file */, buf: buf_1, nbytes) 
    ...
    uint64_t seed = zx.q(tmp_buf[0])
    // This `seed` value eventually becomes the first value in the `seed_from_u64` inline function
}

This means the first 4 bytes of the file are actually our seed for the ChaCha PRNG! Right after this section it reads in 2 bytes. I assume this is for the size of the world, but it’s not super important and I didn’t look into it.

Next, scrolling past the inline seed_from_u64 and init_chacha, we get to this section (again, this is heavily condensed):

uint128_t rdx = cormine_shared::save::Serializer<Cursor>::read_leb128_signed::he32d2bd5e2a4a56b(fd);
int32_t X = rdx.d ^ chacha_buf[chacha_i];
chacha_i += 1;

rdx = cormine_shared::save::Serializer<Cursor>::read_leb128_signed::he32d2bd5e2a4a56b(fd);
int32_t Y = rdx.d ^ chacha_buf[chacha_i];
chacha_i += 1;

rdx = cormine_shared::save::Serializer<Cursor>::read_leb128_signed::he32d2bd5e2a4a56b(fd);
int32_t Z = rdx.d ^ chacha_buf[chacha_i];
chacha_i += 1;

char type;
read(fd, &type, 1);

type ^= chacha_buf[chacha_i].b;

struct Block block = {X, Y, Z, type}; // Obviously it doesn't look like this in the decomp but you get the idea that it gets packed into a struct

It reads in 3 values, XORs them with the PRNG output to get the position of the block, then XORs just a single byte with the PRNG output to get the type of the block.

As for how it reads in the values, here’s what the decomp of the function looks like:

Serializer<Cursor>::read_leb128_signed
uint64_t cormine_shared::save::Serializer<Cursor>::read_leb128_signed::he32d2bd5e2a4a56b(int32_t fd) {
    int32_t rcx = 0
    int64_t rdx = 0
    while (true)
        char var_41 = 0
        uint64_t nbytes_2 = 1
        char* buf = &var_41
        
        while (true)
            uint64_t nbytes = 0x7fffffffffffffff
            
            if (nbytes_2 u< 0x7fffffffffffffff)
                nbytes = nbytes_2
            
            ssize_t rax_4 = read(fd, buf, nbytes)
            struct RustStringSlice* const r12_2
            
            if (rax_4 == -1)
                int64_t r12_1 = sx.q(*__errno_location())
                
                if (r12_1 != 4)
                    r12_2 = r12_1 << 0x20 | 2
                else if (nbytes_2 == 0)
                    label_1dce768:
                    uint64_t rdi_1 = zx.q(var_41)
                    
                    if (rcx != 0x3f || rdi_1 == 0 || rdi_1.d == 0x7f)
                        uint64_t result = zx.q(rdi_1.d & 0x7f) << rcx.b
                        rdx |= result
                        rcx += 7
                        
                        if (rdi_1.b s>= 0)
                            return result
                        
                        break
                    
                    if (rdi_1.b s< 0)
                        nbytes_2 = 1
                        char* buf_1 = &var_41
                        r12_2 = &str_"failed to fill whole buffer"
                        
                        while (true)
                            uint64_t nbytes_1 = 0x7fffffffffffffff
                            
                            if (nbytes_2 u< 0x7fffffffffffffff)
                                nbytes_1 = nbytes_2
                            
                            rax_4 = read(fd, buf: buf_1, nbytes: nbytes_1)
                            
                            if (rax_4 == -1)
                                int64_t rax_7 = sx.q(*__errno_location())
                                
                                if (rax_7 != 4)
                                    r12_2 = rax_7 << 0x20 | 2
                                    goto label_1dce7ab
                            else
                                if (rax_4 == 0)
                                    goto label_1dce7ab
                                
                                if (nbytes_2 u< rax_4)
                                    goto label_1dce878
                                
                                buf_1 = &buf_1[rax_4]
                                nbytes_2 -= rax_4
                            
                            if (nbytes_2 == 0)
                                buf_1 = &var_41
                                nbytes_2 = 1
                                
                                if (var_41 s>= 0)
                                    break
                    
                    r12_2 = nullptr
                else
                    continue
            else
                if (rax_4 != 0)
                    if (nbytes_2 u< rax_4)
                        label_1dce878:
                        core::slice::index::slic...art_index_len_fail::h9494061f39b55dc2()
                        noreturn
                    
                    buf = &buf[rax_4]
                    nbytes_2 -= rax_4
                    
                    if (nbytes_2 != 0)
                        continue
                    
                    goto label_1dce768
                
                r12_2 = &str_"failed to fill whole buffer"
            
            label_1dce7ab:
            anyhow::error::<impl cor...yhow::Error>::from::h08a5f13b3936c0b5(r12_2)
            return 1
}

Here’s my reimplementation in Rust, do note that since each XOR is done with one uint32, we can return a u64 and just cast it to a u32 when needed.

fn read_serialized<R: Read>(reader: &mut R) -> u64 {
    let mut buf = [0; 1];
    let mut result = 0;
    let mut shift = 0;
    let mask = 0xffff_ffff_ffff_ffff;
    loop {
        reader.read_exact(&mut buf).unwrap();
        let byte = buf[0];
        result = result | ((byte & 0x7f) as u64) << shift;
        shift += 7;
        if byte & 0x80 == 0 {
            if byte & 0x40 != 0 {
                result |= (mask << shift) & mask;
            }
            break;
        }
    }
    result
}

Putting it all together

Now all we have to do is basically reimplement the reader and just dump the map to a file. Here’s my final parser, yes i know i suck at rust ok i just wanted to solve the challenge:

cormine_parser.rs
use byteorder::ReadBytesExt;
use byteorder::LittleEndian;
use rand_chacha::rand_core::RngCore;
use rand_chacha::rand_core::SeedableRng;
use rand_chacha::ChaCha12Rng;
use std::env;
use std::io::BufReader;
use std::io::Read;

fn read_serialized<R: Read>(reader: &mut R) -> u64 {
    let mut buf = [0; 1];
    let mut result = 0;
    let mut shift = 0;
    let mask = 0xffff_ffff_ffff_ffff;
    loop {
        reader.read_exact(&mut buf).unwrap();
        let byte = buf[0];
        result = result | ((byte & 0x7f) as u64) << shift;
        shift += 7;
        if byte & 0x80 == 0 {
            if byte & 0x40 != 0 {
                result |= (mask << shift) & mask;
            }
            break;
        }
    }
    result
}

fn main() {
    let args: Vec<String> = env::args().collect();
    if args.len() != 2 {
        eprintln!("Usage: {} <file>", args[0]);
        std::process::exit(1);
    }

    let file = std::fs::File::open(&args[1]).unwrap();
    let mut rdr = BufReader::new(file);

    let seed = rdr.read_u32::<LittleEndian>().unwrap() as u64;
    rdr.read_u16::<LittleEndian>().unwrap(); // Not sure what this is used for


    let mut rng = ChaCha12Rng::seed_from_u64(seed);
    let mut tmp = [0u8; 1];

    let mut decrypt = |dat: u64| -> u32 {
        let prng_out = rng.next_u32();
        (dat as u32) ^ prng_out
    };

    loop {
        let x = decrypt(read_serialized(&mut rdr));
        let y = decrypt(read_serialized(&mut rdr));
        let z = decrypt(read_serialized(&mut rdr));
        rdr.read_exact(&mut tmp).unwrap();
        let typ = decrypt(tmp[0] as u64) as u8;

        println!("{}, {}, {}, {}", x as i32, y as i32, z as i32, typ as i32);

        if rdr.read_exact(&mut tmp).is_err() {
            break;
        }
        rdr.seek_relative(-1).unwrap();
    }
}

Running this on the cormine1.cms file, we get the following output (download):

1, 95, 16, 0
2, 95, 16, 0
3, 95, 16, 0
4, 95, 16, 0
0, 94, 16, 0
0, 93, 16, 0
0, 92, 16, 0
1, 91, 16, 0
2, 91, 16, 0
3, 91, 16, 0
4, 91, 16, 0
7, 95, 16, 0
8, 95, 16, 0
9, 95, 16, 0
6, 94, 16, 0
...

Now, remember this isn’t the flag, its just the blocks we have. All we need to do to get the flag is to just visualize the block id 0s, and we get the flag!

import matplotlib.pyplot as plt

data = open('dump.txt').readlines()
data = [eval(x.strip()) for x in data]

data = [(x, y, z, id) for (x, y, z, id) in data if id == 0]

x = [d[0] / 2 for d in data]
y = [d[1] for d in data]

fig = plt.figure()
ax = fig.add_subplot(111)
ax.scatter(x, y, s=10)
plt.ylim(50, 150)
plt.savefig('cormine1_flag.png')

cormine1 flag

And our flag is corctf{w4llh4cks}.

For the second part, we can repeat the exact same thing, getting this output (download):

1, 95, 16, 3
1, 95, 16, 255
2, 95, 16, 3
2, 95, 16, 255
3, 95, 16, 3
3, 95, 16, 255
4, 95, 16, 3
4, 95, 16, 255
0, 94, 16, 3
0, 94, 16, 255
0, 93, 16, 3
0, 93, 16, 255
...

As you can see, the old blocks are being overwritten with an invalid block id. All we have to do is just ignore these and only plot the id 3, and we get the flag!

- data = [(x, y, z, id) for (x, y, z, id) in data if id == 0]
+ data = [(x, y, z, id) for (x, y, z, id) in data if id == 3]

cormine2 flag

And our flag is corctf{0v3rwrit3}.