This page has been machine-translated from the original page.
I participated in VishwaCTF 2024.
There was only one Rev challenge, so I’m writing up that one here. (It was quite tough.)
Table of Contents
Your Bonus(Rev)
I am very kind, and you’re my friend too. I was about to share some flags with you, but unfortunately, a ransomware attack occurred on the file containing those flags. All the flags got encrypted by the ransomware. After cross-checking the directories, I found the ransomware file and some other related items.
I’m going to share that information with you. However, due to the ransomware, I’m unable to provide you with the flags 😥😥. Now, I need your help to recover those flags. Can you assist me, please? Your cooperation would be highly appreciated, and you will receive a reward for your help.
Note : Ransomware are not meant to be executed as it can harm your systems (although this won’t)
Decompiling the challenge binary reveals that after opening Flags.txt, the following loop is executed:
while( true ) {
piVar4 = (int *)std::getline<>(local_1a0,local_1b8);
bVar1 = std::basic_ios::operator.cast.to.bool
((basic_ios *)((int)piVar4 + *(int *)(*piVar4 + -0xc)));
if (!bVar1) break;
std::__cxx11::basic_string<>::basic_string(local_1b8);
std::__cxx11::basic_string<>::basic_string(local_a0);
pTextLine = &stack0xfffffe28;
devil_function();
std::__cxx11::basic_string<>::~basic_string(local_6c);
std::__cxx11::basic_string<>::length();
pTextLine = &stack0xfffffe28;
zarathos(&stack0xfffffe10,(basic_string *)pTextLine);
std::__cxx11::basic_string<>::basic_string((basic_string *)&stack0xfffffe28);
pTextLine = (char *)local_14;
local_24 = Lucifer(local_54,local_14);
std::__cxx11::basic_string<>::~basic_string(local_54);
ghost_ridders_wepon();
pTextLine = (char *)local_14;
matter_manipulation[abi:cxx11]((basic_string<> *)&stack0xfffffdf8,local_14);
std::__cxx11::basic_string<>::basic_string((basic_string *)&stack0xfffffdf8);
Trigon(local_3c);
std::__cxx11::basic_string<>::~basic_string((basic_string<> *)local_3c);
local_14 = local_14 + 1;
std::__cxx11::basic_string<>::~basic_string((basic_string<> *)&stack0xfffffdf8);
std::__cxx11::basic_string<>::~basic_string((basic_string<> *)&stack0xfffffe10);
std::__cxx11::basic_string<>::~basic_string((basic_string<> *)&stack0xfffffe28);
}Inside this loop, after reading each line as a string, the functions devil_function, Lucifer, ghost_ridders_wepon, matter_manipulation, and Trigon are called in sequence to produce the encrypted string that is ultimately written to the file.
devil_function
The first function, devil_function, appears to do nothing, so it can be ignored.
Getting the string length
The following code block appears to retrieve the length of the string read from the current line:
std::__cxx11::basic_string<>::~basic_string(local_6c);
std::__cxx11::basic_string<>::length();
pTextLine = &stack0xfffffe28;Immediately after basic_string::length() executes, the length of the line’s string is returned in the eax register.
The retrieved string length is stored on the stack at [esp+0x8], and the edx register holds a pointer to the address where the line’s string obtained from [ebp-0x1D0] is stored.
This pointer is then placed at [esp+4] on the stack, and finally a pointer to an unknown byte region retrieved from [ebp-0x1E8] (initially undefined) is placed at the top of the stack.
zarathos function
The zarathos function is called with the following values pushed onto the stack:
- A pointer to the unknown byte region
- A pointer to the address holding the string read from the file
- The string length
The Ghidra decompiler did not interpret the arguments cleanly, so the analysis below includes minor manual corrections:
/* zarathos(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&, int)
*/
undefined * __cdecl zarathos(undefined *param_1,basic_string *param_2,int param_3)
{
undefined *puVar1;
int iVar2;
undefined4 uVar3;
undefined4 uVar4;
time_t tVar5;
char local_2b;
char local_2a;
char local_29;
undefined4 local_28;
undefined4 local_24;
char local_1d;
int local_1c;
int local_18;
int local_14;
int local_10;
local_2b = '5';
puVar1 = (undefined *)std::map<>::operator[]((map<> *)&_HM,&local_2b);
*puVar1 = 0x23;
local_10 = 3;
local_14 = std::__cxx11::basic_string<>::length();
local_14 = local_14 + -1;
std::operator+(param_1,param_2,param_2);
local_2a = '1';
puVar1 = (undefined *)std::map<>::operator[]((map<> *)&_HM,&local_2a);
*puVar1 = 0x29;
tVar5 = _time((time_t *)0x0);
_srand((uint)tVar5);
iVar2 = _rand();
local_18 = iVar2 % 6 + 3;
iVar2 = _rand();
local_1c = local_10 + iVar2 % ((local_14 - local_10) + 1);
local_29 = '6';
puVar1 = (undefined *)std::map<>::operator[]((map<> *)&_HM,&local_29);
*puVar1 = 0x28;
local_28 = std::__cxx11::basic_string<>::begin();
uVar3 = __gnu_cxx::__normal_iterator<>::operator+((__normal_iterator<> *)&local_28,local_1c);
uVar4 = std::__cxx11::basic_string<>::begin();
std::reverse<>(uVar4,uVar3);
uVar3 = std::__cxx11::basic_string<>::end();
local_24 = std::__cxx11::basic_string<>::begin();
uVar4 = __gnu_cxx::__normal_iterator<>::operator+((__normal_iterator<> *)&local_24,local_1c);
std::reverse<>(uVar4,uVar3);
uVar3 = std::__cxx11::basic_string<>::end();
uVar4 = std::__cxx11::basic_string<>::begin();
std::reverse<>(uVar4,uVar3);
local_1d = '2';
puVar1 = (undefined *)std::map<>::operator[]((map<> *)&_HM,&local_1d);
*puVar1 = 0x24;
return param_1;
}The opening steps copy the received string into an unknown region.
Why the stored string length appears to be extended to 52 characters at this point is unclear.
The next steps generate values randomly.
After that, what is presumably the 0x36th element of the map has 0x28 stored into it. (The intent of this implementation is also unclear.)
The following steps are comparatively straightforward: a number of characters equal to the randomly generated value are extracted from the beginning of the string held in the map object passed as an argument.
The subsequent steps reverse the extracted leading portion and prepend it to the original string.
In other words, if 3 characters are taken from ABC....XYZ, the result becomes CBADEFG...XYZ.
The following steps then reverse the portion that was not extracted (CDEF...XYZ).
As a result, the string becomes CBAZYX...FED.
Finally, the entire string is reversed.
After all these operations, the original string has been transformed into DEFG...XYZABC.
After this function returns, the region at the top of the stack holds a copy of the original string, while the address that originally stored the input now holds the transformed string.
Lucifer function
The Lucifer function is called immediately after zarathos completes.
It receives the rearranged string from zarathos and the character count as arguments.
The most notable part of the Lucifer implementation is the following:
local_38[0] = -3;
local_38[1] = 0xfffffffe;
local_38[2] = 0xffffffff;
local_38[3] = 1;
local_28 = 2;
local_24 = 3;
local_10 = random_pick(4,0);
local_3c = local_38[local_10];The values in local_38 are expressed in two’s complement, which makes them harder to read, but they appear to define the range from -3 to 4 (with some values).
The return value of random_pick(4,0) is unclear, but since it is used as the index in local_3c = local_38[local_10], it is reasonable to assume that local_10 receives a random value between -3 and 3.
The subsequent code retrieves the argument string object, creates an iterator, and implements a loop:
local_14 = param_1;
local_40 = std::__cxx11::basic_string<>::begin();
local_44 = std::__cxx11::basic_string<>::end();
while( true ) {
uVar2 = __gnu_cxx::operator!=(&local_40,&local_44);
if ((char)uVar2 == '\0') break;
pcVar3 = (char *)__gnu_cxx::__normal_iterator<>::operator*((__normal_iterator<> *)&local_40);
local_15 = *pcVar3;
local_1c = local_15 + local_3c;
adfedd(extraout_ECX,extraout_EDX,local_1c,param_2);
__gnu_cxx::__normal_iterator<>::operator++((__normal_iterator<> *)&local_40);
}Here, the previously obtained local_3c is added to each character, and then an unknown function called adfedd is invoked.
The decompilation of this function is as follows:
/* adfedd(int, int) */
void __fastcall adfedd(__cxx11 *param_1,undefined4 param_2,undefined4 param_3,undefined4 param_4)
{
basic_string local_24 [7];
std::__cxx11::to_string(param_1,(basic_string<> *)local_24,param_3);
std::__cxx11::basic_string<>::append(local_24);
std::__cxx11::basic_string<>::~basic_string((basic_string<> *)local_24);
return;
}It appears that the first argument (the value of each character plus local_3c) is converted to its decimal representation, and then each digit is appended as a string character.
This result is temporarily mapped to the previously mysterious region at 0x417148.
ghostridderswepon function
The next function, ghost_ridders_wepon, was honestly difficult to understand.
It simply creates a map object, stores 0x5e and 0x2a into it, and returns.
/* ghost_ridders_wepon() */
void ghost_ridders_wepon(void)
{
undefined *puVar1;
char local_e;
char local_d [9];
local_e = '4';
puVar1 = (undefined *)std::map<>::operator[]((map<> *)&_HM,&local_e);
*puVar1 = 0x5e;
local_d[0] = '0';
puVar1 = (undefined *)std::map<>::operator[]((map<> *)&_HM,local_d);
*puVar1 = 0x2a;
return;
}matter_manipulation function
When this function is called, it returns a pointer to the region containing the encrypted byte sequence.
Since this is what ultimately gets written to the encrypted file, it is highly likely that this function performs the actual encryption.
The implementation looks like this:
It defines a vector table, repeatedly appends strings inside a loop, and then returns a pointer to the region holding those appended strings.
In summary, the value retrieved by pcVar2 = (char *)std::map<>::at(&local_25); is appended to the output string via std::__cxx11::basic_string<>::operator+=(param_1,*pcVar2); to build the final return value.
Since local_25 is an iterator, the input value is processed here.
while( true ) {
uVar1 = __gnu_cxx::operator!=(&local_2c,&local_30);
if ((char)uVar1 == '\0') break;
pcVar2 = (char *)__gnu_cxx::__normal_iterator<>::operator*((__normal_iterator<> *)&local_2c);
local_25 = *pcVar2;
local_14 = 0x68;
std::vector<>::push_back((vector<> *)local_24,&local_14);
local_13 = 0x61;
std::vector<>::push_back((vector<> *)local_24,&local_13);
pcVar2 = (char *)std::map<>::at(&local_25);
std::__cxx11::basic_string<>::operator+=(param_1,*pcVar2);
__gnu_cxx::__normal_iterator<>::operator++((__normal_iterator<> *)&local_2c);
}At this point, what the iterator is traversing is the string of decimal digit characters produced by the earlier conversion step.
In the subsequent pcVar2 = (char *)std::map<>::at(&local_25); call, passing the first character 8 as the argument returns the character %.
Although the actual vector table mapping could not be found statically, tracing values through dynamic analysis revealed the following correspondence:
%: 8^: 4#: 5(: 6): 1@: 3$: 2*: 0&: 9!: 7
This was also confirmed by observing that the string 8485868762636465666768697071727374757677787980818283 was replaced with %^%#%(%!($(@(^(#(((!(%(&!*!)!$!@!^!#!(!!!%!&%*%)%$%@.
Writing the Solver
Based on the analysis so far, the encryption is performed in the following steps:
- Extract a random number of characters from the beginning of the original string, reverse them, and prepend the reversed portion to the original string (e.g., extracting 3 characters from
ABC....XYZyieldsCBADEFG...XYZ). - Reverse the portion that was not extracted (reversing
CDEF...XYZgivesCBAZYX...FED). - Reverse the entire string (resulting in
DEFG...XYZABC). - Randomly select one of the values from -3 to 3 (approximately).
- For each character in the transformed string, subtract the value from step 4 and convert to decimal, then concatenate each digit as a string (for character
D, the digits6and8would be appended). - Replace each digit in the resulting digit string with the corresponding symbol.
Of the steps above, the number of characters to extract in step 1 and the value selected in step 4 are both set randomly.
However, since both ranges are small enough, brute-forcing is feasible for recovering the flag.
Since brute-forcing the rearrangement step is not strictly necessary, the following solver was written:
table = {'*':'0', ')':'1', '$':'2', '@':'3', '^':'4', '#':'5', '(':'6', '!':'7', '%':'8', '&':'9'}
hacked_texts = r"""%##^!)@#(!!(!$%)%&%#(!^&%#^(#*##^&^%&)&)%^!)%)!*($($%$(#(%%&%#@^@)^%!)!)((&@##&$###^^*&@
##@@^&#^&@@%####!$!@!@(#!)&)%#%&!$#^@^^%!*%)&)&@@((@!@@@(%!@(@(@!^%)%&!!%#!!%###($@^
((!^#!%#()^&#)&@#*%*^&&@(^^&(@(%%@!^%&#(@&&)&)(%^(!&%^!)%)!*%#(@(#%$(%%&%*#*!(#)&$@^(%!^(!#%
^&&@%#@(#)#*%@%$^(^(%###(%%@!^%&#(@&&)&)!(%$((#*%^!)%)!*(%%&(((%%#!(#)^(()^&##^%%##*%*#*#*%!#)&@#*%*
!@((!!!(@&@%@&##!$((!#^&###^!#()#%(*(!##^&!$!)%#%@((((%#%&%@!*!)(@!(&@#(&)%&#(#!&)%##^#^($!)#^@^!)!@
!#%##*%*^&#)&@(^^&(@%@%$!)(%%@!^#&()&@(*%&#(@&&)&)%^!)%)!*%#(@(#%$(%%&#%&@#*%*^&&@%*#*!(#)^(
%)((%^@&!(!(%^(^%@%&#####*#^&)@%^*@^@(&)##^&^*@(@@@&()(*#%(*!!!!%#!)^&#((@!)!$((@&@%%)!@%#
@&^&#^^%#^#@#@()(*#%#(!)%)^*@(@@^%&)%&&)(%%#((%&&)%^!)%)!*(#%$(%%&####^%#^^*@(#^^&%@%#!(((@%
(*()()%&#&&@!!(((%^%@#@@#)!@(!%*!!%)!#(!!!(%!%%#&)!)!!%$&@%$!*!)!((&%)&@#*(@%*!@!@%#^*@((*(*
%!!*&@&@%!%#@^@#%&&)^(#@%!%#(!!(@%^((&@^@#%!%#(!%^&@@#%!%#@&@@@#%!@)%&@@%!@^&$%@%#%*!*%$(^&)%#@#
"""
for n in range(-3,4):
for hacked_text in hacked_texts.split("\n"):
flag = ""
buf = ""
for i,word in enumerate(hacked_text):
buf += table[word]
if i % 2 == 1:
flag += chr(int(buf)+n)
buf = ""
if "CTF" in flag:
print(flag)Running this produces the following output:
DL;W?35_4R3_B3AFUL[:)]]F0QVISHWACTF[R4N5^$FLE<
MW4R35_B3AUTIFUL=?_>[:)]]VISHWACTF[<_4R3_R4N50
)382877?><:IS*][]FWD[]VISHCTF[9928*&83UWND(The middle line, MW4R35_B3AUTIFUL=?_>[:)]]VISHWACTF[<_4R3_R4N50, appears to match the expected flag format.
Manually rearranging the pieces reveals that VISHWACTF[4R3_R4N50MW4R35_B3AUTIFUL=] is the correct flag.
Summary
That was exhausting. My reverse-engineering skills clearly need more work…