This page looks best with JavaScript enabled

Malwarebytes CrackMe 3 2021 Solution

Released on 29 October and designed by hasherezade

 ·  ☕ 25 min read  ·  🌈🕊️ rainbowpigeon

This CrackMe was a pleasure to complete! It was decently difficult for my level and I definitely learnt a lot. I was the 6th to submit, though I could have probably been faster if the release aligned with my timezone and I didn’t spend that much time outside 😄. Nevertheless, I won the main prize for the writeup contest!
Much appreciation to hasherezade for designing this CrackMe as well as releasing all her amazing tools that aid analysis.

Details Links
Official Contest Announcement https://blog.malwarebytes.com/threat-intelligence/2021/10/the-return-of-the-malwarebytes-crackme/
Official Contest Summary https://blog.malwarebytes.com/threat-intelligence/2021/11/malwarebytes-crackme-contest-summary/
Github Gist Version of Writeup https://gist.github.com/rainbowpigeon/1d6e3997a050e2b3bd6e67846c2c7085

Overview

The challenge file MBCrackme.exe has 3 password-check buttons for 3 stages.

.NET GUI for MBCrackme.exe with 3 password input fields

Detect It Easy says it is a 32-bit .NET executable, so let’s pop it into dnSpy for analysis!

Detect It Easy detecting the application as .NET v4.0.30319

The program’s entry point calls Application.Run on Form1 which calls InitializeComponent() to register on-click event handlers named button1_Click, button2_Click, and button3_Click for the 3 password-check buttons respectively.

Decompilation in dnSpy shows 3 on-click event handlers in Form1

Stage 1

button1_Click

In button1_Click, our input text is first checked to not be empty and then passed to Form1.decode along with Resources.mb_logo_star.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private void button1_Click(object sender, EventArgs e)
{
    if (this.textBox1.Text.Length == 0)
    {
        MessageBox.Show("Enter the password!");
        return;
    }
    bool flag = false;
    string text = this.textBox1.Text;
    byte[] array = Form1.decode(Resources.mb_logo_star, text);
    // checking of returned array and flag afterwards
}

Steganography

The function decode loops through each pixel of the mb_logo_star bitmap resource and constructs a new byte b by using bits from the RGB value of the pixel. 3 bits are taken from the Red value, 3 from the Green, and 2 from the Blue — slightly different from the usual LSB steganography.
The new byte b is then XOR’d with a byte from our input text and stored in a new array that is returned by the function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static byte[] decode(Bitmap bm, string password_str)
{
    byte[] bytes = Encoding.ASCII.GetBytes(password_str);
    byte[] array = new byte[bm.Width * bm.Height];
    int num = 0;
    for (int i = 0; i < bm.Width; i++)
    {
        for (int j = 0; j < bm.Height; j++)
        {
            Color pixel = bm.GetPixel(i, j);
            int num2 = Form1.keep_bits((int)pixel.R, 3);
            int num3 = Form1.keep_bits((int)pixel.G, 3) << 3;
            int num4 = Form1.keep_bits((int)pixel.B, 2) << 6;
            byte b = (byte)(num2 | num3 | num4);
            if (bytes.Length != 0)
            {
                b ^= bytes[num % bytes.Length];
            }
            array[num] = b;
            num++;
        }
    }
    return array;
}

The array with the decoded bytes is then resized to validSize_1 = 241152; if it is larger than that. Finally, the CRC32 checksum of the bytes is calculated and checked against validCrc32_1 = 2741486452U;.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void button1_Click(object sender, EventArgs e)
{
    // empty input check omitted
    byte[] array = Form1.decode(Resources.mb_logo_star, text);
    if (array.Length > Form1.validSize_1)
    {
        Array.Resize<byte>(ref array, Form1.validSize_1);
    }
    if (Crc32Algorithm.Compute(array) == Form1.validCrc32_1)
    {
        flag = true;
        try
        {
            if (Form1.g_serverProcess == null || Form1.g_serverProcess.HasExited)
            {
                File.WriteAllBytes(this.g_serverPath, array);
                flag = this.runProcess(this.g_serverPath);
            }
        }
        // error catching omitted
    }
}

If the checksum of the decoded bytes in array is correct, the bytes are written to g_serverPath which is Path.Combine(Path.GetTempPath(), "level2.exe"), i.e. %TEMP%/level2.exe.
runProcess is then called which will basically execute the decoded level2.exe with ProcessWindowStyle.Hidden.

Obtaining The Password

Since we know that the decoded level2.exe would eventually have to be a valid PE to be executed, and PEs usually contain many null bytes especially in the headers and towards the end, the bytes extracted from the mb_logo_star resource before any XOR decoding should actually contain snippets of the password in plaintext since (password byte) XOR (password byte) == (null byte).

