Logo
Overview

TAMUctf 2025 Writeups

Archan6el Archan6el
April 1, 2025
37 min read
index

Backdoored

This one was a fun Minecraft related rev challenge.

We are told that the user’s Spigot Minecraft server files have been encrypted. Extracting the archive .tar.gz file they give us, let’s take a look at the server

image

So immediately we notice a RANSOM_NOTE.txt, meaning that the server was likely hit with some kind of ransomware. Taking a look at world, we can see that all the region files and the level.dat file have been encrypted, as seen by the .enc extension.

image

Alright, let’s try to find what caused this.

In the plugins directory, we can find notsuspiciousplugin-0.9.0.jar

image

We can start reversing and taking a look at this using jd-gui.

We see a lot of .class files here, but the Encrypt and Decrypt class files are particularly interesting

image

In both the Encrypt and Decrypt files though, we can see that the function eventually calls a nativeLib function

Encrypt: image

Decrypt: image

Looking at the NativeLib class, it seems to be using functions from notsuspicious.so image

Let’s take a look at that .so file.

We can run unzip on this .jar file to get the notsuspicious.so file on its own. I extracted to a directory I call extracted-files image

Let’s pop this into Ghidra

After Ghidra does its analysis, we can see a lot of the same functions we saw in the .jar file. This is likely the underlying code for them. We can also see some functions that based on the names, seem to be used to specifically encrypt the Minecraft region and level files, encrypt_level_dat and encrypt_region_files.

image

Let’s look at the decrypt function first and try to rename some variables

int decrypt(EVP_PKEY_CTX *ctx,uchar *out,size_t *outlen,uchar *in,size_t inlen)
{
uint uVar1;
uint uVar2;
uint uVar3;
int iVar4;
int local_40;
uint local_3c;
uint local_38;
uint local_30;
iVar4 = (int)out;
if (((((DAT_00105151 != '\0') && (*ctx == ctx[8])) && (ctx[8] == ctx[9])) &&
(((((uint)(byte)ctx[1] + (uint)(byte)*ctx == 0xdf &&
((uint)(byte)ctx[2] + (uint)(byte)*ctx == 0xce)) &&
(((uint)(byte)ctx[3] + (uint)(byte)*ctx == 0xd1 &&
(((uint)(byte)ctx[4] + (uint)(byte)*ctx == 0xd0 &&
((uint)(byte)ctx[5] + (uint)(byte)*ctx == 0xd2)))))) &&
((uint)(byte)ctx[6] + (uint)(byte)*ctx == 0xda)))) &&
(((((uint)(byte)ctx[7] + (uint)(byte)*ctx == 0xd3 &&
((uint)(byte)ctx[8] + (uint)(byte)*ctx == 0xde)) &&
((uint)(byte)ctx[9] + (uint)(byte)*ctx == 0xde)) &&
(((uint)(byte)ctx[10] + (uint)(byte)*ctx == 0xe1 &&
((uint)(byte)ctx[0xb] + (uint)(byte)*ctx == 0x90)))))) {
DAT_00105152 = 1;
FUN_00101a5d();
}
if (iVar4 < 0) {
iVar4 = iVar4 + 3;
}
uVar1 = iVar4 >> 2;
uVar3 = uVar1;
if (uVar1 != 0) {
local_40 = (int)(0x34 / (ulong)uVar1) + 6;
local_3c = local_40 * -0x61c88647;
local_38 = *(uint *)ctx;
do {
uVar2 = local_3c >> 2 & 3;
uVar3 = uVar1;
while (local_30 = uVar3 - 1, local_30 != 0) {
uVar3 = *(uint *)(ctx + (ulong)(uVar3 - 2) * 4);
*(uint *)(ctx + (ulong)local_30 * 4) =
*(int *)(ctx + (ulong)local_30 * 4) -
((uVar3 >> 5 ^ local_38 << 2) + (uVar3 << 4 ^ local_38 >> 3) ^
(*(uint *)((long)outlen + (ulong)(local_30 & 3 ^ uVar2) * 4) ^ uVar3) +
(local_3c ^ local_38));
local_38 = *(uint *)(ctx + (ulong)local_30 * 4);
uVar3 = local_30;
}
uVar3 = *(uint *)(ctx + (ulong)(uVar1 - 1) * 4);
*(uint *)ctx = *(int *)ctx -
((*(uint *)((long)outlen + (ulong)uVar2 * 4) ^ uVar3) + (local_3c ^ local_38) ^
(uVar3 >> 5 ^ local_38 << 2) + (uVar3 << 4 ^ local_38 >> 3));
local_38 = *(uint *)ctx;
local_3c = local_3c + 0x61c88647;
local_40 = local_40 + -1;
uVar3 = CONCAT31((int3)(local_38 >> 8),local_40 != 0);
} while (local_40 != 0);
}
return uVar3;
}

Right at the beginning of this function we find an interesting check:

image

It seems to be checking if the contents of ctx, which from the rest of the function we can pretty confidently deduce to be the ciphertext that we want to decrypt, is equal to a certain value. It also is checking if some value, DAT_00105151 is true. I’ll rename DAT_00105151 to checker.

If the ciphertext equals the desired value and checker is true, it calls a function FUN_00101a5d. Looking at that function, we can see that it’s responsible for encrypting all the important files in the Minecraft server using the encrypt_level_dat and encrypt_region_files functions that we saw from before. I’ll rename the function to encrypt_all_files

void encrypt_all_files(void)
{
char cVar1;
int iVar2;
char *pcVar3;
size_t sVar4;
FILE *__s;
long in_FS_OFFSET;
char local_318 [256];
char local_218 [520];
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
if (((checker == '\x01') && (DAT_00105152 == '\x01')) &&
(pcVar3 = getcwd(local_318,0x100), pcVar3 != (char *)0x0)) {
snprintf(local_218,0x200,"%s/world/level.dat",local_318);
iVar2 = access(local_218,0);
if ((iVar2 == 0) && (cVar1 = encrypt_level_dat(local_218), cVar1 == '\x01')) {
snprintf(local_218,0x200,"%s/world/region/",local_318);
cVar1 = encrypt_region_files(local_218);
if (cVar1 == '\x01') {
snprintf(local_218,0x200,"%s/world_nether/level.dat",local_318);
iVar2 = access(local_218,0);
if ((iVar2 == 0) && (cVar1 = encrypt_level_dat(local_218), cVar1 == '\x01')) {
snprintf(local_218,0x200,"%s/world_nether/DIM-1/region/",local_318);
cVar1 = encrypt_region_files(local_218);
if (cVar1 == '\x01') {
snprintf(local_218,0x200,"%s/world_the_end/level.dat",local_318);
iVar2 = access(local_218,0);
if ((iVar2 == 0) && (cVar1 = encrypt_level_dat(local_218), cVar1 == '\x01')) {
snprintf(local_218,0x200,"%s/world_the_end/DIM1/region/",local_318);
cVar1 = encrypt_region_files(local_218);
if (cVar1 == '\x01') {
sVar4 = strlen(local_318);
pcVar3 = (char *)malloc(sVar4 + 0x18);
sVar4 = strlen(local_318);
snprintf(pcVar3,sVar4 + 0x17,"%s/RANSOM_NOTE.txt",local_318);
__s = fopen(pcVar3,"w");
if (__s != (FILE *)0x0) {
fwrite("Your world has been encrypted. To get it back, please do the following:\n"
,1,0x48,__s);
fwrite("1. Send 500,000 ETH to the following address: 0x1234567890abcdef\n",1,0x41
,__s);
fwrite("2. Do 5,000 push-ups on camera and upload it to YouTube\n",1,0x38,__s);
fwrite("3. Wait for further instructions\n",1,0x21,__s);
fwrite("4. Keep waiting for further instructions\n",1,0x29,__s);
fwrite("If you do not comply within 48 hours, your world will be deleted.\n",1,
0x42,__s);
}
}
}
}
}
}
}
}
if (local_10 == *(long *)(in_FS_OFFSET + 0x28)) {
return;
}
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}

Based on this, it seems that the ciphertext equaling whatever desired value the decrypt function is looking for and whatever sets checker to true are the conditions that activate the ransomware and encrypt everything. We just need to find what exactly those conditions are.

Before we do that, I first take a look at what exactly the decrypt function is doing. It seems to go through rounds, uses some kind of constant, and does a lot of xor and bitwise operations. After some research, the ransomware seems to be using a modified version of the TEA/XTEA block cipher. Due to this new info, I retype and rename some variables for easier reading.

