TISC 2021 Writeup Cover Image

TISC 2021 Writeup

| 10 min read

TISC (The InfoSecurity Challenge) is a CTF organised by CSIT, and this is the second year they are organising this.

My overall experience can be described as a fun ride on an emotional rollercoaster, from feeling hopeful and challenged to being completely stuck, and from time to time a sense of accomplishment for finally cracking the puzzle.

I have been very busy so I am just going to focus on stuff that I think might be more interesting, and will leave it to the other participants to write more complete write-ups of the challenges.

Level 3

Image

For level 3, we are given 2 bitmap files and here's what they look like as an image.

Image

Nothing interesting, so let's open it in a hex editor and see what's inside. The file starts with a regular bitmap header, but as I scroll down, I noticed exe file fragments.

Image
Image
Image

Right at the end, there is also a MZ DOS header. After taking a closer look, I figured out these fragments are slices of the same exe file and are reordered from bottom to top. To restore it back to its original state, I refer to Microsoft's PE Format page to identify where the cuts were made.

After some painstaking effort, I found out that the cuts were made uniformly, which means I can avoid the tedious work of rearranging it manually, and a simple Python script can do the job. So here's the Python function that I wrote.

def main():
with open('1.bmp', 'rb') as bmp_file:
data = bmp_file.read()
with open('1.exe', 'wb') as exe_file:
for i in range(len(data), 0, -148):
exe_file.write(data[i-148:i-3])

With the exe file fully restored, it's time to do some reverse engineering. This exe file is a command line application (CLI), it’s very minimal and not protected. It takes in one command line argument, which is the location of the file to read. The argument can be either a filename (if the file is in the same directory), or a relative path, and the file must end with "txt".

Below is Ghidra's decompiled output of the key function in this exe. First it checks the command line argument passed in, then it reads the content of the file to memory, and finally it does an XOR using the loaded file content with an embedded string.