While debugging MBCrackMe.exe in dnSpy, I set a breakpoint at the 1st input text length check and I clicked on the button to submit password 1 without actually entering anything. I stepped over the length check and continued running inside the Form1.decode function.

Hitting breakpoint at input length check in dnSpy and setting next statement to skip it

Since my input text is empty, the 2nd length check of if (bytes.Length != 0) inside decode would fail and the extracted bits from the mb_logo_star resource would not be XOR’d with anything.

dnSpy's Watch view shows that input length is zero so extracted byte will not be XOR'd

I continued running out of the decode function and until after the array has been resized. Inspecting the memory contents of array now, we see the plaintext password!

Memory contents of array in dnSpy shows plaintext password

At this point, we can also save the contents of array and XOR decode it manually in CyberChef to give us what would be level2.exe.

XORing extracted array with password in CyberChef gives a PE

Stage 1 password: easy_level_one_almost_done_xor_pe_and_keep_going!

Stage 2

button2_Click

Before analyzing level2.exe, let’s take a look at code for the 2nd button click back in the .NET executable.

Just like before, the input text is checked to not be empty and then written to the named pipe crackme_pipe. A response is read back from the pipe into array and checked to have a CRC32 checksum of validCrc32_2 = 499670621U. If this passes, LoadNext.Load(Form1.g_serverProcess, array) is called which will be for Stage 3.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private void button2_Click(object sender, EventArgs e)
{
    if (this.textBox2.Text.Length == 0)
    {
        MessageBox.Show("Enter the password!");
        return;
    }
    bool flag = false;
    string pipeName = "crackme_pipe";
    string text = this.textBox2.Text;
    byte[] array = null;
    try
    {
        NamedPipeClientStream namedPipeClientStream = new NamedPipeClientStream(".", pipeName);
        namedPipeClientStream.Connect(1000);
        StreamWriter streamWriter = new StreamWriter(namedPipeClientStream);
        TextReader textReader = new StreamReader(namedPipeClientStream);
        streamWriter.WriteLine(text);
        streamWriter.Flush();
        string s = textReader.ReadLine();
        array = Encoding.ASCII.GetBytes(s);
        if (Crc32Algorithm.Compute(array) == Form1.validCrc32_2)
        {
            flag = true;
        }
    }
    // error catching omitted
    if (flag)
    {
        // some display stuff omitted
        LoadNext.Load(Form1.g_serverProcess, array);
        return;
    }
}

level2.exe

Dynamic WinAPI Resolution

By adding the correct structure offsets in IDA, we can see that level2.exe resolves WinAPIs dynamically for use. This is achieved by first retrieving pointers to PEB and Ldr, and then walking through the forward pointers Flink of the doubly-linked InLoadOrderModuleList until it reaches the first entry which will be ntdll.dll because it is always loaded first in any process.

main function of level2.exe in IDA showing the traversal of the InLoadOrderModuleList

After obtaining the base address DllBase of ntdll.dll, it is passed into get_func_by_hash with a hash constant to retrieve the desired exported function address. This is done by iterating through the export directory table, hashing each export function name with a custom rotate and XOR routine, and then comparing the hash with the parameter passed in. If the correct function name has been found, the function pointer is returned.

get_func_by_hash function in IDA where the export directory is traversed

PE Implant Decompression

In the case of level2.exe, this dynamic resolution is used just twice to retrieve 2 functions: ntdll_RtlDecompressBuffer and NtAllocateVirtualMemory.

ntdll_RtlDecompressBuffer is used in decompress to LZ decompress a buffer of size 0x27259.

1
2
3
memset(uncompress_buf, 0, 0x4E4B2u);
if ( !decompress(&compress_buf, 0x27259, uncompress_buf, 0x4E4B2, &final_uncompressed_size) )
    return -3;

decompress function in IDA where ntdll_RtlDecompressBuffer is used with maximum LZ compression setting

The decompressed data, which is actually a PE, is then moved into a new buffer allocated with NtAllocateVirtualMemory. Finally, the PE is executed.

NtAllocateVirtualMemory used in main function to move decompressed data to a new region for execution

For ease of analysis, I dumped out this PE implant while debugging the level2.exe process with pe-sieve using virtual dump mode (/dmode 1).

PE Implant

The first function called in the implant is for anti-analysis.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
int __cdecl main(int argc, const char **argv, const char **envp)
{
  void *buf; // eax
  void *buf_; // edi
  int v6; // esi

  buf = operator new(1u);
  if ( buf )
    buf_ = (void *)anti_analysis(buf);
  else
    buf_ = 0;
  if ( !buf_ )
    return -1;
  v6 = main_stuff((int)"\\\\.\\pipe\\crackme_pipe", 1337);
  free_like(buf_);
  return v6;
}

Anti-Analysis

Interestingly, all the code and functions in this subroutine are very similar to the snippets listed at https://github.com/hasherezade/antianalysis_demos.