int decrypt(uint *ctx,uchar *out,size_t *outlen,uchar *in,size_t inlen)
{
uint uVar1;
uint uVar2;
uint uVar3;
int iVar4;
int local_40;
uint DELTA;
uint ciphertext;
uint local_30;
iVar4 = (int)out;
if (((((checker != '\0') && (*(char *)ctx == *(char *)(ctx + 2))) &&
(*(char *)(ctx + 2) == *(char *)((long)ctx + 9))) &&
(((((uint)*(byte *)((long)ctx + 1) + (uint)*(byte *)ctx == 0xdf &&
((uint)*(byte *)((long)ctx + 2) + (uint)*(byte *)ctx == 0xce)) &&
(((uint)*(byte *)((long)ctx + 3) + (uint)*(byte *)ctx == 0xd1 &&
(((uint)*(byte *)(ctx + 1) + (uint)*(byte *)ctx == 0xd0 &&
((uint)*(byte *)((long)ctx + 5) + (uint)*(byte *)ctx == 0xd2)))))) &&
((uint)*(byte *)((long)ctx + 6) + (uint)*(byte *)ctx == 0xda)))) &&
(((((uint)*(byte *)((long)ctx + 7) + (uint)*(byte *)ctx == 0xd3 &&
((uint)*(byte *)(ctx + 2) + (uint)*(byte *)ctx == 0xde)) &&
((uint)*(byte *)((long)ctx + 9) + (uint)*(byte *)ctx == 0xde)) &&
(((uint)*(byte *)((long)ctx + 10) + (uint)*(byte *)ctx == 0xe1 &&
((uint)*(byte *)((long)ctx + 0xb) + (uint)*(byte *)ctx == 0x90)))))) {
DAT_00105152 = 1;
FUN_00101a5d();
}
if (iVar4 < 0) {
iVar4 = iVar4 + 3;
}
uVar1 = iVar4 >> 2;
uVar3 = uVar1;
if (uVar1 != 0) {
local_40 = (int)(0x34 / (ulong)uVar1) + 6;
DELTA = local_40 * -0x61c88647;
ciphertext = *ctx;
do {
uVar2 = DELTA >> 2 & 3;
uVar3 = uVar1;
while (local_30 = uVar3 - 1, local_30 != 0) {
uVar3 = ctx[uVar3 - 2];
ctx[local_30] =
ctx[local_30] -
((uVar3 >> 5 ^ ciphertext << 2) + (uVar3 << 4 ^ ciphertext >> 3) ^
(*(uint *)((long)outlen + (ulong)(local_30 & 3 ^ uVar2) * 4) ^ uVar3) +
(DELTA ^ ciphertext));
ciphertext = ctx[local_30];
uVar3 = local_30;
}
uVar3 = ctx[uVar1 - 1];
*ctx = *ctx - ((*(uint *)((long)outlen + (ulong)uVar2 * 4) ^ uVar3) + (DELTA ^ ciphertext) ^
(uVar3 >> 5 ^ ciphertext << 2) + (uVar3 << 4 ^ ciphertext >> 3));
ciphertext = *ctx;
DELTA = DELTA + 0x61c88647;
local_40 = local_40 + -1;
uVar3 = CONCAT31((int3)(ciphertext >> 8),local_40 != 0);
} while (local_40 != 0);
}
return uVar3;
}

Well I mean, we do have the notsuspicious.so file to our disposal, so we could just use it to decrypt all the encrypted files using this decrypt function right? While that is true, we don’t know one important thing. TEA/XTEA implementations usually require a 16-byte key, and it seems like this ransomware requires it too, as evidenced by these lines back in the .jar file image

So what even is that key? Well, it might be tied to those 2 conditions we found earlier that activates the encrypt_all_files() function, and as we keep analyzing, we’ll find that it’s specifically tied to the checker variable.

While looking at the other functions we can find in the .so, I find something pretty odd in the base64_encode function.