void __cdecl FUN_00401000(int param_1,int param_2)
{
uint *puVar1;
uint *puVar2;
byte bVar3;
char *_Str;
uint uVar4;
uint uVar5;
uint uVar6;
char cVar7;
char *pcVar8;
byte *pbVar9;
uint uVar10;
short *_DstBuf;
size_t _Count;
size_t sVar11;
byte *pbVar12;
uint *puVar13;
undefined extraout_DL;
undefined extraout_DL_00;
undefined extraout_DL_01;
undefined uVar14;
int iVar15;
code *pcVar16;
code *pcVar17;
bool bVar18;
uint local_28;
uint local_20;
FILE *local_10;
uint local_c;

local_c = DAT_00407004 ^ (uint)&stack0xffffffd4;
iVar15 = param_2;
puts("HELLO WORLD");
if (1 < param_1) {
_Str = *(char **)(param_2 + 4);
pcVar8 = strrchr(_Str,0x2e);
if ((pcVar8 == (char *)0x0) || (pcVar8 == _Str)) {
pbVar9 = &DAT_0040575a;
}
else {
pbVar9 = (byte *)(pcVar8 + 1);
}
pbVar12 = &DAT_00405784; // 'txt'
do {
bVar3 = *pbVar12;
bVar18 = bVar3 < *pbVar9;
if (bVar3 != *pbVar9) {
LAB_00401080:
uVar10 = -(uint)bVar18 | 1;
goto LAB_00401085;
}
if (bVar3 == 0) break;
bVar3 = pbVar12[1];
bVar18 = bVar3 < pbVar9[1];
if (bVar3 != pbVar9[1]) goto LAB_00401080;
pbVar12 = pbVar12 + 2;
pbVar9 = pbVar9 + 2;
} while (bVar3 != 0);
uVar10 = 0;
LAB_00401085:
if (uVar10 == 0) {
fopen_s(&local_10,*(char **)(param_2 + 4),"rb");
pcVar16 = fseek_exref;
if (local_10 == (FILE *)0x0) {
puts("File opening failed");
local_20 = 0;
local_28 = 0;
pcVar16 = fseek_exref;
}
else {
fseek(local_10,0,2);
local_20 = ftell(local_10);
local_28 = (int)local_20 >> 0x1f;
fclose(local_10);
}
_DstBuf = (short *)malloc(local_20);
fopen_s(&local_10,*(char **)(iVar15 + 4),"rb");
pcVar17 = puts_exref;
cVar7 = (char)iVar15;
if (local_10 == (FILE *)0x0) {
puts("File opening failed");
}
else {
(*pcVar16)(local_10,0,2);
_Count = ftell(local_10);
iVar15 = (int)_Count >> 0x1f;
rewind(local_10);
sVar11 = fread(_DstBuf,1,_Count,local_10);
pcVar17 = puts_exref;
if ((sVar11 == _Count) && (iVar15 == 0)) {
fclose(local_10);
cVar7 = (char)iVar15;
pcVar17 = puts_exref;
if (sVar11 != 0) {
uVar10 = 0;
if ((local_28 != 0) || ((local_20 != 0 && (0x3f < local_20)))) {
uVar10 = 0;
if ((&DAT_0040312f + local_20 < _DstBuf) ||
((undefined *)((int)_DstBuf + (local_20 - 1)) < &DAT_00403130)) {
local_10 = (FILE *)(&DAT_00403130 + -(int)_DstBuf);
cVar7 = (char)-(int)_DstBuf + '@';
puVar13 = (uint *)(_DstBuf + 0x10);
do {
do {
puVar1 = puVar13 + 0x10;
uVar4 = *(uint *)(&UNK_00403134 + uVar10);
uVar5 = *(uint *)(&UNK_00403138 + uVar10);
uVar6 = *(uint *)(&UNK_0040313c + uVar10);
puVar13[-8] = *(uint *)(&DAT_00403130 + uVar10) ^ puVar13[-8];
puVar13[-7] = uVar4 ^ puVar13[-7];
puVar13[-6] = uVar5 ^ puVar13[-6];
puVar13[-5] = uVar6 ^ puVar13[-5];
puVar2 = (uint *)(&DAT_00403140 + uVar10);
uVar4 = *(uint *)(&UNK_00403144 + uVar10);
uVar5 = *(uint *)("!llergist " + uVar10);
uVar6 = *(uint *)("!llergist " + uVar10 + 4);
uVar10 = uVar10 + 0x40;
puVar13[-4] = *puVar2 ^ puVar13[-4];
puVar13[-3] = uVar4 ^ puVar13[-3];
puVar13[-2] = uVar5 ^ puVar13[-2];
puVar13[-1] = uVar6 ^ puVar13[-1];
puVar2 = (uint *)((int)puts + -(int)_DstBuf + (int)puVar1);
uVar4 = puVar2[1];
uVar5 = puVar2[2];
uVar6 = puVar2[3];
*puVar13 = *puVar2 ^ *puVar13;
puVar13[1] = uVar4 ^ puVar13[1];
puVar13[2] = uVar5 ^ puVar13[2];
puVar13[3] = uVar6 ^ puVar13[3];
puVar2 = (uint *)((int)&UNK_00403100 + -(int)_DstBuf + (int)puVar1);
uVar4 = puVar2[1];
uVar5 = puVar2[2];
uVar6 = puVar2[3];
puVar13[4] = *puVar2 ^ puVar13[4];
puVar13[5] = uVar4 ^ puVar13[5];
puVar13[6] = uVar5 ^ puVar13[6];
puVar13[7] = uVar6 ^ puVar13[7];
puVar13 = puVar1;
} while ((uint)((int)uVar10 >> 0x1f) < local_28);
} while (((uint)((int)uVar10 >> 0x1f) <= local_28) &&
(uVar10 < (local_20 & 0xffffffc0)));
}
}
if (((uint)((int)uVar10 >> 0x1f) <= local_28) &&
(((uint)((int)uVar10 >> 0x1f) < local_28 || (uVar10 < local_20)))) {
pbVar9 = (byte *)(uVar10 + (int)_DstBuf);
do {
do {
pbVar12 = pbVar9 + 1;
*pbVar9 = *pbVar9 ^ pbVar9[(int)&DAT_00403130 - (int)_DstBuf];
uVar10 = uVar10 + 1;
pbVar9 = pbVar12;
} while ((uint)((int)uVar10 >> 0x1f) < local_28);
} while (((uint)((int)uVar10 >> 0x1f) <= local_28) && (uVar10 < local_20));
}
FUN_00401360(_DstBuf,local_20);
free(_DstBuf);
FUN_00401581(local_c ^ (uint)&stack0xffffffd4,extraout_DL,cVar7);
return;
}
}
else {
puts("Reading error");
cVar7 = (char)iVar15;
}
}
(*pcVar17)("failed to read file");
free(_DstBuf);
uVar14 = extraout_DL_00;
goto LAB_00401338;
}
}
cVar7 = (char)iVar15;
puts("flag{THIS_IS_NOT_A_FLAG}");
uVar14 = extraout_DL_01;
LAB_00401338:
FUN_00401581(local_c ^ (uint)&stack0xffffffd4,uVar14,cVar7);
return;
}