Array of 34 checksums being inserted into a set followed by anti-debug and anti-analysis functions in IDA

Even inside the find_denied_processes function, the use of CreateToolhelp32Snapshot, Process32First, tolower, and a checksum calculation that involves left rotations and XOR operations are almost the same as that in the GitHub repository.

Side-by-side comparison of find_denied_processes function in IDA and in GitHub shows striking similarity

Referencing the left rotate function in util.h used for checksum calculation, we can recreate the function used in level2.exe for process blacklisting as such:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def rol32(x, n):
    return (x << n) | (x >> (32-n))

def calc_checksum(input):
    checksum = 0xBADC0FFE
    input = input.lower()
    for c in input:
        checksum = rol32(checksum, 5)
        checksum = checksum & 0xffffffff
        checksum ^= ord(c)
    return checksum

Something to note is that IDA isn’t able to parse the C++ STL set container used for process_list, so I modified Rolf Rolles’ STL types identification IDAPython script to make the pseudocode more readable when methods like .find and .end are used.

Pseudocode for set .find and .end methods made more readable with custom structure in IDA

Main

The next function called in the PE implant is main_stuff with the named pipe crackme_pipe and a port number 1337 as arguments.

1
v6 = main_stuff((int)"\\\\.\\pipe\\crackme_pipe", 1337);

A mutex is created as well as a thread for each of the next levels.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
int __stdcall main_stuff(int pipestr, int port1337)
{
  HANDLE v3; // esi
  HANDLE v4; // ebx
  thread_params ThreadParams3; // [esp+0h] [ebp-10h] BYREF
  thread_params ThreadParams2; // [esp+8h] [ebp-8h] BYREF

  CreateMutexA(0, 1, "MB_Crackme_level2_mutex");
  if ( GetLastError() )
    return 1;
  ThreadParams2.comms_data = pipestr;
  ThreadParams2.check_func_addr = (int)level2_check;
  v3 = create_thread(&ThreadParams2);
  // cleanup code omitted for brevity
  ThreadParams3.comms_data = port1337;
  ThreadParams3.check_func_addr = (int)level3_check;
  v4 = create_thread_2(&ThreadParams3);
  // cleanup code omitted for brevity
}

Password-checking subroutines are passed as parameters to the threads in addition to the named pipe string and port number for level 2 and level 3 respectively. I made a small structure in IDA to track this more conveniently:

1
2
3
4
5
struct thread_params
{
  int comms_data;
  int check_func_addr;
};

Level 2 Thread

The created thread for level 2 creates and connects to the duplex named pipe crackme_pipe. The handle to the pipe and the level 2 password-check function address are then passed to another created child thread.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
DWORD __stdcall parent_thread(thread_params *lpThreadParameter)
{
    // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

    params = lpThreadParameter;
    if ( !lpThreadParameter )
        return 87;
    hPipe = CreateNamedPipeA(
            lpThreadParameter->comms_data,
            PIPE_ACCESS_DUPLEX,
            PIPE_TYPE_MESSAGE_|PIPE_READMODE_MESSAGE_,
            0xFFu,
            0x200u,
            0x200u,
            0,
            0);
    if ( hPipe == INVALID_HANDLE_VALUE )
        return GetLastError();
    while ( !ConnectNamedPipe(hPipe, 0) && GetLastError() != 535 )
    {
        CloseHandle(hPipe);
    LABEL_8:
        hPipe = CreateNamedPipeA(
                params->comms_data,
                PIPE_ACCESS_DUPLEX,
                PIPE_TYPE_MESSAGE_|PIPE_READMODE_MESSAGE_,
                0xFFu,
                0x200u,
                0x200u,
                0,
                0);
        if ( hPipe == INVALID_HANDLE_VALUE )
            return GetLastError();
    }
    Parameter.check_func_addr = params->check_func_addr;
    v6 = 1;
    Parameter.comms_data = hPipe;
    threadID = 0;
    v4 = CreateThread(0, 0, child_thread, &Parameter, 0, &threadID);
    // cleanup code omitted
}

This next child thread reads from the pipe and calls the password-check subroutine on the data received which would be what we entered in the original .NET form. The output of the checking subroutine is then written to the pipe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
DWORD __stdcall child_thread(thread_params *lpThreadParameter)
{
    // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]  
    if ( !lpThreadParameter )
        return 87;
    hPipe = lpThreadParameter->comms_data;
    memset(Buffer, 0, sizeof(Buffer));
    NumberOfBytesRead = 0;
    if ( ReadFile(hPipe, Buffer, 0x200u, &NumberOfBytesRead, 0) && NumberOfBytesRead )
    {
        // debug stuff omitted
        if ( lpThreadParameter->check_func_addr )
        {
            v13 = 0;
            v14 = 15;
            LOBYTE(input) = 0;
            strcpy_like(&input, Buffer, strlen(Buffer));
            v5 = (lpThreadParameter->check_func_addr)(buf, input);
            sub_22BD0(lpBuffer, v5);
            v23 = 0;
            stl_stuff(buf);
            v14 = 0;
            v13 = &NumberOfBytesWritten;
            v12 = nNumberOfBytesToWrite;
            response = lpBuffer;
            NumberOfBytesWritten = 0;
            if ( v21 >= 0x10 )
            response = lpBuffer[0];
            if ( !WriteFile(hPipe, response, v12, v13, v14) || nNumberOfBytesToWrite != NumberOfBytesWritten )
            {
                // cleanup code omitted
            }
        }
    }
    // cleanup code omitted
}