void base64_encode(byte *param_1,int param_2,char *param_3)
{
byte bVar1;
byte bVar2;
byte bVar3;
long lVar4;
byte *pbVar5;
ulong *__src;
long in_FS_OFFSET;
char *local_70;
byte *local_60;
lVar4 = *(long *)(in_FS_OFFSET + 0x28);
pbVar5 = param_1 + param_2;
local_70 = param_3;
for (local_60 = param_1; 2 < (long)pbVar5 - (long)local_60; local_60 = local_60 + 3) {
bVar2 = *local_60;
bVar3 = local_60[1];
bVar1 = local_60[2];
*local_70 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
[(int)(uint)(bVar2 >> 2)];
local_70[1] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
[(int)((bVar2 & 3) << 4 | (uint)(bVar3 >> 4))];
local_70[2] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
[(int)((bVar3 & 0xf) << 2 | (uint)(bVar1 >> 6))];
local_70[3] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
[(int)(bVar1 & 0x3f)];
local_70 = local_70 + 4;
}
if (pbVar5 != local_60) {
bVar2 = *local_60;
*local_70 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
[(int)(uint)(bVar2 >> 2)];
if ((long)pbVar5 - (long)local_60 == 1) {
local_70[1] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
[(int)((bVar2 & 3) << 4)];
local_70[2] = '=';
}
else {
bVar3 = local_60[1];
local_70[1] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
[(int)((bVar2 & 3) << 4 | (int)((char)bVar3 >> 4))];
local_70[2] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
[(int)(((int)(char)bVar3 & 0xfU) << 2)];
}
local_70[3] = '=';
local_70 = local_70 + 4;
}
*local_70 = '\0';
if (param_2 == 8) {
__src = (ulong *)(local_60 + -6);
if ((*__src ^ *(ulong *)(local_70 + -0xc)) == 0x51e02052f115e3b) {
checker = 1;
strcpy(&DAT_00105140,(char *)__src);
strcat(&DAT_00105140,(char *)__src);
strcpy(local_70 + -0xc,&DAT_00105140);
}
else {
checker = 0;
}
}
else {
checker = 0;
}
if (lVar4 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}

There’s some sort of conditional check here that sets checker to true: image

Analyzing the rest of the function, it seems that what this check is doing is checking to see if the inputted text to the function is 8 bytes long. If it is, it base64 encodes the inputted text, and XOR’s the input and the first 8 bytes of its base64 encoded version together. If it equals 0x51e02052f115e3b, checker is set to true.

We can write a pretty simple Python script to actually brute force byte by byte what this inputted text needs to be:

# Basically brute forcing the logic we found in the b64encode function in Ghidra
# if ((*__src ^ *(ulong *)(local_70 + -0xc)) == 0x51e02052f115e3b)
import base64
# Target value (0x51e02052f115e3b)
target = 0x51e02052f115e3b
# Convert the target value into a list of bytes
target_bytes = list(target.to_bytes(8, byteorder='little'))
# Start with empty key
key = ''
# Basically loop through and incrementally crack the key
for i in range(8):
for key_val in range(256):
encoded = base64.b64encode(key.encode() + bytes([key_val]))
# XOR key_val with the first byte of the base64-encoded string
if (key_val ^ encoded[i]) == target_bytes[i]:
key += chr(key_val) # Append the character corresponding to key_val
print(f"Key so far: {key}")
break # Break out of the loop once a match is found for this byte

Running this gets us:

image

So the inputted text needs to be b4Ckd0Or. As you can tell by my key references in the output, I started to realize that this might actually be the key for the modified TEA/XTEA algorithm in the decrypt function that we’re looking for. This is 8 bytes though, and the key needs to be 16 bytes. Perhaps the key is b4Ckd0Orb4Ckd0Or? Only one way to find out.

We can write a C program to call the decrypt function from notsuspicious.so, but what even are the parameters we need to pass in?

I mean of course we have some hint of this in Ghidra: image

But we can use gdb to be 100% sure.

notsuspiciousplugin-0.9.0.jar is a plugin used by the Spigot server, so we can run the Spigot server normally and then set breakpoints.

We can run the server with java -Xms1G -Xmx2G -jar spigot-1.21.4.jar

image

Now we need to attach gdb to this process. Run ps aux | grep spigot to find the process ID of the Spigot server

image

So we find that the ID is 810. We can attach gdb to this process with gdb -p 810

Now that gdb is attached, we can set the breakpoint. We’ll set it at decrypt, since that’s the function that we want to find the parameters for

image

Now how do we even go about calling this function?

Looking back at notsuspiciousplugin-0.9.0.jar, in plugin.yml, we can find how to call decrypt

image

So the syntax to call decrypt on the Minecraft server is dec <ciphertext> <key>

Also looking at the DecryptCommand function in the .jar, we see that our ciphertext has to be in hex

image

For our ciphertext, I’ll use 74657374, which is test in hex. For the key I’ll use the key, testkey

image

Alright nice, we hit our breakpoint.

Let’s take a look at our registers to see what the parameters are

image

For x86-64 architectures, rdi is the first parameter, rsi is the second parameter, rdx is the third parameter, rcx is the fourth parameter, and so on and so forth. Well let’s look at what we have here

image

It seems that the ciphertext, “test”, was the first parameter (rdi), the length of the ciphertext, which is 4, was the second parameter (rsi), and the key is the third parameter (rdx). The rest seem to be repeats or extraneous data.

From this, we can pretty confidently say that decrypt takes 3 parameters, so to call decrypt, we would do decrypt(ciphertext, ciphertext_length, key).

We can now write our C program to decrypt all the encrypted files!

Essentially what we need to do is to “import” the decrypt function from notsuspicious.so and call it on all the encrypted files.

If we look at the decrypt function’s logic, we can see that the passed in ciphertext, or ctx gets modified / decrypted in place

image

So we pass in the ciphertext to decrypt, and then we can write the “ciphertext”, which is now decrypted, to another file.

Let’s write that solve program! I’ll allow it to take the key as input since we’re not entirely sure if b4Ckd0Orb4Ckd0Or is the key. Our program will go through all subdirectories finding any files that end with .enc and attempt to decrypt them.

// C program to basically import the decrypt function we found in Ghidra from notsuspicious.so and call it on the files we want to decrypt. decrypt modifies "ciphertext" variable in place, so once we call decrypt, we can just write "ciphertext" to the new files
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
#include <dlfcn.h>
typedef int (*decrypt_func)(unsigned char *ciphertext, size_t ciphertext_size, const char *key);
// Function to check if a file has a ".enc" extension
int has_enc_extension(const char *filename) {
size_t len = strlen(filename);
return len > 4 && strcmp(filename + len - 4, ".enc") == 0;
}
// Function to decrypt files recursively in a directory
void decrypt_files_in_dir(const char *dir_path, const char *key, decrypt_func decrypt) {
DIR *dir = opendir(dir_path);
struct dirent *entry;
if (dir == NULL) {
perror("Failed to open directory");
return;
}
while ((entry = readdir(dir)) != NULL) {
char full_path[1024];
snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, entry->d_name);
// Skip "." and ".." entries
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) {
continue;
}
struct stat statbuf;
if (stat(full_path, &statbuf) == -1) {
perror("Failed to stat file");
continue;
}
// If it's a directory, recurse into it
if (S_ISDIR(statbuf.st_mode)) {
decrypt_files_in_dir(full_path, key, decrypt);
}
// If it's a file and ends with ".enc", decrypt it
else if (S_ISREG(statbuf.st_mode) && has_enc_extension(entry->d_name)) {
printf("Decrypting: %s\n", full_path);
// Read the ciphertext
FILE *file = fopen(full_path, "rb");
if (!file) {
perror("Failed to open file");
continue;
}
fseek(file, 0, SEEK_END);
size_t file_size = ftell(file);
fseek(file, 0, SEEK_SET);
unsigned char *ciphertext = malloc(file_size);
if (!ciphertext) {
perror("Memory allocation failed");
fclose(file);
continue;
}
fread(ciphertext, 1, file_size, file);
fclose(file);
// Decrypt the file using the loaded function
// We know what parameters are passed in due to GDB (attached to spigot.jar process) and breakpoint at the decrypt function. We saw that RDI (first param) was the ciphertext, RSI (second param) was the length, and RDX (third param) was the key
int result = decrypt(ciphertext, file_size, key);
if (result == 0) {
printf("Successfully decrypted: %s\n", full_path);
// Generate a new file name for the decrypted content (e.g., remove ".enc" only)
char decrypted_file[1024];
strncpy(decrypted_file, full_path, sizeof(decrypted_file));
decrypted_file[sizeof(decrypted_file) - 1] = '\0';
size_t len = strlen(decrypted_file);
if (len >= 4 && strcmp(decrypted_file + len - 4, ".enc") == 0) {
decrypted_file[len - 4] = '\0'; // Just strip ".enc", don't add anything
}
// Open the new file for writing (in binary mode)
FILE *dec_file = fopen(decrypted_file, "wb");
if (!dec_file) {
perror("Failed to open decrypted file for writing");
free(ciphertext);
continue;
}
// Write the decrypted data to the new file (assuming it was done in-place)
fwrite(ciphertext, 1, file_size, dec_file);
fclose(dec_file);
printf("Decrypted file written to: %s\n", decrypted_file);
}
// Really lazy way to just decrypt anyway. result not being 0 doesn't mean that the decryption failed
else {
printf("Successfully decrypted: %s\n", full_path);
// Generate a new file name for the decrypted content (e.g., remove ".enc" only)
char decrypted_file[1024];
strncpy(decrypted_file, full_path, sizeof(decrypted_file));
decrypted_file[sizeof(decrypted_file) - 1] = '\0';
size_t len = strlen(decrypted_file);
if (len >= 4 && strcmp(decrypted_file + len - 4, ".enc") == 0) {
decrypted_file[len - 4] = '\0'; // Just strip ".enc", don't add anything
}
// Open the new file for writing (in binary mode)
FILE *dec_file = fopen(decrypted_file, "wb");
if (!dec_file) {
perror("Failed to open decrypted file for writing");
free(ciphertext);
continue;
}
// Write the decrypted data to the new file (assuming it was done in-place)
fwrite(ciphertext, 1, file_size, dec_file);
fclose(dec_file);
printf("Decrypted file written to: %s\n", decrypted_file);
}
free(ciphertext);
}
}
closedir(dir);
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "Usage: %s <key>\n", argv[0]);
return 1;
}
const char *key = argv[1];
// Load the shared object at runtime
void *handle = dlopen("./notsuspicious.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "Failed to load shared library: %s\n", dlerror());
return 1;
}
// Get the decrypt function from the shared object
decrypt_func decrypt = (decrypt_func)dlsym(handle, "decrypt");
if (!decrypt) {
fprintf(stderr, "Failed to find decrypt function: %s\n", dlerror());
dlclose(handle);
return 1;
}
// Start decrypting from the current directory
decrypt_files_in_dir(".", key, decrypt);
// Close the shared object when done
dlclose(handle);
return 0;
}

We can also do this in Python which is way simpler.

import os
import ctypes
# Load the shared library
lib = ctypes.CDLL("./notsuspicious.so")
# Define the function signature
# int decrypt(unsigned char *ciphertext, size_t ciphertext_size, const char *key)
lib.decrypt.argtypes = [ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t, ctypes.c_char_p]
lib.decrypt.restype = ctypes.c_int
# Check for ".enc" files
def has_enc_extension(filename):
return filename.endswith(".enc")
# Recursively decrypt files in a directory
def decrypt_files_in_dir(dir_path, key):
for entry in os.listdir(dir_path):
full_path = os.path.join(dir_path, entry)
if os.path.isdir(full_path):
decrypt_files_in_dir(full_path, key)
elif os.path.isfile(full_path) and has_enc_extension(entry):
print(f"Decrypting: {full_path}")
# Read file contents
with open(full_path, "rb") as f:
data = bytearray(f.read())
# Convert to ctypes array
buf = (ctypes.c_ubyte * len(data)).from_buffer(data)
# Call the decrypt function
# We know what parameters are passed in due to GDB (attached to spigot.jar process) and breakpoint at the decrypt function. We saw that RDI (first param) was the ciphertext, RSI (second param) was the length, and RDX (third param) was the key
result = lib.decrypt(buf, len(data), key.encode())
# Write the decrypted content to a new file (strip ".enc")
decrypted_file = full_path[:-4] # remove ".enc"
with open(decrypted_file, "wb") as f:
f.write(data)
print(f"Successfully decrypted: {full_path}")
print(f"Decrypted file written to: {decrypted_file}")
if __name__ == "__main__":
import sys
if len(sys.argv) != 2:
# Key is b4Ckd0Orb4Ckd0Or
print(f"Usage: {sys.argv[0]} <key>")
sys.exit(1)
key = sys.argv[1]
decrypt_files_in_dir(".", key)

We run it with ./backdoor_solve b4Ckd0Orb4Ckd0Or or python3 backdoor_solve.py b4Ckd0Orb4Ckd0Or. Let’s hope that key is correct.

image

It seems to have worked?

It appears we have decrypted the files

image

Now we just have to find the flag. There’s many ways to do this. You could use the Python Anvil library, or use NPTExplorer. Or, if you have Minecraft, you can just pop the world into minecraft.

I just copy and pasted the world directory into my Windows Minecraft saves directory

image

Now, we can load up Minecraft Java edition and look at the world. Where exactly is the flag though?

Well, looking through some of the data, we can find the usercache that shows some player info on this challenge’s creator, Flocto

image

So we know his user ID. That means we can go to the world/playerdata directory and find where Flocto is in the game world. We can view this using NPTExplorer.

image

So now we know where he is! Let’s go there in the game world. It’s a Survival world, so I activated cheats by opening to LAN and just teleported to -1000, 114, -1000, or at least as close as I could get. There’s a sand tower that we have to get to the top of

image

Once there, we get our flag!

image