The embedded string begins with "!llergist" as shown in the Ghidra decompiled function above. Let's look for the string in a hex editor to get a clearer view.

The actual start of the string isn't "!llergist", but for ease of explanation, let's assume Ghidra is right.

Image

These words resemble the words found in 2.bmp (shown below), they are almost identical, just slightly altered, with some alphabets replaced by numbers and symbols. Below is the hex editor view of 2.bmp, this file is similar to 1.bmp, starting with a bitmap header, but followed by words in normal lower case English alphabet.

Image

And it looks like it has been sliced and rearranged in the same way too. "!llergist" maps to "allergist", and it's also found near the bottom of the bmp file. So again I pinpointed the cut locations and wrote another similar script to restore the file.

def main():
with open('2.bmp', 'rb') as bmp_file:
data = bmp_file.read()
with open('2.txt', 'wb') as txt_file:
for i in range(len(data), 0, -100):
txt_file.write(data[i-100:i-1])

With the right text file loaded in, the XOR operation now produces the secret message, which turns out to be a dll file, and I will name it 3.dll. The exe file doesn't save or output the result of the XOR operation, so I have to load it into OllyDbg to extract the result from the memory.

As usual, I will start with static analysis, so here’s the Ghidra decompiled output of it's key function.

void FUN_10001050(void)
{
byte bVar1;
uint uVar2;
byte *_DstBuf;
size_t _Count;
size_t sVar3;
byte *pbVar4;
int iVar5;
char *pcVar6;
undefined extraout_DL;
undefined extraout_DL_00;
undefined extraout_DL_01;
undefined extraout_DL_02;
undefined extraout_DL_03;
undefined uVar7;
uint uVar8;
bool bVar9;
undefined in_stack_fffffde4;
FILE *local_214;
byte abStack528 [256];
byte local_110 [260];
uint local_c;

local_c = DAT_10004004 ^ (uint)&stack0xfffffde4;
fopen_s(&local_214,"key.txt","rb");
if (local_214 != (FILE *)0x0) {
fseek(local_214,0,2);
uVar2 = ftell(local_214);
fclose(local_214);
if ((uVar2 | (int)uVar2 >> 0x1f) != 0) {
_DstBuf = (byte *)malloc(uVar2);
fopen_s(&local_214,"key.txt","rb");
uVar7 = extraout_DL;
if (local_214 != (FILE *)0x0) {
fseek(local_214,0,2);
_Count = ftell(local_214);
rewind(local_214);
sVar3 = fread(_DstBuf,1,_Count,local_214);
uVar7 = extraout_DL_00;
if ((sVar3 == _Count) && (-1 < (int)_Count)) {
fclose(local_214);
uVar7 = extraout_DL_01;
if (sVar3 != 0) {
if (_DstBuf != (byte *)0x0) {
pcVar6 = "Words of the wise may open many locks in life.";
pbVar4 = _DstBuf;
do {
bVar1 = *pbVar4;
bVar9 = bVar1 < (byte)*pcVar6;
if (bVar1 != *pcVar6) {
LAB_10001191:
uVar2 = -(uint)bVar9 | 1;
goto LAB_10001196;
}
if (bVar1 == 0) break;
bVar1 = pbVar4[1];
bVar9 = bVar1 < ((byte *)pcVar6)[1];
if (bVar1 != ((byte *)pcVar6)[1]) goto LAB_10001191;
pbVar4 = pbVar4 + 2;
pcVar6 = (char *)((byte *)pcVar6 + 2);
} while (bVar1 != 0);
uVar2 = 0;
LAB_10001196:
if (uVar2 == 0) {
puts("*Wink wink*");
}
}
memset(local_110,0,0xff);
iVar5 = 0;
do {
abStack528[iVar5] = (byte)iVar5;
iVar5 = iVar5 + 1;
} while (iVar5 < 0x100);
uVar2 = 0;
local_214 = (FILE *)0x0;
do {
bVar1 = abStack528[uVar2];
local_214 = (FILE *)((int)&local_214->_ptr +
(int)(char)bVar1 +
(int)(char)_DstBuf[uVar2 + (((uint)((ulonglong)uVar2 *
0x124924925 >> 0x21) &
0xfffffff8) - uVar2 / 0xe) * -2] &
0xff);
abStack528[uVar2] = abStack528[(int)local_214];
uVar2 = uVar2 + 1;
abStack528[(int)local_214] = bVar1;
} while ((int)uVar2 < 0x100);
uVar2 = 0;
uVar8 = 0;
iVar5 = 0x20;
do {
uVar2 = uVar2 + 1 & 0xff;
bVar1 = abStack528[uVar2];
uVar8 = (int)(char)bVar1 + uVar8 & 0xff;
abStack528[uVar2] = abStack528[uVar8];
abStack528[uVar8] = bVar1;
iVar5 = iVar5 + -1;
} while (iVar5 != 0);
iVar5 = 0;
do {
uVar2 = uVar2 + 1 & 0xff;
bVar1 = abStack528[uVar2];
uVar8 = (int)(char)bVar1 + uVar8 & 0xff;
abStack528[uVar2] = abStack528[uVar8];
abStack528[uVar8] = bVar1;
local_110[iVar5] =
abStack528[(int)(char)abStack528[uVar2] + (int)(char)bVar1 & 0xff] ^
(&DAT_10003124)[iVar5];
iVar5 = iVar5 + 1;
} while (iVar5 < 0x26);
iVar5 = 0;
do {
FUN_10001010(&DAT_1000314c,local_110[iVar5]);
iVar5 = iVar5 + 1;
} while (iVar5 < 0x26);
puts("\n");
free(_DstBuf);
FUN_1000132a(local_c ^ (uint)&stack0xfffffde4,extraout_DL_02,in_stack_fffffde4);
return;
}
}
}
goto LAB_100012fb;
}
}
puts("Almost There!!");
uVar7 = extraout_DL_03;
LAB_100012fb:
FUN_1000132a(local_c ^ (uint)&stack0xfffffde4,uVar7,in_stack_fffffde4);
return;
}