Level 2 Password Check

At the start of the password-checking subroutine, we can see 28 bytes of some sort of encoded/encrypted data being stored.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
encrypted[0] = 0xF158955A;
if ( _0xf >= 16 )
input_ = Block;
input_end = &input_[input_length];
encrypted[1] = 0xB5626D7C;
encrypted[2] = 0xD68AC6C2;
encrypted[3] = 0x10F6F220;
if ( _0xf >= 0x10 )
input_start = Block;
encrypted[4] = 0x4CEF8FD8;
encrypted[5] = 0x8B4663D6;
encrypted[6] = 0xA2BE0D1A;

Next, our input password appears to have leading and trailing whitespaces trimmed.
Then, its checksum is calculated according to the same algorithm used for the process blacklisting above in Anti-Analysis. Characters are converted to lowercase for the calculation of the checksum.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
if ( _0xf < 16 || (input = Block, (input_4 = Block) != 0) )
{
    checksum = 0xBADC0FFE;
    length = strlen(input);
    i = 0;
    if ( length )
    {
        do
        {
        c = tolower(input[i]);
        input = input_4;
        ++i;
        checksum = c ^ __ROL4__(checksum, 5);
        }
        while ( i < length );
        input_5 = Block;
    }
    v2 = buf_ptr_;
    v12 = _0xf; 
}

This checksum is checked to be found in the set of blacklisted process names created earlier.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
head = process_list._Myhead;
parent = process_list._Myhead->_Parent;
if ( parent->_IsNil )                         // .find != .end
    goto fail;
do
{
    if( parent->_Key >= checksum )
    {
        head = parent;
        parent = parent->_Left;
    }
    else
    {
        parent = parent->_Right;
    }
}
while ( !parent->_IsNil );
if ( head == process_list._Myhead || checksum < head->_Key )
    goto fail;
input_6 = &Block;
if ( v12 >= 0x10 )
    input_6 = input_5;
rc4_decrypt(input_6, input_length, encrypted, 0x1Du);

If the checksum is present in the set of blacklisted process name checksums, our input password (in its original casing) is used as the key to RC4-decrypt, in-place, the encrypted block of data we saw being stored at the start.

The RC4 encryption/decryption scheme can be identified by recognition of the key-scheduling algorithm (KSA) and the pseudo-random generation algorithm (PRGA) code structures.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
int __cdecl rc4_decrypt(int input_key, __int16 input_length, int encrypted, unsigned int out_length)
{
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

  s_ = s;
  ctr = 0;
  j__ = 0;
  zero = 0;
  i__ = 0;
  do                                            // KSA - initialization of identity permutation
    *s_++ = i__++;                              // S[i] = i
  while ( i__ < 256u );
  s__ = s;
  i = 256;
  do                                            // KSA - mixing of key bytes
  {
    element_i = *s__++;
    j__ += element_i + *(ctr + input_key);      // j = j + S[i] + key[i]
    element_j = &s[j__];
    *(s__ - 1) = *element_j;                    // swapping
    LOBYTE(tmp_) = ctr + 1;
    *element_j = element_i;                     // swapping
    ctr = 0;
    tmp_ = tmp_;
    if ( tmp_ != input_length )
      ctr = tmp_;
    --i;
  }
  while ( i );
  j_ = HIBYTE(zero);
  i_ = zero;
  if ( out_length )
  {
    do                                          // PRGA
    {
      s_i = &s[++i_];                           // i = i + 1
      s_j = *s_i;
      j_ += *s_i;                               // j += S[i]
      tmp = &s[j_];                             // swapping
      *s_i = *tmp;
      *tmp = s_j;
      tmp_ = s[(*s_i + s_j)];                   // keystream K = S[(S[i] + S[j])]
      *(i + encrypted) ^= tmp_;                 // keystream XOR ciphertext
      ++i;
    }
    while ( i < out_length );
  }
  return tmp_;
}

