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
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.
Alright, let’s try to find what caused this.
In the plugins directory, we can find notsuspiciousplugin-0.9.0.jar
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
In both the Encrypt and Decrypt files though, we can see that the function eventually calls a nativeLib function
Encrypt:
Decrypt:
Looking at the NativeLib class, it seems to be using functions from notsuspicious.so
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
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.
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:
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
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:
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 bytestarget_bytes = list(target.to_bytes(8, byteorder='little'))
# Start with empty keykey = ''
# Basically loop through and incrementally crack the keyfor 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 byteRunning this gets us:
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:
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
Now we need to attach gdb to this process. Run ps aux | grep spigot to find the process ID of the Spigot server
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
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
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
For our ciphertext, I’ll use 74657374, which is test in hex. For the key I’ll use the key, testkey
Alright nice, we hit our breakpoint.
Let’s take a look at our registers to see what the parameters are
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
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
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" extensionint 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 directoryvoid 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 osimport ctypes
# Load the shared librarylib = 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" filesdef has_enc_extension(filename): return filename.endswith(".enc")
# Recursively decrypt files in a directorydef 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.
It seems to have worked?
It appears we have decrypted the files
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
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
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.
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
Once there, we get our flag!
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 thoughtlock 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 = Cookedmewing 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 sha256import numpy as npclass 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 = Trueprint("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 ASCIIascii_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:
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>
Alright, so how do we get the keys?
Well, we can run bt to see all the previous function calls.
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
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
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:
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
Let’s try the next stack frame, frame 1002
If we run x/128bx key again, we get
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:
# Set the frame number you want to start fromset $frame = 1003
# Loop through frames and retrieve key, stop when frame reaches 4while $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 - 1endWe 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:2727 otp(p, n, depth + 1);0x7ffe603a1840: 0x31 0xcf 0x0d 0x15 0xff 0xe0 0x28 0x540x7ffe603a1848: 0x87 0x26 0x40 0x3b 0x86 0x6f 0xc2 0xf30x7ffe603a1850: 0x81 0x2c 0xac 0x1d 0xbe 0x96 0x5c 0x6f0x7ffe603a1858: 0xf0 0x81 0x7e 0x7e 0xe2 0x76 0x7e 0x2c0x7ffe603a1860: 0x35 0xed 0x01 0x47 0x65 0x65 0x38 0x1c0x7ffe603a1868: 0xeb 0x91 0x17 0x3c 0x41 0xe3 0x67 0xc40x7ffe603a1870: 0x84 0xf5 0x9d 0x78 0x72 0xe1 0x42 0xec0x7ffe603a1878: 0x9d 0xe1 0x01Frame 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:2727 otp(p, n, depth + 1);0x7ffe603a1780: 0xc2 0x84 0xbb 0x4f 0x69 0xaa 0x1f 0xb00x7ffe603a1788: 0xa9 0x9c 0x1f 0x9c 0xc6 0x61 0xa3 0x9a0x7ffe603a1790: 0x8a 0x11 0x79 0x10 0x96 0x0b 0xb8 0x820x7ffe603a1798: 0xd1 0xd5 0xa2 0x2b 0x05 0x01 0x6b 0x3a0x7ffe603a17a0: 0xfe 0xbe 0x4a 0x1c 0xb8 0x38 0xb3 0x6b0x7ffe603a17a8: 0x16 0x89 0x5e 0xbb 0x94 0x63 0x1c 0x9d0x7ffe603a17b0: 0x62 0xfb 0x47 0xa4 0x70 0x35 0x52 0x160x7ffe603a17b8: 0xfb 0x6b 0xbfFrame 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:2727 otp(p, n, depth + 1);0x7ffe603a16c0: 0x0e 0x25 0xaa 0x33 0x5c 0x78 0xeb 0x100x7ffe603a16c8: 0x3b 0xf3 0x76 0x56 0x4a 0x5c 0xde 0x800x7ffe603a16d0: 0x18 0xfc 0x1a 0x6a 0x3f 0xd5 0x53 0x520x7ffe603a16d8: 0x22 0x50 0x62 0xf6 0x83 0x47 0xb7 0x040x7ffe603a16e0: 0xdb 0xee 0x86 0x7b 0x6a 0x07 0x63 0x710x7ffe603a16e8: 0xae 0x64 0x04 0xf6 0xa3 0x0b 0x18 0xff0x7ffe603a16f0: 0x94 0xbd 0x98 0xc5 0x5d 0xb6 0x0f 0xb30x7ffe603a16f8: 0x2d 0x28 0xa5Frame 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:2727 otp(p, n, depth + 1);0x7ffe603a1600: 0xa5 0x4e 0xee 0x13 0x11 0x9f 0x29 0x670x7ffe603a1608: 0xa1 0xa1 0x2b 0x35 0x43 0x70 0xf6 0x560x7ffe603a1610: 0x9a 0x33 0xfa 0xdb 0x2e 0x91 0x4f 0x0e0x7ffe603a1618: 0x67 0xd9 0x6b 0x2d 0x4a 0x3c 0x0b 0xc30x7ffe603a1620: 0x16 0x07 0x9d 0x24 0xf6 0xe9 0xb0 0x8a0x7ffe603a1628: 0x24 0xee 0x66 0x72 0x35 0xd0 0x7f 0xaf0x7ffe603a1630: 0x1a 0x72 0xd8 0x75 0x38 0x7c 0x01 0x6d0x7ffe603a1638: 0x27 0x5c 0xd2Frame 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:2727 otp(p, n, depth + 1);0x7ffe603a1540: 0x3c 0x11 0x86 0xe1 0x7d 0xd0 0xd5 0xc90x7ffe603a1548: 0xbb 0xdd 0x44 0x34 0xab 0x90 0x46 0xbc0x7ffe603a1550: 0xe7 0xe8 0xfc 0x2f 0xe1 0x2c 0x6d 0xd40x7ffe603a1558: 0x71 0xc8 0x50 0x9e 0xc4 0x7b 0xce 0xca0x7ffe603a1560: 0x7d 0x28 0xe3 0x1c 0x99 0xff 0xc7 0x130x7ffe603a1568: 0x71 0x99 0x8f 0xa7 0xd9 0x63 0x51 0xa50x7ffe603a1570: 0x0e 0xf2 0xcb 0x6c 0xc1 0xc1 0x93 0x410x7ffe603a1578: 0x3b 0x73 0x26Frame 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:2727 otp(p, n, depth + 1);0x7ffe603a1480: 0x16 0x1b 0xbc 0x54 0x76 0xb4 0xe8 0xe90x7ffe603a1488: 0x49 0x7b 0x2d 0xa7 0x40 0xc3 0x4f 0x810x7ffe603a1490: 0x9c 0x6a 0x45 0xe8 0x66 0x94 0x06 0x610x7ffe603a1498: 0xc2 0xc7 0xcf 0xe4 0xba 0xa4 0x56 0xb80x7ffe603a14a0: 0xcf 0xae 0x20 0x25 0xaa 0x54 0x65 0x870x7ffe603a14a8: 0xc6 0x19 0xee 0x30 0xeb 0x8c 0x55 0xd70x7ffe603a14b0: 0xa3 0x68 0xa5 0xca 0x20 0x7f 0x16 0x520x7ffe603a14b8: 0xfa 0x37 0xdcFrame 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:2929 dump();0x7ffe603a13c0: 0x38 0x09 0x88 0xb7 0x33 0xe9 0x5c 0x1c0x7ffe603a13c8: 0x34 0x01 0xb7 0x8a 0xff 0x7b 0x61 0xb40x7ffe603a13d0: 0xc7 0xbe 0x5e 0xf7 0x9c 0x3c 0xd7 0x770x7ffe603a13d8: 0x44 0xf4 0x00 0x44 0x03 0x14 0xf1 0x600x7ffe603a13e0: 0xf4 0x85 0x2f 0x05 0x5d 0x6a 0xf8 0x7d0x7ffe603a13e8: 0x87 0xeb 0x24 0x1d 0x08 0x7c 0xa0 0xa80x7ffe603a13f0: 0x47 0xf9 0xd8 0x20 0x19 0x8b 0x7e 0x5a0x7ffe603a13f8: 0x08 0xf5 0x2dThis 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 modewith 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 integersprint(encrypted_flag)
keys = []
current_key = []# Open the output log filewith 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 resultprint(printable_string)Running this gets us:
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 sysimport 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?AM:kYJ:1M:gNJ:1AM:mYJ:1M:iNJ:1AM:FYJ:1M:gNJ:1AM:hYJ:1M:eNJ:1AM:lYJ:1M:mNJ:1AM:ZYJ:1M:{NJ:1AM:VYJ:1M:iNJ:1AM:TYJ:1M:tNJ:1AM:YYJ:1M:_NJ:1AM:fYJ:1M:dNJ:1AM:gYJ:1M:oNJ:1AM:BYJ:1M:eNJ:1AM:TYJ:1M:sNJ:1AM:BYJ:1M:_NJ:1AM:AYJ:1M:wNJ:1AM:EYJ:1M:hNJ:1AM:QYJ:1M:aNJ:1AM:mYJ:1M:tNJ:1AM:KYJ:1M:_NJ:1AM:zYJ:1M:iNJ:1AM:TYJ:1M:tNJ:1AM:lYJ:1M:_NJ:1AM:oYJ:1M:dNJ:1AM:VYJ:1M:oNJ:1AM:HYJ:1M:eNJ:1AM:UYJ:1M:sNJ:1AM:fYJ:1M:}NJ:1J:2*T:Wrong!S*T:Correct!SHow do we go about running WDID.py?
If we try to run python3 WDID.py, usage shows that we need a source file
How about we try python3 WDID.py flag.wdz
It prompts us for input, asking what the flag is:
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:
As soon as we input an incorrect character though it exits:
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 prefixflag = "gigem{" # Start with the known prefixchars = string.printable.strip() # All printable characterswdid_script = "WDID.py"wdz_file = "flag.wdz"
found = Falsewhile 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:
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):
_DAT_00102060 is (this is a partial snippet):
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_00104020def 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 resultprint("Recovered param_1:", recovered_param_1)Running this gets us:
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”
Indeed, gigem{v3ry_F45t_SIMD_x0r_w1th_2_keys} is the flag!