A quick glance tells us this function reads in a “key.txt” file, performs some operations with it, then prints out a short string.

After the content of key.txt is loaded, it checks if the content matches "Words of the wise may open many locks in life. ", if it does, it prints "*Wink wink*". There are two possibilities here, either the sentence "Words of the wise..." is the right key or it’s only a hint about the key.

I explored the simpler option first, it being a hint about the key, so I will have to figure out what "words of the wise" refers to. I searched for "wise" in the previous txt file and found 3 occurrences (rubywise, gardenwise, problemwise). Next I tried using them as the key, including all the different permutations I can think of, combining the words in different orders, letter cases, etc, but could not get any legible output from them. So I concluded that this hint alone might not provide sufficient information to get to the key.

Note that unlike exe files, a dll file by itself cannot be executed, and one way to run it is to write an exe file which loads and calls it’s functions. In this case, this dll has a DllMain entry point so there is no need to call any function. Below is an example of how it can be done.

#include <windows.h>

int main() {
LoadLibrary(TEXT("3.dll"));
return 0;
}

At this point, I am left with no option but to do deeper reverse engineering, and also to entertain the possibility that the "Words of the wise..." sentence might be the right key. Looking at Ghidra's decompiled code, the “wink” code block appears to be independent and does not affect the logic of the codes that come after it.

So for this hypothesis to be true, the only possibility is that some of the assembly instructions might be tampered with and I am supposed to fix it. Looking around, I did find something suspicious that might justify this hypothesis. As shown below, I found long NOP instructions, which means there is some space for codes to be inserted, replacing the NOP.

Image

Although I wasn't able to find anything suspicious with the assembly instructions above the NOP, nothing seems to be modified or removed, I did not want to dismiss any possibilities prematurely as I was pretty stuck. The multi-byte NOP could also very well be legitimately used for aligning the codes.

Another sign that might support this possibility is the usage of the "wink" word, which seems to be an affirmative code word. I recalled that it also appeared in the prize page and is used in an affirmative manner as shown below.

Image

So I started doing dynamic analysis, to better understand what the function does. The latest OllyDbg v2 doesn’t support loading of dll files so I had to use v1.10. Despite being very dated, it works almost perfectly for this task. The only shortcoming is that it cannot handle multi-byte NOP instruction and will crash there during debugging.

As shown below, it wasn't able to understand the multi-byte NOP instruction, 0F 1F 84 00 00 00 00 00, and place each byte in a single line. Instructions that came after were also affected.