If the decrypted data passes the printable character checks below, it is sent back on the pipe for the .NET MBCrackme.exe process to read, check and display.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
rc4_decrypt(input_6, input_length, encrypted, 29u);
i_1 = 0;
while ( 1 )
{
    chr = *(encrypted + i_1); // encrypted is now decrypted
    if ( !chr )
        break;
    if ( (chr >= ' ' && chr != '\x7F' || chr == '\n' || chr == '\r') && ++i_1 < 29 )
        continue;
    goto fail;
}
if ( i_1 <= 5 )
{
fail:
    // return "Password Invalid!" and cleanup
}
else
{
    *(result + 16) = 0;
    *(result + 20) = 15;
    *result = 0;
    strcpy_like(result, encrypted, strlen(encrypted));
    stl_stuff(&Block);
}
return result;

Essentially, we first need to find out what are the blacklisted process names. The password we want will be one of those.

Cracking The Password

I initially wrote a multi-threaded Python bruteforcer to find out what process names mapped to the set of blacklisted checksums. While it did find what seemed to be legitimate process names like pin, windbg, and idaq, it was clearly too slow considering the possible lengths of process names. More problematically, it was also encountering checksum collisions - obviously nonsensical strings like adckbsa, xfdccr, and cllydbd would match the checksums. This approach was not the way.

10 out of 34 checksums found but with false positives due to checksum collisions

I switched strategies and decided on a dictionary attack instead. I exported executable filenames from various paths inside Mandiant’s/FireEye’s FlareVM with dir /a/s/b *.exe and modified my script to take in that list. That found a good number of process names but there were still quite a few left unknown.

More but still not all checksums found through the dictionary attack

The last idea I had in mind was to source for paths from online repositories collating malware evasion techniques like https://github.com/LordNoteworthy/al-khaser.

This turned out to be the best method as the final process blacklist was discovered to contain exactly the Al-Khaser AntiAnalysis process list in the same order.
It was only missing vmwaretray found in the Al-Khaser Anti-VM process list, pe-sieve and hollows_hunter found through the FlareVM dictionary attack method, and pin which was discovered through my initial bruteforce method.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
checksums[0] = 0xC81D63C9 # ollydbg
checksums[1] = 0x5B2839AC # processhacker
checksums[2] = 0x17DAD73F # tcpview
checksums[3] = 0x72C7241C # autoruns
checksums[4] = 0x58E483ED # autorunsc
checksums[5] = 0x82134662 # filemon
checksums[6] = 0x34204667 # procmon
checksums[7] = 0x4CD53A71 # regmon
checksums[8] = 0x34206499 # procexp
checksums[9] = 0xFFDEB191 # idaq
checksums[10] = 0x7AC6410B # idaq64
checksums[11] = 0xEA3503AA # immunitydebugger
checksums[12] = 0xCCFA2924 # wireshark
checksums[13] = 0x3A09FFBC # dumpcap
checksums[14] = 0x38EA0C1B # hookexplorer
checksums[15] = 0x58E479EC # importrec
checksums[16] = 0x1B964E1A # petools
checksums[17] = 0x707F9D9A # lordpe
checksums[18] = 0xF5A79701 # sysinspector
checksums[19] = 0x9F5473B # proc_analyzer
checksums[20] = 0xBA635AC6 # sysanalyzer
checksums[21] = 0xBB18A65 # sniff_hit
checksums[22] = 0x46119FD8 # windbg
checksums[23] = 0xFB7BF6AF # joeboxcontrol
checksums[24] = 0x3F75D54B # joeboxserver
checksums[25] = 0x49110E9F # resourcehacker
checksums[26] = 0x5D9F9FD8 # x32dbg
checksums[27] = 0x5DCC9FD8 # x64dbg
checksums[28] = 0x8293C33E # fiddler
checksums[29] = 0x5D112314 # httpdebugger
checksums[30] = 0x9D9F8189 # vmwaretray
checksums[31] = 0xC10AE786 # pe-sieve
checksums[32] = 0x67D8B725 # hollows_hunter
checksums[33] = 0x7FE9020 # pin

Decryption

I tried each of them (lowercase) as the key to RC4 decrypt the 28 bytes of encrypted data we saw earlier (5A 95 58 F1 7C 6D 62 B5 C2 C6 8A D6 20 F2 F6 10 D8 8F EF 4C D6 63 46 8B 1A 0D BE A2) but all gave gibberish. So I used their original casings referenced from the Al-Khaser repository and ProcessHacker worked to give me we_are_good_to_go_to_level3! as the decrypted message. Decryption in CyberChef.

Stage 2 password: ProcessHacker
Stage 2 decrypted message: we_are_good_to_go_to_level3!

Stage 3

Reflective DLL Loading

