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.
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
tellsshc
to create an untraceable (no strace/ptrace) binary-H
tellsshc
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
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:
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:
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:
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.
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')
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]
And our flag is corctf{0v3rwrit3}
.