All Articles

VishwaCTF 2024 Writeup

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.

image-20240308201831073

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.

image-20240308202520348

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.

image-20240308203127584

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.

image-20240308205322057

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.

image-20240308211534796

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.

image-20240308212550418

The following steps then reverse the portion that was not extracted (CDEF...XYZ).

image-20240308212716697

As a result, the string becomes CBAZYX...FED.

image-20240308212751601

Finally, the entire string is reversed.

After all these operations, the original string has been transformed into DEFG...XYZABC.

image-20240308212844605

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.

image-20240308213327017

Lucifer function

The Lucifer function is called immediately after zarathos completes.

It receives the rearranged string from zarathos and the character count as arguments.

image-20240308225255920

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).

image-20240308225928105

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.

image-20240308231205983

This result is temporarily mapped to the previously mysterious region at 0x417148.

image-20240308231405174

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.

image-20240308232810400

The implementation looks like this:

image-20240308232911922

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.

image-20240308233306254

In the subsequent pcVar2 = (char *)std::map<>::at(&local_25); call, passing the first character 8 as the argument returns the character %.

image-20240308234026337

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:

  1. 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....XYZ yields CBADEFG...XYZ).
  2. Reverse the portion that was not extracted (reversing CDEF...XYZ gives CBAZYX...FED).
  3. Reverse the entire string (resulting in DEFG...XYZABC).
  4. Randomly select one of the values from -3 to 3 (approximately).
  5. 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 digits 6 and 8 would be appended).
  6. 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*&#2][]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…