Upon completion of level 2, the .NET executable calls LoadNext.Load(Form1.g_serverProcess, array); which basically reflectively loads a DLL and then invoke its RunMe method. The DLL is stored at LoadNext.EncArr and is first Base64-decoded, AES-256-CBC-decrypted, and Gzip-decompressed. The AES decryption uses the SHA256 hash of the Stage 2 decrypted message as a password along with a salt {5,3,3,7,8,0,0,8} and an iteration count of 1000 to derive the key and IV.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public static int Load(Process process1, byte[] password)
{
    try
    {
        Type type = Assembly.Load(LoadNext.DecompressBytes(AES.decryptContent(Convert.FromBase64String(LoadNext.EncArr), password))).GetType("Level3Bin.Class1");
        object obj = Activator.CreateInstance(type);
        Type[] types = new Type[]
        {
            typeof(Process)
        };
        MethodInfo method = type.GetMethod("RunMe", types);
        object[] parameters = new object[]
        {
            process1
        };
        method.Invoke(obj, parameters);
    }
    // error catching omitted
}

While debugging the .NET process after entering the passwords for Stage 1 and Stage 2, I used pe-sieve with /data 1 once the DLL has been loaded. This option scans non-executable pages in .NET applications, and dumped out the DLL Level3Bin.dll successfully.

DLL Injection

Level3Bin.dll is also a .NET executable so into dnSpy it goes!

The RunMe method calls GetTempFileName which creates a random file name with extension .dat in the %TEMP% folder.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public static int RunMe(Process process1)
{
    try
    {
        string tempFileName = Class1.GetTempFileName("dat");
        if (Class1.DropTheDll(tempFileName))
        {
            DllInj.InjectToProcess(process1, tempFileName);
        }
    }
    // error catching omitted
}

DropTheDll Base64-decodes another DLL and writes it to the random file name created.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
private static bool DropTheDll(string fileName)
{
    string s = "<really long base64 string>";
    bool result = false;
    try

    {
        byte[] bytes = Convert.FromBase64String(s);
        File.WriteAllBytes(fileName, bytes);
        result = true;
    }
    // error catching omitted
}

InjectToProcess finally injects this dropped DLL into the original level2.exe process using the conventional VirtualAlloc+WriteProcessMemory+CreateRemoteThread method.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public static int InjectToProcess(Process targetProcess, string dllName)
{
    IntPtr hProcess = DllInj.OpenProcess(1082, false, targetProcess.Id);
    IntPtr procAddress = DllInj.GetProcAddress(DllInj.GetModuleHandle("kernel32.dll"), "LoadLibraryA");
    IntPtr intPtr = DllInj.VirtualAllocEx(hProcess, IntPtr.Zero, (uint)((dllName.Length + 1) * Marshal.SizeOf(typeof(char))), 12288U, 4U);
    UIntPtr uintPtr;
    DllInj.WriteProcessMemory(hProcess, intPtr, Encoding.Default.GetBytes(dllName), (uint)((dllName.Length + 1) * Marshal.SizeOf(typeof(char))), out uintPtr);
    DllInj.CreateRemoteThread(hProcess, IntPtr.Zero, 0U, procAddress, intPtr, 0U, IntPtr.Zero);
    return 0;
}

I Base64-decoded and saved the DLL out manually for further analysis in IDA. I had to retrieve the really long Base64 string through IDA since dnSpy would truncate it.

API Hooking

The DLL had the suspicious sections .detourc and .detourd.

Segments viewed in IDA shows .detourc and .detourd

DllMain also had the strings Hooking the process and Unhooking the process so I searched that up in GitHub and found them being used in this MS Detours API hooking sample project by hasherezade herself. The sample project also utilized an injected DLL. With this knowledge, I had a rough understanding of what the function calls in DllMain were for.

DllMain with renamed MS Detours function names as referenced from the GitHub repository

To find out what were the functions being patched, I ran level2.exe and then injected the DLL manually using dll_injector32. I ran pe-sieve again on the level2.exe running process and it detected hooks and helpfully dumped information into .tag files.

In crypt32.dll, CryptStringToBinaryA is hooked.

46d70;CryptStringToBinaryA->6c632990[6c630000+2990:injected.dll:0];5

In user32.dll, GetCursorPos is hooked.

27c00;GetCursorPos->6c632b10[6c630000+2b10:injected.dll:0];5

In KERNELBASE.dll, Sleep is hooked.

1249c0;Sleep->6c632b60[6c630000+2b60:injected.dll:0];5

PE implant

Let’s find where the hooked functions are called back in the code for level 3 in the PE implant.

Level 3 Thread