gigem{i_also_wanted_to_play_hypixel_too_thanks} is the flag, and with that, we have finished this challenge!

Brainrot

This challenge gives us two .gyat files, which are essentially just Python files but the syntax is replaced with brainrot terms

lock in hashlib glaze sha256
skibidi Brain:
bop __init__(unc, neurons):
unc.neurons = neurons
unc.thought_size = 10
bop brainstem(unc):
its giving sha256(",".join(str(x) mewing x diddy sum(unc.neurons, [])).encode()).hexdigest()
bop rot(unc, data):
mewing i diddy huzz(len(data)):
unc.neurons[(3 * i rizz 7) % unc.thought_size][(9 * i rizz 3) % unc.thought_size] ^= data[i]
bop think(unc, data):
thought = [0] * unc.thought_size
mewing i diddy huzz(unc.thought_size):
thought[i] = sum(unc.neurons[i][j] * data[j] mewing j diddy huzz(unc.thought_size))
unc.neurons[:-1] = unc.neurons[1:]
unc.neurons[-1] = thought
its giving thought
lock in brain glaze Brain
healthy_brain = [[71, 101, 18, 37, 41, 69, 80, 28, 23, 48], [35, 32, 44, 24, 27, 20, 34, 58, 24, 9], [73, 29, 37, 94, 27, 58, 104, 65, 116, 44], [26, 83, 77, 116, 9, 96, 111, 118, 52, 62], [100, 15, 119, 53, 59, 34, 38, 68, 104, 110], [51, 1, 54, 62, 56, 120, 4, 80, 60, 120], [125, 92, 95, 98, 97, 110, 93, 33, 128, 93], [70, 23, 123, 40, 75, 23, 104, 73, 52, 6], [14, 11, 99, 16, 124, 52, 14, 73, 47, 66], [128, 11, 49, 111, 64, 108, 14, 66, 128, 101]]
brainrot = b"gnilretskdi ,coffee ,ymotobol ,amenic etulosba ,oihO ni ylno ,oihO ,pac eht pots ,pac ,yadot yarp uoy did ,pu lio ,eohs ym elkcub 2 1 ,sucric latigid ,zzir tanec iaK ,tac frumS ,yzzilg ,ekahs melraH ,tanec iaK ,raebzaf ydderF ,gnixamnoog ,hoesac ,relzzir eht rof ttayg ruoy tuo gnikcits ,reppay ,gnippay ,pay ,gniggom ,gom,ttalcobmob ,gnillihc gnib ,deepswohsi ,tor niarb ,oitar + L ,ozob L ,L ,oitar ,ie ie iE ,suoived ,emem seimmug revas efil dna seceip s'eseeR ,io io io ,ytrap zzir koTkiT ,teggun ,su gnoma ,retsopmi ,yssus ,suS ,elgnid eladnuaQ ,gnos metsys ym ni atnaF ,kcil suoived ,syddid ta sthgin 5 ,hsinapS ro hsilgnE .gnos teksirb ,agnizab ,bruc eht etib ,orb lil ,dulb ,ni gnihcram og stnias eht nehw ho ,neerb fo seert ees I ,sinneD ekud ,biks no ,ennud yvvil ,knorg ybab ,rehtorb pu s'tahw ,gab eht ni seirf eht tuP ,edaf repat wol ,yddid ,yddirg ,ahpla ,gnixxamskool ,gninoog ,noog ,egde ,gnigde ,raeb evif ydderf ,ekahs ecamirg ,ynnacnu ,arua ,daeh daerd tnalahcnon ,ekard ,gnixat munaF ,xat munaf ,zzir idibikS ,yug llihc ,eiddab ,kooc reh/mih tel ,gnikooc ,kooc ,nissub ,oihO ,amgis eht tahw ,amgis ,idibikS no ,relzzir ,gnizzir ,zzir ,wem ,gniwem ,ttayg ,teliot idibikS ,idibikS"[::-1]
brain = Brain(healthy_brain)
brain.rot(brainrot)
flag = input("> ").encode()
chat is this real not len(flag) twin 40:
yap("i'll be nice and tell you my thoughts have to be exactly 40 characters long")
exit()
required_thoughts = [
[59477, 41138, 59835, 73146, 77483, 59302, 102788, 67692, 62102, 85259],
[40039, 59831, 72802, 77436, 57296, 101868, 69319, 59980, 84518, 73579466],
[59783, 73251, 76964, 58066, 101937, 68220, 59723, 85312, 73537261, 7793081533],
[71678, 77955, 59011, 102453, 66381, 60215, 86367, 74176247, 9263142620, 982652150581],
]
failed_to_think = Cooked
mewing i diddy huzz(0, len(flag), 10):
thought = brain.think(flag[i:i rizz 10])
chat is this real thought != required_thoughts[i//10]:
failed_to_think = Aura
chat is this real failed_to_think or brain.brainstem() != "4fe4bdc54342d22189d129d291d4fa23da12f22a45bca01e75a1f0e57588bf16":
yap("ermm... you might not be a s""igma...")
only in ohio:
yap("holy s""kibidi you popped off... go submit the flag")

After analyzing for a while, we realize that all this boils down to is just 4 different systems of equations each with 10 variables that we need to solve for. It’s essentially just linear algebra

required_thoughts contains the 4 sets of constant vectors for each of the 4 systems of equations. In brain.gyat, rot() initalizes the coefficient matrix, and as it performs calculations using think(), it changes said coefficient matrix

I edit the programs to be valid Python files by fixing the keywords, and create my own function in brain.gyat (now brain.py after editing it) called reverse_think that does the linear algebra calculations for us and gives us the answers. Since there are 10 unknowns for 4 different systems of equations, we should end up with 40 total results (since there are 40 unknowns). These unknowns should make up the bytes of the flag.

from hashlib import sha256
import numpy as np
class Brain:
def __init__(unc, neurons):
unc.neurons = neurons
unc.thought_size = 10
def brainstem(unc):
return sha256(",".join(str(x) for x in sum(unc.neurons, [])).encode()).hexdigest()
def rot(unc, data):
for i in range(len(data)):
unc.neurons[(3 * i + 7) % unc.thought_size][(9 * i + 3) % unc.thought_size] ^= data[i]
def get_neurons(unc):
print(unc.neurons)
def think(unc, data):
thought = [0] * unc.thought_size
for i in range(unc.thought_size):
thought[i] = sum(unc.neurons[i][j] * data[j] for j in range(unc.thought_size))
unc.neurons[:-1] = unc.neurons[1:]
unc.neurons[-1] = thought
return thought
def reverse_think(unc, thought):
"""
Reverse the 'think' operation by solving for the original data
given the neurons (weights) and the resulting thought.
"""
# Ensure that the number of neurons matches the size of the thought
if len(thought) != unc.thought_size:
raise ValueError("Thought size does not match the expected neuron size.")
# Convert neurons to a NumPy array for easier matrix operations
neurons_matrix = np.array(unc.neurons)
thought_array = np.array(thought)
print("neurons_matrix shape:", neurons_matrix.shape)
print("thought_array shape:", thought_array.shape)
print("Neurons: ")
print(neurons_matrix)
print()
print("Thoughts:")
print(thought_array)
print()
try:
# Use np.linalg.solve for solving a square system of equations
data = np.linalg.solve(neurons_matrix, thought_array)
unc.neurons[:-1] = unc.neurons[1:]
unc.neurons[-1] = thought
except np.linalg.LinAlgError:
raise ValueError("Failed to solve for data. Check the matrix dimensions.")
return data.tolist()
from brain import Brain
healthy_brain = [[71, 101, 18, 37, 41, 69, 80, 28, 23, 48], [35, 32, 44, 24, 27, 20, 34, 58, 24, 9], [73, 29, 37, 94, 27, 58, 104, 65, 116, 44], [26, 83, 77, 116, 9, 96, 111, 118, 52, 62], [100, 15, 119, 53, 59, 34, 38, 68, 104, 110], [51, 1, 54, 62, 56, 120, 4, 80, 60, 120], [125, 92, 95, 98, 97, 110, 93, 33, 128, 93], [70, 23, 123, 40, 75, 23, 104, 73, 52, 6], [14, 11, 99, 16, 124, 52, 14, 73, 47, 66], [128, 11, 49, 111, 64, 108, 14, 66, 128, 101]]
brainrot = b"gnilretskdi ,coffee ,ymotobol ,amenic etulosba ,oihO ni ylno ,oihO ,pac eht pots ,pac ,yadot yarp uoy did ,pu lio ,eohs ym elkcub 2 1 ,sucric latigid ,zzir tanec iaK ,tac frumS ,yzzilg ,ekahs melraH ,tanec iaK ,raebzaf ydderF ,gnixamnoog ,hoesac ,relzzir eht rof ttayg ruoy tuo gnikcits ,reppay ,gnippay ,pay ,gniggom ,gom,ttalcobmob ,gnillihc gnib ,deepswohsi ,tor niarb ,oitar + L ,ozob L ,L ,oitar ,ie ie iE ,suoived ,emem seimmug revas efil dna seceip s'eseeR ,io io io ,ytrap zzir koTkiT ,teggun ,su gnoma ,retsopmi ,yssus ,suS ,elgnid eladnuaQ ,gnos metsys ym ni atnaF ,kcil suoived ,syddid ta sthgin 5 ,hsinapS ro hsilgnE .gnos teksirb ,agnizab ,bruc eht etib ,orb lil ,dulb ,ni gnihcram og stnias eht nehw ho ,neerb fo seert ees I ,sinneD ekud ,biks no ,ennud yvvil ,knorg ybab ,rehtorb pu s'tahw ,gab eht ni seirf eht tuP ,edaf repat wol ,yddid ,yddirg ,ahpla ,gnixxamskool ,gninoog ,noog ,egde ,gnigde ,raeb evif ydderf ,ekahs ecamirg ,ynnacnu ,arua ,daeh daerd tnalahcnon ,ekard ,gnixat munaF ,xat munaf ,zzir idibikS ,yug llihc ,eiddab ,kooc reh/mih tel ,gnikooc ,kooc ,nissub ,oihO ,amgis eht tahw ,amgis ,idibikS no ,relzzir ,gnizzir ,zzir ,wem ,gniwem ,ttayg ,teliot idibikS ,idibikS"[::-1]
brain = Brain(healthy_brain)
brain.rot(brainrot)
#flag = input("> ").encode()
#if not len(flag) != 40:
# print("i'll be nice and tell you my thoughts have to be exactly 40 characters long")
# exit()
required_thoughts = [
[59477, 41138, 59835, 73146, 77483, 59302, 102788, 67692, 62102, 85259],
[40039, 59831, 72802, 77436, 57296, 101868, 69319, 59980, 84518, 73579466],
[59783, 73251, 76964, 58066, 101937, 68220, 59723, 85312, 73537261, 7793081533],
[71678, 77955, 59011, 102453, 66381, 60215, 86367, 74176247, 9263142620, 982652150581],
]
failed_to_think = False
#for i in range(0, len(flag), 10):
# thought = brain.think(flag[i:i + 10])
# if thought != required_thoughts[i//10]:
# failed_to_think = True
print("Neurons:")
brain.get_neurons()
answer = []
for constants in required_thoughts:
answer_row = brain.reverse_think(constants)
for a in answer_row:
answer.append(a)
print()
print("Final answer:")
print(answer)
# Round the values and convert to ASCII
ascii_chars = ''.join(chr(round(value)) for value in answer)
print("The ASCII result is:", ascii_chars)
if failed_to_think or brain.brainstem() != "4fe4bdc54342d22189d129d291d4fa23da12f22a45bca01e75a1f0e57588bf16":
print("ermm... you might not be a s""igma...")
else:
print("holy s""kibidi you popped off... go submit the flag")

In rot.py I call reverse_think, passing in the 4 constant vectors from required_thoughts, and we get the output:

image

The flag is gigem{whats_up_my_fellow_skibidi_sigmas}

OTP

This challenge revolves around a C program that encrypts the flag through XOR using random changing keys, and a core dump that contains said keys. We have encyrypted_flag.bin which contains the final encrypted flag output, we just have to find a way to decrypt it.

First of all, we are given the binary otp and its source code, otp.c:

#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#define KEYS 1000
int RANDOM_FD = -1;
void dump() {
char cmd[128];
int pid = getpid();
snprintf(cmd, sizeof(cmd), "gcore -o dump %d; mv dump.%d dump", pid, pid);
system(cmd);
}
void otp(unsigned char* p, int n, int depth) {
char key[128];
read(RANDOM_FD, key, n);
for (int i = 0; i < n; ++i) {
p[i] ^= key[i];
}
if (depth < KEYS - 1) {
otp(p, n, depth + 1);
} else {
dump();
}
}
int main(int argc, char** argv) {
if (argc != 3) {
printf("Usage: %s <FLAG_FILE> <ENCRYPTED_FLAG_FILE>\n", argv[0]);
return 0;
}
char* flag = argv[1];
char* encrypted_flag = argv[2];
RANDOM_FD = open("/dev/urandom", O_RDONLY);
unsigned char p[128];
int fd = open(flag, O_RDONLY);
if (fd < 0) {
printf("%s not found\n", flag);
return 0;
}
int n = read(fd, p, 128);
int write_fd = open(encrypted_flag, O_WRONLY | O_CREAT, 0644);
if (n > 1) {
if (p[n - 1] == '\n') {
--n;
}
otp(p, n, 0);
write(write_fd, p, n);
}
close(RANDOM_FD);
close(fd);
close(write_fd);
}

As we can see, it seems to generate random keys by using /dev/urandom and encrypts our flag by XOR’ing the bytes of the flag by the bytes of the key. This is done over and over again. How do we get those keys? This is where the core dump comes into play.

We can run the otp binary with the core dump in order to examine the registers and the program execution from the dump.

We can do this pretty easily with gdb <path to binary> <path to dump>

image

Alright, so how do we get the keys?

Well, we can run bt to see all the previous function calls.

image

Alright, so we can see a whole lot of frames, 1003 in fact, and most of the function calls are calling otp(), which as we saw from the source code, is what’s used to “encrypt” (it’s just XOR’ing) the flag with the random keys.

If we scroll all the way back, we see that otp() is called for the first time during frame 4

image

So from frames 4-1003, new random keys are being generated and are being XOR’d with our flag. Thankfully, XOR is reversible, so we can reverse this process, but we need all those keys.

We can use frame <frame number> to switch to a stack frame and inspect registers and variables on the stack. I switch to the most recent stack frame, which is frame 1003

image

Well, from the source code, we see that the name of the key variable is straight up just key. Let’s try running x/128bx key to see if we get anything. We’re trying to print 128 bytes of hex at the key variable. We do 128 bytes since in the source code, we see that the char array variable is initalized with char key[128]

We get:

image

Alright nice the key got printed for this stack frame!

Something interesting though is all those 0’s in the middle. Perhaps the key doesn’t take up the entire 128 bytes? It looks like the key probably ends at 0xf6, which is the 59th byte. We’ll keep that in mind for now

image

Let’s try the next stack frame, frame 1002

image

If we run x/128bx key again, we get

image

Again, everything after the 59th byte (0x1d) seems to be mostly zero! It seems that our key is actually 59 bytes, and not 128.

Now that we have a pretty good grasp of what’s going on, we just need to go through each stack frame starting from 1003 and going all the way back to 4 to get the keys, and XOR that with our encrypted flag. Instead of doing, x/128bx key, we’ll instead do x/59bx key since we know the key is only 59 bytes long. We can automate getting those keys with a gdb script, like so:

extract_keys.gdb
# Set the frame number you want to start from
set $frame = 1003
# Loop through frames and retrieve key, stop when frame reaches 4
while $frame >= 4
# Print the key for the current frame
printf "Frame %d:\n", $frame
# Go to the current frame
frame $frame
# Get the key at the address of 'key' (59 bytes)
x/59xb &key
# Decrement the frame number
set $frame = $frame - 1
end

We can run this and save the output to another file with gdb -batch -x <path to gdb script> <path to binary> <path to dump> >> <file to save output>

I’ll send the output to a file named extracted_keys.txt

Running gdb -batch -x extract_keys.gdb otp dump >> extracted_keys.txt gets us this very long file (this is just some of the output)

...
...
...
A bunch of output
...
...
...
Frame 10:
#10 0x00000000004012a6 in otp (p=0x7ffe603d01d0 "\203U\263k\221\221\031\017\206\bz\350$q\240v\352wN\037\236\253\371\024\200\335LT\264/\232\001!S\271r\221\r1\303\020\277\246Kr(\225'p1\333N\005\334\353\327r\357\177\n\376\177", n=0x3b, depth=0x3e1) at /otp.c:27
27 otp(p, n, depth + 1);
0x7ffe603a1840: 0x31 0xcf 0x0d 0x15 0xff 0xe0 0x28 0x54
0x7ffe603a1848: 0x87 0x26 0x40 0x3b 0x86 0x6f 0xc2 0xf3
0x7ffe603a1850: 0x81 0x2c 0xac 0x1d 0xbe 0x96 0x5c 0x6f
0x7ffe603a1858: 0xf0 0x81 0x7e 0x7e 0xe2 0x76 0x7e 0x2c
0x7ffe603a1860: 0x35 0xed 0x01 0x47 0x65 0x65 0x38 0x1c
0x7ffe603a1868: 0xeb 0x91 0x17 0x3c 0x41 0xe3 0x67 0xc4
0x7ffe603a1870: 0x84 0xf5 0x9d 0x78 0x72 0xe1 0x42 0xec
0x7ffe603a1878: 0x9d 0xe1 0x01
Frame 9:
#9 0x00000000004012a6 in otp (p=0x7ffe603d01d0 "\203U\263k\221\221\031\017\206\bz\350$q\240v\352wN\037\236\253\371\024\200\335LT\264/\232\001!S\271r\221\r1\303\020\277\246Kr(\225'p1\333N\005\334\353\327r\357\177\n\376\177", n=0x3b, depth=0x3e2) at /otp.c:27
27 otp(p, n, depth + 1);
0x7ffe603a1780: 0xc2 0x84 0xbb 0x4f 0x69 0xaa 0x1f 0xb0
0x7ffe603a1788: 0xa9 0x9c 0x1f 0x9c 0xc6 0x61 0xa3 0x9a
0x7ffe603a1790: 0x8a 0x11 0x79 0x10 0x96 0x0b 0xb8 0x82
0x7ffe603a1798: 0xd1 0xd5 0xa2 0x2b 0x05 0x01 0x6b 0x3a
0x7ffe603a17a0: 0xfe 0xbe 0x4a 0x1c 0xb8 0x38 0xb3 0x6b
0x7ffe603a17a8: 0x16 0x89 0x5e 0xbb 0x94 0x63 0x1c 0x9d
0x7ffe603a17b0: 0x62 0xfb 0x47 0xa4 0x70 0x35 0x52 0x16
0x7ffe603a17b8: 0xfb 0x6b 0xbf
Frame 8:
#8 0x00000000004012a6 in otp (p=0x7ffe603d01d0 "\203U\263k\221\221\031\017\206\bz\350$q\240v\352wN\037\236\253\371\024\200\335LT\264/\232\001!S\271r\221\r1\303\020\277\246Kr(\225'p1\333N\005\334\353\327r\357\177\n\376\177", n=0x3b, depth=0x3e3) at /otp.c:27
27 otp(p, n, depth + 1);
0x7ffe603a16c0: 0x0e 0x25 0xaa 0x33 0x5c 0x78 0xeb 0x10
0x7ffe603a16c8: 0x3b 0xf3 0x76 0x56 0x4a 0x5c 0xde 0x80
0x7ffe603a16d0: 0x18 0xfc 0x1a 0x6a 0x3f 0xd5 0x53 0x52
0x7ffe603a16d8: 0x22 0x50 0x62 0xf6 0x83 0x47 0xb7 0x04
0x7ffe603a16e0: 0xdb 0xee 0x86 0x7b 0x6a 0x07 0x63 0x71
0x7ffe603a16e8: 0xae 0x64 0x04 0xf6 0xa3 0x0b 0x18 0xff
0x7ffe603a16f0: 0x94 0xbd 0x98 0xc5 0x5d 0xb6 0x0f 0xb3
0x7ffe603a16f8: 0x2d 0x28 0xa5
Frame 7:
#7 0x00000000004012a6 in otp (p=0x7ffe603d01d0 "\203U\263k\221\221\031\017\206\bz\350$q\240v\352wN\037\236\253\371\024\200\335LT\264/\232\001!S\271r\221\r1\303\020\277\246Kr(\225'p1\333N\005\334\353\327r\357\177\n\376\177", n=0x3b, depth=0x3e4) at /otp.c:27
27 otp(p, n, depth + 1);
0x7ffe603a1600: 0xa5 0x4e 0xee 0x13 0x11 0x9f 0x29 0x67
0x7ffe603a1608: 0xa1 0xa1 0x2b 0x35 0x43 0x70 0xf6 0x56
0x7ffe603a1610: 0x9a 0x33 0xfa 0xdb 0x2e 0x91 0x4f 0x0e
0x7ffe603a1618: 0x67 0xd9 0x6b 0x2d 0x4a 0x3c 0x0b 0xc3
0x7ffe603a1620: 0x16 0x07 0x9d 0x24 0xf6 0xe9 0xb0 0x8a
0x7ffe603a1628: 0x24 0xee 0x66 0x72 0x35 0xd0 0x7f 0xaf
0x7ffe603a1630: 0x1a 0x72 0xd8 0x75 0x38 0x7c 0x01 0x6d
0x7ffe603a1638: 0x27 0x5c 0xd2
Frame 6:
#6 0x00000000004012a6 in otp (p=0x7ffe603d01d0 "\203U\263k\221\221\031\017\206\bz\350$q\240v\352wN\037\236\253\371\024\200\335LT\264/\232\001!S\271r\221\r1\303\020\277\246Kr(\225'p1\333N\005\334\353\327r\357\177\n\376\177", n=0x3b, depth=0x3e5) at /otp.c:27
27 otp(p, n, depth + 1);
0x7ffe603a1540: 0x3c 0x11 0x86 0xe1 0x7d 0xd0 0xd5 0xc9
0x7ffe603a1548: 0xbb 0xdd 0x44 0x34 0xab 0x90 0x46 0xbc
0x7ffe603a1550: 0xe7 0xe8 0xfc 0x2f 0xe1 0x2c 0x6d 0xd4
0x7ffe603a1558: 0x71 0xc8 0x50 0x9e 0xc4 0x7b 0xce 0xca
0x7ffe603a1560: 0x7d 0x28 0xe3 0x1c 0x99 0xff 0xc7 0x13
0x7ffe603a1568: 0x71 0x99 0x8f 0xa7 0xd9 0x63 0x51 0xa5
0x7ffe603a1570: 0x0e 0xf2 0xcb 0x6c 0xc1 0xc1 0x93 0x41
0x7ffe603a1578: 0x3b 0x73 0x26
Frame 5:
#5 0x00000000004012a6 in otp (p=0x7ffe603d01d0 "\203U\263k\221\221\031\017\206\bz\350$q\240v\352wN\037\236\253\371\024\200\335LT\264/\232\001!S\271r\221\r1\303\020\277\246Kr(\225'p1\333N\005\334\353\327r\357\177\n\376\177", n=0x3b, depth=0x3e6) at /otp.c:27
27 otp(p, n, depth + 1);
0x7ffe603a1480: 0x16 0x1b 0xbc 0x54 0x76 0xb4 0xe8 0xe9
0x7ffe603a1488: 0x49 0x7b 0x2d 0xa7 0x40 0xc3 0x4f 0x81
0x7ffe603a1490: 0x9c 0x6a 0x45 0xe8 0x66 0x94 0x06 0x61
0x7ffe603a1498: 0xc2 0xc7 0xcf 0xe4 0xba 0xa4 0x56 0xb8
0x7ffe603a14a0: 0xcf 0xae 0x20 0x25 0xaa 0x54 0x65 0x87
0x7ffe603a14a8: 0xc6 0x19 0xee 0x30 0xeb 0x8c 0x55 0xd7
0x7ffe603a14b0: 0xa3 0x68 0xa5 0xca 0x20 0x7f 0x16 0x52
0x7ffe603a14b8: 0xfa 0x37 0xdc
Frame 4:
#4 0x00000000004012b2 in otp (p=0x7ffe603d01d0 "\203U\263k\221\221\031\017\206\bz\350$q\240v\352wN\037\236\253\371\024\200\335LT\264/\232\001!S\271r\221\r1\303\020\277\246Kr(\225'p1\333N\005\334\353\327r\357\177\n\376\177", n=0x3b, depth=0x3e7) at /otp.c:29
29 dump();
0x7ffe603a13c0: 0x38 0x09 0x88 0xb7 0x33 0xe9 0x5c 0x1c
0x7ffe603a13c8: 0x34 0x01 0xb7 0x8a 0xff 0x7b 0x61 0xb4
0x7ffe603a13d0: 0xc7 0xbe 0x5e 0xf7 0x9c 0x3c 0xd7 0x77
0x7ffe603a13d8: 0x44 0xf4 0x00 0x44 0x03 0x14 0xf1 0x60
0x7ffe603a13e0: 0xf4 0x85 0x2f 0x05 0x5d 0x6a 0xf8 0x7d
0x7ffe603a13e8: 0x87 0xeb 0x24 0x1d 0x08 0x7c 0xa0 0xa8
0x7ffe603a13f0: 0x47 0xf9 0xd8 0x20 0x19 0x8b 0x7e 0x5a
0x7ffe603a13f8: 0x08 0xf5 0x2d

This file is extremely long so no way are we parsing this by hand. From the output, we can see that the key bytes for each stack frame follow a line that begins with 0x<some big hex value>:

We can write a Python script to parse this output file, get the key from each stack frame, XOR it with our encrypted flag in encrypted_flag.bin, and print the output

# Open the encrypted_flag.bin file in binary mode
with open('encrypted_flag.bin', 'rb') as file:
encrypted_flag = file.read()
# Print the bytes in a readable format (e.g., a list of hexadecimal values)
print("Encrypted Flag (in hex):")
encrypted_flag = list(encrypted_flag) # Convert byte data to a list of integers
print(encrypted_flag)
keys = []
current_key = []
# Open the output log file
with open('extracted_keys.txt', 'r') as f:
for line in f:
if line[0:2] == "0x":
#print(line)
begin = line.index(":")
#print(line[begin+1:].split("\t"))
key_arr = line[begin+1:].split("\t")
key_arr = [hex.strip().replace("\n", "") for hex in key_arr]
for hex in key_arr:
if hex != "":
current_key.append(int(hex, 16))
if(len(current_key) == 59):
keys.append(current_key)
current_key = []
break
#print(key_arr)
decrypted_flag = []
for key in keys:
for i in range(len(encrypted_flag)):
encrypted_flag[i] ^= key[i]
print("Final")
print(encrypted_flag)
printable_string = ''.join([chr(byte) for byte in encrypted_flag])
# Print the result
print(printable_string)

Running this gets us:

image

The flag is gigem{if_you_did_that_manually_i_am_so_sorry_for_your_loss}

This challenge is now complete!

Whatitdoes

This challenge revolves around an odd Python script that requires some interesting files as input.

We have the script WDID.py:

import sys
import argparse
class WaduzitdoProgram:
def __init__(self, source: bytes, delim: bytes = None) -> None:
self.insts = []
self.input = []
self.current_char = ''
self.match_flag = False
self.markers = []
self.marker_indices = {} # Maps PC positions to marker indices
self.accept_char_positions = [] # Stores positions of AcceptChar instructions
self.jump_positions = [] # Stores positions of Jump instructions
self.forward_jumps = {} # Will store precomputed jump targets
self.pc = 0
self.parse(source, delim)
def _parse_inst(self, token: str) -> tuple:
if not token or token.isspace():
return None
if token[0] == '*':
marker_index = len(self.markers)
marker_position = len(self.insts)
self.markers.append(marker_position)
self.marker_indices[marker_position] = marker_index
return self._parse_inst(token[1:])
if token[0] == 'Y':
if len(token) > 1:
inner_inst = self._parse_inst(token[1:])
if inner_inst:
return ('Conditional', True) + inner_inst
return None
elif token[0] == 'N':
if len(token) > 1:
inner_inst = self._parse_inst(token[1:])
if inner_inst:
return ('Conditional', False) + inner_inst
return None
if token[0] == 'T':
return ('Text', token[2:] if len(token) > 2 else '')
elif token[0] == 'A':
return ('AcceptChar',)
elif token[0] == 'M':
parts = token.split(':', 1)
char = parts[1] if len(parts) > 1 else ''
return ('MatchChar', char)
elif token[0] == 'J':
parts = token.split(':', 1)
if len(parts) > 1:
try:
return ('Jump', int(parts[1].strip()))
except ValueError:
print(f'Invalid jump value: {parts[1]}')
sys.exit(1)
return ('Jump', 0) # Default jump to marker 0 if no value provided
elif token[0] == 'S':
return ('Stop',)
else:
print(f'Unknown token: {token}')
sys.exit(1)
def parse(self, source: bytes, delim: bytes = None) -> None:
if delim is None:
tokens = source.decode().splitlines()
else:
tokens = [token.decode() for token in source.split(delim)]
# Initialize with marker 0 at position 0
self.markers = [0]
self.marker_indices[0] = 0
for token in tokens:
inst = self._parse_inst(token)
if inst:
curr_pos = len(self.insts)
if inst[0] == 'AcceptChar':
self.accept_char_positions.append(curr_pos)
elif inst[0] == 'Jump':
self.jump_positions.append(curr_pos)
# Handle nested conditional jumps
elif inst[0] == 'Conditional' and len(inst) > 2 and inst[2] == 'Jump':
self.jump_positions.append(curr_pos)
self.insts.append(inst)
# Precompute only the necessary forward marker locations
self._compute_forward_markers()
def _compute_forward_markers(self):
"""
Precompute the forward markers only for positions with Jump instructions.
Creates a mapping from (pc_position, jump_count) to target_position.
"""
self.forward_jumps = {}
# For each position with a Jump instruction
for pos in self.jump_positions:
# Get the instruction at this position
inst = self.insts[pos]
# For conditional jumps, extract the jump part
if inst[0] == 'Conditional' and len(inst) > 2 and inst[2] == 'Jump':
jump_count = inst[3]
elif inst[0] == 'Jump':
jump_count = inst[1]
else:
continue # Skip if not a jump instruction
# Skip J:0 as it's handled separately
if jump_count == 0:
continue
# Find the target for this specific jump count
target_pos = self._find_nth_marker_after(pos, jump_count)
if target_pos is not None:
# Store only this specific jump target
if pos not in self.forward_jumps:
self.forward_jumps[pos] = {}
self.forward_jumps[pos][jump_count] = target_pos
def _find_nth_marker_after(self, start_pos, n):
"""
Find the position of the nth marker after the given position.
Returns None if not enough markers found.
"""
count = 0
for marker_pos in sorted(self.markers):
if marker_pos > start_pos:
count += 1
if count == n:
return marker_pos
return None
def _accept_char(self) -> None:
if not self.input:
# Read in one character from stdin
self.current_char = sys.stdin.read(1)
if self.current_char == '\n':
# Don't read in newline characters
self.current_char = sys.stdin.read(1)
else:
# Pop the first character from the input buffer
self.current_char = self.input.pop(0)
def _run_instruction(self, inst: tuple) -> None:
if inst[0] == 'Conditional':
# Only run the inner instruction if the match flag matches the condition
if inst[1] == self.match_flag:
inner_inst = inst[2:]
self._run_instruction(inner_inst)
else:
self.pc += 1
return
elif inst[0] == 'Stop':
self.pc = len(self.insts)
return
elif inst[0] == 'Jump':
jump_index = inst[1]
if jump_index == 0:
# Special case: J:0 jumps backward to the most recent A command
# Binary search for the most recent AcceptChar position
target_pos = -1
left, right = 0, len(self.accept_char_positions) - 1
while left <= right:
mid = (left + right) // 2
if self.accept_char_positions[mid] < self.pc:
target_pos = self.accept_char_positions[mid]
left = mid + 1
else:
right = mid - 1
if target_pos != -1:
self.pc = target_pos
else:
self.pc = 0 # If no AcceptChar found, jump to beginning
else:
# O(1) jump using precomputed table
if self.pc in self.forward_jumps and jump_index in self.forward_jumps[self.pc]:
self.pc = self.forward_jumps[self.pc][jump_index]
else:
# If not precomputed, find it on demand
target_pos = self._find_nth_marker_after(self.pc, jump_index)
if target_pos is not None:
self.pc = target_pos
else:
print(f"Error: Jump target {jump_index} markers ahead not found")
self.pc = len(self.insts) # Stop execution
return
elif inst[0] == 'Text':
print(inst[1])
elif inst[0] == 'AcceptChar':
self._accept_char()
elif inst[0] == 'MatchChar':
self.match_flag = self.current_char == inst[1]
self.pc += 1
def run(self, input=None, debug=False) -> None:
self.pc = 0
self.input = list(input) if input else []
self.match_flag = False
while self.pc < len(self.insts):
inst = self.insts[self.pc]
if debug:
print(f"PC: {self.pc}, Inst: {inst}, Char: '{self.current_char}', Match: {self.match_flag}")
self._run_instruction(inst)
def main():
parser = argparse.ArgumentParser(description='Waduzitdo Program Interpreter')
parser.add_argument('source_file', help='Path to the Waduzitdo source file')
parser.add_argument('--debug', '-d', action='store_true', help='Enable debug mode')
parser.add_argument('--delimiter', '-l', help='Specify a custom delimiter for tokens')
parser.add_argument('--input', '-i', help='Provide input string instead of reading from stdin')
parser.add_argument('--show-instructions', '-s', action='store_true', help='Display parsed instructions')
args = parser.parse_args()
try:
with open(args.source_file, 'rb') as f:
source = f.read()
except FileNotFoundError:
print(f"Error: Source file '{args.source_file}' not found")
sys.exit(1)
except IOError as e:
print(f"Error reading source file: {e}")
sys.exit(1)
delim = args.delimiter.encode() if args.delimiter else None
try:
waduzitdo = WaduzitdoProgram(source, delim=delim)
if args.show_instructions or args.debug:
print("Markers:", waduzitdo.markers)
print("Jump positions:", waduzitdo.jump_positions)
print("AcceptChar positions:", waduzitdo.accept_char_positions)
print("Instructions:")
for i, inst in enumerate(waduzitdo.insts):
marker = "* " if i in waduzitdo.marker_indices else " "
print(f"{i}: {marker}{inst}")
print("Precomputed jump targets:")
for pos, targets in waduzitdo.forward_jumps.items():
if targets:
print(f" From PC {pos}: {targets}")
print()
waduzitdo.run(input=args.input, debug=args.debug)
except Exception as e:
print(f"Error during execution: {e}")
if args.debug:
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()

We are also given the files nim.wdz and flag.wdz

Of course, flag.wdz is what immediately interests me:

*T:What's the flag?
A
M:k
YJ:1
M:g
NJ:1
A
M:m
YJ:1
M:i
NJ:1
A
M:F
YJ:1
M:g
NJ:1
A
M:h
YJ:1
M:e
NJ:1
A
M:l
YJ:1
M:m
NJ:1
A
M:Z
YJ:1
M:{
NJ:1
A
M:V
YJ:1
M:i
NJ:1
A
M:T
YJ:1
M:t
NJ:1
A
M:Y
YJ:1
M:_
NJ:1
A
M:f
YJ:1
M:d
NJ:1
A
M:g
YJ:1
M:o
NJ:1
A
M:B
YJ:1
M:e
NJ:1
A
M:T
YJ:1
M:s
NJ:1
A
M:B
YJ:1
M:_
NJ:1
A
M:A
YJ:1
M:w
NJ:1
A
M:E
YJ:1
M:h
NJ:1
A
M:Q
YJ:1
M:a
NJ:1
A
M:m
YJ:1
M:t
NJ:1
A
M:K
YJ:1
M:_
NJ:1
A
M:z
YJ:1
M:i
NJ:1
A
M:T
YJ:1
M:t
NJ:1
A
M:l
YJ:1
M:_
NJ:1
A
M:o
YJ:1
M:d
NJ:1
A
M:V
YJ:1
M:o
NJ:1
A
M:H
YJ:1
M:e
NJ:1
A
M:U
YJ:1
M:s
NJ:1
A
M:f
YJ:1
M:}
NJ:1
J:2
*T:Wrong!
S
*T:Correct!
S

How do we go about running WDID.py?

If we try to run python3 WDID.py, usage shows that we need a source file

image

How about we try python3 WDID.py flag.wdz

It prompts us for input, asking what the flag is:

image

We know the flag starts with gigem{ so I begin with that. When I was testing it seems that it can accept the flag character by character like so:

image

As soon as we input an incorrect character though it exits:

image

This means that we can just brute force this. I end up writing this Python brute force script:

from pwn import *
import string
# Flag starts with the known prefix
flag = "gigem{" # Start with the known prefix
chars = string.printable.strip() # All printable characters
wdid_script = "WDID.py"
wdz_file = "flag.wdz"
found = False
while not found:
# If flag ends with "}" we know we have the full flag
if(len(flag) > 0 and flag[len(flag) - 1] == "}"):
found = True
break
for c in chars:
print(f"Trying: {flag + c}")
# Start the process with a timeout (e.g., 5 seconds)
p = process(["python3", wdid_script, wdz_file])
# Send the current flag + character one by one
p.sendline(flag + c) # Only send the current character along with the prefix
try:
# Attempt to read the output with a timeout
output = p.recvall(timeout=1).decode()
# Check if the current character is valid based on the output
if "Wrong!" not in output:
print(output)
print(f"[+] Found valid character: {c}")
flag += c # Add the valid character to the flag
print(f"Flag so far: {flag}") # Print the flag so far
break # Exit the loop to continue searching for the next character
except EOFError:
# If the process exits without output (hanging or valid)
print(f"[+] Found valid character: {c} (EOFError)")
flag += c # Add the valid character to the flag
print(f"Flag so far: {flag}") # Print the flag so far
break # Exit the loop to continue searching for the next character
except TimeoutError:
# If the process times out, count it as valid
print(f"[+] Found valid character: {c} (timeout occurred)")
flag += c # Add the valid character to the flag
print(f"Flag so far: {flag}") # Print the flag so far
break # Exit the loop to continue searching for the next character
p.close()
print(f"Found flag: {flag}")

After running this for a bit we get:

image

The flag is gigem{it_does_what_it_does}

Xorox

This challenge was a pretty simple XOR rev challenge.

We are given a binary, xorox

Popping it into Ghidra and taking a look at the main function, it seems that the program expects the flag as input. The program loads in some variable _DAT_00104020 and then takes everything past gigem from the flag and passes it to a function transformation, along with some variable _DAT_00102080. If this function returns 1, the program prints “Yup”. Otherwise, it prints “Nope”

/* WARNING: Globals starting with '_' overlap smaller symbols at the same address */
undefined8 main(int param_1,undefined8 *param_2)
{
int iVar1;
undefined8 uVar2;
long in_FS_OFFSET;
undefined local_38 [32];
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
if (param_1 == 2) {
iVar1 = strncmp((char *)param_2[1],"gigem",5);
if (iVar1 == 0) {
vmovdqu_avx(_DAT_00104020);
local_38 = vmovdqu_avx(_DAT_00102080);
iVar1 = transformation(param_2[1] + 5,local_38);
if (iVar1 != 0) {
puts("Yup");
uVar2 = 0;
goto LAB_0010132c;
}
}
puts("Nope");
uVar2 = 1;
}
else {
printf("Usage: %s <flag>\n",*param_2);
uVar2 = 1;
}
LAB_0010132c:
if (local_10 == *(long *)(in_FS_OFFSET + 0x28)) {
return uVar2;
}
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}

Taking a look at the transformation function it seems to load in a new variable, _DAT_00102060, and performs some XOR logic with param_1 and param_2, which we know to be everything after gigem in the flag and whatever _DAT_00102080 is respectively. It also performs some XOR logic with whatever is in in_YMM7 as well, which is probably _DAT_00104020 which we saw earlier in the main function

undefined4 transformation(undefined (*param_1) [32],undefined (*param_2) [32])
{
long in_FS_OFFSET;
undefined auVar1 [32];
undefined auVar2 [32];
undefined auVar3 [32];
undefined auVar4 [32];
undefined in_YMM7 [32];
auVar2 = vmovdqu_avx(_DAT_00102060);
auVar1 = vmovdqu_avx(*param_2);
auVar3 = vmovdqu_avx(*param_1);
auVar4 = vmovdqu_avx(auVar2);
auVar2 = vpxor_avx2(auVar1,auVar3);
auVar1 = vpxor_avx2(auVar3,auVar4);
auVar1 = vpxor_avx2(auVar1,in_YMM7);
auVar3 = vpaddd_avx2(auVar2,auVar1);
auVar3 = vpsubd_avx2(auVar3,auVar2);
vptest_avx(auVar3,auVar3);
if (*(long *)(in_FS_OFFSET + 0x28) != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail(auVar2._0_8_,auVar1._0_8_,auVar3._0_8_);
}
return 1;
}

Well if we look at this XOR logic and analyze it (or have AI do it lol), we find that reversing the XOR logic to find param_1, which is the flag, just boils down to:

param_1 or flag = DAT_00102060 ^ in_YMM7

As already mentioned, in_YMM7 is likely DAT_00104020, so it’s just:

param_1 or flag = DAT_00102060 ^ DAT_00104020

We just need the values of DAT_00104020 and DAT_00102060 and then we can compute the flag!

We can find that really easily in Ghidra.

DAT_00104020 is (this is a partial snippet):

image

_DAT_00102060 is (this is a partial snippet):

image

Now we can write a Python script to reverse the XOR logic:

# To get param_1 or the flag, the problem just simplifies to param_1 = DAT_00102060 ^ DAT_00104020
def reverse_transformation(DAT_00102060, DAT_00104020):
# XOR each byte to recover param_1 (the flag)
recovered_param_1 = bytes([p ^ c for p, c in zip(DAT_00102060, DAT_00104020)])
return recovered_param_1
# The value of (DAT_00102060)
DAT_00102060 = bytes.fromhex("fb6ff3cd3a7f8c2aaaca6026f3eec28c92b5a3d761fda1ef5e02902ad2c2dda9")
# The value of (DAT_00104020)
DAT_00104020 = bytes.fromhex("8019c0bf4320ca1e9fbe3f75baa386d3ea85d1e816ccd5870130cf41b7bbaed4")
# Reverse the XOR operation to recover param_1 (which should be the flag)
recovered_param_1 = reverse_transformation(DAT_00102060, DAT_00104020)
# Print the recovered result
print("Recovered param_1:", recovered_param_1)

Running this gets us:

image

It looks just a tad bit malformed. It seems like it should be {v3ry_F45t_SIMD_x0r_w1th_2_keys} instead of {v3ry_F45t_SIMD_x0r?w1th_2_keys}. Well, good thing we can check with the binary. If we run ./xorox <flag> with our probable flag and it’s correct, it should print “Yup”

image

Indeed, gigem{v3ry_F45t_SIMD_x0r_w1th_2_keys} is the flag!