Image

To fix this problem, I simply have to replace the multi-byte NOP instruction with single byte NOP instructions and it works flawlessly thereafter.

Image

While stepping through the codes and looking at what it’s doing, I found that the codes were unnecessarily unintuitive and could be simplified to a simple human readable version, so I attempted to reproduce it in Python. The process involves converting the assembly code to Python, simplifying and merging the operations. And after much tedious work, I finally succeeded and below is the final product.

Note that this has been slightly cleaned up to make it more pythonic, hopefully more readable, not the exact original but pretty close. The codes are self explanatory, and the only thing I want to point out is some of the assembly instructions use the 8 bits AL register, so I did some conversation using the modulus operator (% 0x100).

def dll_clone(key_str):
x = [0xf3, 0x62, 0x5e, 0xf3, 0xab, 0xb1, 0x8c, 0x80, 0x72, 0xe0, 0xb6, 0xed, 0x9d, 0xc6, 0xb1, 0xc8,
0xf8, 0xb4, 0x6d, 0x2f, 0x8b, 0xb3, 0x3b, 0x1a, 0x3d, 0xe0, 0x2c, 0xa1, 0x59, 0xc1, 0x6f, 0xd9,
0x7a, 0x67, 0x28, 0x9c, 0x8f, 0x3d]
arr = list(range(0x100))
key = list(map(lambda a: ord(a), key_str))
msg = []

# Step A
n = 0
for i in range(0x100):
n = (n + arr[i] + key[i % 14]) % 0x100
arr[i], arr[n] = arr[n], arr[i] # swap

# Step B
n = 0
for i in range(0x46):
n = (n + arr[i + 1]) % 0x100
arr[i + 1], arr[n] = arr[n], arr[i + 1] # swap

if i >= 0x20:
a = (arr[i + 1] + arr[n]) % 0x100
msg.append(arr[a] ^ x[i-0x20]) # xor

return ''.join(map(lambda a: chr(a), msg))

On a side note, I shared this with the CSIT guys, they were surprised that I actually reproduced the algorithm from assembly code. They told me this is the first time they see something like this, which makes sense, speed is most important in CTF, and might not be worth the time being too thorough. They also shared that this is the RC4 algorithm.

With this, it is clear that the function operates in a very logical manner and the assembly codes weren't tampered with. I also could not identify any flaws with this algorithm that can allow me to get the hidden message without the key and with minimal effort.

Even without knowing that it's RC4, it's pretty obvious that variable x, which I have extracted from the dll is input A and must be XOR with input B, the output produced by the algorithm with the right key, else the hidden message cannot be produced. So without the key, the only other way is to bruteforce it.

At this point, I am really stuck. Despite having fully reverse engineered the dll, I still don't see a clear path forward. What I know is the key must be at least 14 characters and it might have something to do with "Words of the wise" or might not, because the clue did not give a firm answer and only goes as far to say it "may” open many locks in life.

I thought that since I now have a working function of the algorithm, I should just throw everything at it and hopefully it might produce the hidden message. So I threw everything that I could find at it, which are at least 14 characters, or somehow associated with "wise".

It was a futile search until I realised I have not tried the 2.txt word list. So I tried that and hey presto, the flag appeared in front of my eyes. The key is "!t4ttaRRatt4t!", exactly 14 characters, hidden within 2.txt.

I am glad that I finally solved this. Although I spent far too much time than required on this, I enjoyed the process and didn't regret doing so. This is getting too long and I will end off here for now. I might write about the PALIDROME's parting gift if I have the time to play with it, after I received it.

On hindsight, knowing that "words of the wise" just refers to 2.txt, I could have just used the grep command, once I found out that it's looking for a 14 character key, which I believe should be the intended way.

$ strings 2.txt | grep -woE '\S{14}'

philosophizers
obstructionist
trichodontidae
overanimatedly
interpellation
precandidature
pasteurisation
lymphosarcomas
ureteroenteric
discommendably
hyperanabolism
dextrogyratory
unhistorically
!t4ttaRRatt4t!
perissological
reputationless
noncongruously
metantimonious
torturableness
decorativeness
multicarinated
semiopalescent
unqualifyingly
[[[\\\\]]]]^^^
{{{||||}}}}~~~
:::;;;;<<<<===
!!!!""""####$$
$$%%%%&&&&''''
(((())))****++
++,,,,----....

Image below contains assembly code of the dll algorithm for those interested to check it out.

Image