The created thread for level 3 creates a socket and binds to TCP 127.0.0.1:1337. It listens and accepts connections, passing recv’d data into the password-checking subroutine stored in the thread’s parameters.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
DWORD __stdcall sub_256F0(thread_params *lpThreadParameter)
{
    // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

    if ( !lpThreadParameter )
        return 87;
    port1337 = lpThreadParameter->comms_data;
    memset(&WSAData, 0, sizeof(WSAData));
    if ( WSAStartup(0x202u, &WSAData) )
        return 0;
    sd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if ( sd == INVALID_SOCKET )
    {
        MessageBoxA(0, "Creating the socket failed", "Stage 3", 0x30u);
        WSACleanup();
        return 0;
    }
    memset(recv_buf, 0, sizeof(recv_buf));
    v26 = 0;
    ip_addr = inet_addr("127.0.0.1");
    *name.sin_zero = 0i64;
    name.sin_family = AF_INET;
    name.sin_addr.S_un.S_addr = ip_addr;
    name.sin_port = htons(port1337);
    // debugging stuff omitted
    if ( bind(sd, &name, 16) == -1 || listen(sd, 5) == -1 )
    {
        MessageBoxA(0, "Binding the socket failed", "Stage 3", 0x30u);
    }
    else
    {
        cerr(dword_58070, "Listening...\n");
        for ( i = accept(sd, 0, 0); i != -1; i = accept(sd, 0, 0) )
        {
            cerr(dword_58070, "Accepted connection...\n");
            if ( recv(i, recv_buf, 512, 0) > 0 )
            {
                v19 = lpThreadParameter->check_func_addr == 0;
                v26 = 1;
                if ( v19 )
                    break;
                ip_addr = &recv_buf[1];
                v23 = 0;
                v24 = 15;
                LOBYTE(input) = 0;
                strcpy_like(&input, recv_buf, &recv_buf[strlen(recv_buf) + 1] - &recv_buf[1]);
                v20 = (lpThreadParameter->check_func_addr)(v28, input);
                // string stuff omitted
                send(i, v21, v23, v24);
                stl_stuff(buf);
            }
            closesocket(i);
        }
    }
    closesocket(sd);
    WSACleanup();
    return v26 != 0;
}

The original .NET executable will communicate with the socket upon button3_Click.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
private void button3_Click(object sender, EventArgs e)
{
    // length check omitted
    try
    {
        TcpClient tcpClient = new TcpClient("127.0.0.1", 1337);
        byte[] array = Encoding.ASCII.GetBytes(this.textBox3.Text);
        NetworkStream stream = tcpClient.GetStream();
        stream.Write(array, 0, array.Length);
        array = new byte[256];
        string text = string.Empty;
        int count = stream.Read(array, 0, array.Length);
        text = Encoding.ASCII.GetString(array, 0, count);
        if (text.Length > 10)
        {
            // display stuff omitted
        }
        MessageBox.Show(text);
    }
    // error catching omitted
}

Level 3 Password Check

At the start of the password-checking subroutine, we can see encoded/encrypted data once again being stored. This subroutine reuses the rc4_decrypt function we saw earlier for the level 2 password check so this data is probably RC4-encrypted as well.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
encrypted[21] = 0;
encrypted[0] = 0xA39BB17F;
encrypted[1] = 0x987AB8DB;
encrypted[2] = 0x2F6BE93E;
encrypted[3] = 0x5A40C4AC;
encrypted[4] = 0x5F900F42;
encrypted[5] = 0xAB9CF15C;
encrypted[6] = 0xF51B7932;
encrypted[7] = 0x6A3CA0C;
encrypted[8] = 0x4A4A45C4;
encrypted[9] = 0x21591DF6;
encrypted[10] = 0xC7F3DA41;
encrypted[11] = 0xA3EEEFBA;
encrypted[12] = 0x45820D2D;
encrypted[13] = 0x34D33517;
encrypted[14] = 0xD7C3DCCB;
encrypted[15] = 0xFA5E5BB3;
encrypted[16] = 0x69E23F67;
encrypted[17] = 0x5A4102EF;

CryptStringToBinaryA is then called on our input password for Base64-decoding.

1
2
3
4
5
6
7
8
len_pcbBinary = 255;
memset(buf_pbBinary, 0, 0xFFu);
b64String = &input;
if ( v27 >= 0x10 )
    b64String = input;
if ( !CryptStringToBinaryA(b64String, len_cchString, CRYPT_STRING_BASE64, buf_pbBinary, &len_pcbBinary, 0, &pdwFlags) )
    goto FAIL;
length = len_pcbBinary;

The hooked version in the injected DLL still calls the original CryptStringToBinaryA, but also initializes a variable which we’ll call sleep_var to 4.

1
2
3
4
5
6
success = orig1(a1, a2, a3, a4, a5, v22, v23);
init_4 = sleep_var;
result = success;
if ( success )
    init_4 = 4;
sleep_var = init_4;

The Base64-decoded input is then run through the following routine where the cursor’s coordinates are supposedly used to modify and check the input in a loop.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  if ( len_pcbBinary )
  {
    while ( GetCursorPos(&Point) )
    {
      y_and7 = Point.y & 7;
      byte = &buf_pbBinary[i_2 % length];
      byte_orig___ = *byte;
      byte_orig = *byte;
      byte_orig_ = *byte;
      byte_orig__ = *byte;
      if ( (i_2 & 1) != 0 )
      {
        modified_byte = (byte_orig_ << y_and7) | (byte_orig >> (8 - y_and7));
        v10 = byte_orig__ << (8 - y_and7);
        v11 = byte_orig___ >> y_and7;
      }
      else
      {
        modified_byte = (byte_orig_ >> y_and7) | (byte_orig << (8 - y_and7));
        v10 = byte_orig__ >> (8 - y_and7);
        v11 = byte_orig___ << y_and7;
      }
      if ( (v11 | v10) == LOBYTE(Point.x) )
        *byte = modified_byte;
      Sleep(30u);
      length = (DWORD)length_1;
      if ( ++i_2 >= (unsigned int)length_1 )
        goto DONE;
    }
    goto FAIL;

But GetCursorPos is hooked to retrieve bytes from two arrays using sleep_var as an index instead of just returning the actual cursor’s coordinates:

1
2
3
4
5
6
7
8
9
int __stdcall cursor_patch(tagPOINT *a1)
{
  unsigned int v1; // esi

  v1 = sleep_var;
  a1->y = (unsigned __int8)y_array[sleep_var & 0x1F];
  a1->x = (unsigned __int8)x_array[v1 % 0x21];
  return 1;
}

The value y is used to do some rotation operations on our Base64-decoded input which is then checked with x.
On each loop iteration, Sleep is called which is hooked to increment sleep_var.

1
2
3
4
void __stdcall sleep_patch(int a1)
{
  ++sleep_var;
}

If the check passes, the Base64-decoded string goes through a printable character check and is then used as the RC4-key for decryption of the 72 bytes of encrypted data stored at the start. The final RC4-decrypted string also goes through the same printable character check and will be sent back in a TCP response if successful.

Solution Script

I reimplemented everything the hooked functions were checking for in Python to solve for the password:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
y_array = [
  0x83, 0x1B, 0x89, 0x20, 0x37, 0x8B, 0x57, 0xC6, 0x78, 0x74, 
  0x00, 0xC4, 0x48, 0x83, 0xDB, 0x7C, 0x48, 0x49, 0x8B, 0x48, 
  0xF8, 0x49, 0xFF, 0x24, 0x74, 0x93, 0x53, 0x03, 0x4A, 0x03, 
  0xC0, 0x48
]

x_array = [
  0x95, 0xB9, 0x63, 0x59, 0xDC, 0xB5, 0x58, 0xC6, 0x6C, 0x5F, 
  0x68, 0x6F, 0x6F, 0xAD, 0xDC, 0x5F, 0x6D, 0x58, 0xDA, 0x65, 
  0x5F, 0x58, 0xD7, 0x62, 0x69, 0x9D, 0xD7, 0x91, 0x96, 0x99, 
  0x66, 0x65, 0x9C, 0x00, 0x00, 0x00
]

decoded = [None] * 200

sleep_ctr = 4

def get_cursor():
    return (x_array[sleep_ctr % 0x21], y_array[sleep_ctr & 0x1f])

def sleep():
    global sleep_ctr
    sleep_ctr += 1

def rol(x, n, bits):
    return (x << n) | (x >> (bits-n))

def ror(x, n, bits):
    return (x >> n ) | (x << (bits-n))

def solve():
    for i in range(200):
        x, y = get_cursor()
        y = y & 7
        if i % 2 != 0:
            # rotate left by y twice
            tmp = rol(x, y, 8) & 0xff
            b = rol(tmp, y, 8) & 0xff
        else:
            # rotate right by y twice
            tmp = ror(x, y, 8) & 0xff
            b = ror(tmp, y, 8) & 0xff
        decoded[i] = b
        sleep()
    print("".join([chr(b) for b in decoded]))

solve()

which output something like this:

Output of Python solution script shows password with non-printable characters trailing behind

which means the Base64-decoded password is small_hooks_make_a_big_difference.

This password is then used to RC4-decrypt 7fb19ba3dbb87a983ee96b2facc4405a420f905f5cf19cab32791bf50ccaa306c4454a4af61d592141daf3c7baefeea32d0d82451735d334cbdcc3d7b35b5efa673fe269ef02415a which gives flag{you_got_this_best_of_luck_in_reversing_and_beware_of_red_herrings}. Decryption in CyberChef.

Stage 3 password: c21hbGxfaG9va3NfbWFrZV9hX2JpZ19kaWZmZXJlbmNl
Stage 3 decoded password: small_hooks_make_a_big_difference
Stage 3 decrypted flag: flag{you_got_this_best_of_luck_in_reversing_and_beware_of_red_herrings}


Final flag displayed in .NET GUI after entering the 3 correct passwords

Thanks for reading!

Share on

rainbowpigeon
WRITTEN BY
rainbowpigeon
OSCP | OSED | Burp Suite Certified Practitioner