This page looks best with JavaScript enabled

SEETF 2022 Official Author Writeup - Susware

A Reversing challenge made together with DraftDown Labs!

 ·  โ˜• 13 min read  ·  ๐ŸŒˆ๐Ÿ•Š๏ธ rainbowpigeon

This SEETF 2022 Reversing challenge only had 1 solve by AuroraDawn’s gigx. no rev/pwn no life (r4kapig)’s crazyman was really close though!

Details Links
CTFtime.org Event Page https://ctftime.org/event/1543
Official SEETF Discord https://discord.gg/JNVzKc7PJR
Official Event Landing Page https://seetf.sg/seetf/
Publicly-released Challenges https://github.com/Social-Engineering-Experts/SEETF-2022-Public

Preface

This challenge is designed so that the full solving process provides you with a realistic approximation of completing an actual malware analysis task. Common malware techniques like anti-debugging, string obfuscation, encryption, compression, packing, and process injection are used.

Challenge Description

Author: rainbowpigeon, DraftDown Labs

It has long been rumored that many threat actors were after zeyu2001’s personal stockpile of the latest web 0-days. Alas, tragedy struck when he joined too many suspicious Telegram groups without due caution and ended up getting infected by suspicious malware.

He quickly put his Cyber Olympianโ„ข skills to use and managed to retrieve the offending binary as well as capture the network traffic sent out by it. However, he’s too busy getting more CVEs and HackerOne bounties to conduct any further analysis. Can you help him decipher the malware’s traffic?

The password for the ZIP file is infected. While the binary will not harm your system, I suggest analyzing everything in a Virtual Machine with antiviruses switched off.

This challenge was made in collaboration with DraftDown Labs!

Challenge Files

https://github.com/Social-Engineering-Experts/SEETF-2022-Public/tree/main/reversing/susware

Solution

Main Executable

TLS Callbacks

Initial triage of susware.exe in tools like pestudio will highlight that there are multiple TLS callbacks. These callbacks also show up in Exports in IDA Pro so they aren’t hard to miss.

5 TLS callbacks detected by IDA Pro and pestudio

We should inspect these callbacks first as they will actually be executed before WinMain. Cursory analysis of the WinMain code should reveal to you that the code in WinMain is just a red herring as it’s impossible to guess any flag generated by it.

The last TLS callback will AES-CBC-192 decrypt resource 139 with key 0xakm_0xpwn_0xDr4ftD0wn. and IV 02bfb0a6c9296f0f provided that anti-debug checks are passed. It’s not necessary to identify the encryption algorithm yourself because you can just dynamically debug the executable, change EIP to bypass the anti-debug checks, and dump out the decrypted resource from memory. But if you do identify the encryption algorithm (either manually or with the help of IDA plugins like capa explorer or findcrypt-yara) then you could also dump out the resource using Resource Hacker or CFF Explorer and then decrypt it in CyberChef.

The MZ header of the decrypted resource should tell you that it is a PE. This PE is injected into C:\\Windows\\SysWOW64\\svchost.exe for execution via the RunPE technique.

Injected Executable

Unpacking

The injected PE is packed with UPX but modified to bypass being detected as UPX by tools like Exeinfo PE, Detect It Easy, Nauz File Detector, and the standard UPX utility itself.
Exeinfo PE will only show “Unknown Packer-Protector , 4 sections”, Nauz File Detector suggests a “Protector” is at play because of high section entropy, while upx -d will give the error “NotPackedException: not packed by UPX”.

Apart from high entropy in the section of the entry point which can be shown by multiple tools and not just Nauz File Detector, there are other telltale signs that there is possibly some type of packing involved:

  • small amount of and suspicious imported functions that the PE uses (VirtualProtect, LoadLibraryA, GetProcAddress)
  • IDA Pro will tell you that the imports segment seems to be destroyed
  • the PE section named “.text” has a raw size of 0 bytes but is writable and has a virtual size of 0x7000 bytes

So what do we do? It is feasible to just analyze it entirely in memory, but there are a few ways to unpack it for easier analysis.

  • Tools like XVolkolak, Scylla, pe-sieve can be used to dump out the unpacked executable. Some may then further require manual fixing of the entry point location and disabling of the PE’s “DLL can move” flag so that it can be dynamically run.
  • Manual unpacking is also possible by breakpointing at the only popad call, stepping to the original entry point (OEP) and then dumping out memory segments.
  • I think one could also modify UPX’s source to remove any packing detection checks so that it can unpack the PE.
  • If you’re really familiar with UPX, it’s possible to identify the modifications I made to the PE and reverse those changes so that the standard UPX can unpack it just fine

Encryption

RC4 KSA and PRGA routines can be easily spotted in the IDA Pro pseudocode - once before the send and once more after the recv. Hardcoded key is FtkvWPDfIdKGWvP5788D4kNeO6FMCXsO. There are, however, a few modifications that the usual standard RC4 doesn’t have:

  • RC4-drop[n] is used meaning n initial keystream bytes are discarded. n is 3328 for the client-to-server traffic and 3840 for the server-to-client traffic
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
v30 = 0;
v31 = 3328;
LOBYTE(v32) = 0;
do
{
    v33 = &v66[(unsigned __int8)++v30];
    v34 = *v33;
    v54 = *v33 + (_BYTE)v32;
    v32 = &v66[v54];
    *v33 = *v32;
    *v32 = v34;
    LOBYTE(v32) = v54;
    --v31;
}
while ( v31 );
1
2
  v13 = v9 ^ *(_BYTE *)((unsigned __int8)(v20 + *v18) + a5);
  *a1 = v13;
  • Extra bitwise negation just before the plaintext is XOR’d with the keystream: result = v13 ^ ~(a6 + a1[v14]);
  • Extra parameter a6 in actual RC4 encryption/decryption function will offset either the plaintext or resulting ciphertext depending on the sign of a6. If a6 is 2 for encryption, a6 needs to be -2 for decryption.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
if ( a6 >= 0 )
{
      ...
      result = v13 ^ ~(a6 + a1[v14]);
      *a1 = result;
      ...
}
else
{
      ...
      result = a6 + (v11 ^ ~a1[v14]);
      *a1 = result;
      ...
}

Compression

A common problem malware analysts face is identifying compression formats used or even identifying that compression is used in the first place.

If you have already successfully decrypted the traffic in the PCAP or you were dynamically inspecting buffers in the PE before encryption takes place, you would notice that you’re able to see plaintext but it is still slightly garbled. This is an indication that there’s possibly compression used.

A closer look at the pseudocode should reveal to you that the supposed compression could be occurring at sub_402C00 which happens after ReadFile but before the encryption and send.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
        ReadFile(hNamedPipe, v8, 0xFFFFu, v9, 0);
        v68[0] = 0;
        len = 0;
        v70 = 15;
        v73 = 0;
        sub_402C00(v8, *v9, v68);
        v10 = (const char *)unknown_libname_1(len + 1);
        v10[len] = 0;
        for ( i = 0; i < 256; ++i )
          v72[i] = i;
        v12 = 0;

Through dynamic analysis, you should be able to narrow down that the actual compression occurs at a function inside sub_402C00 that looks like this:

 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
59
60
61
62
63
char *__cdecl sub_402CE0(int a1, unsigned int a2, char *a3, int a4, __int16 a5)
{
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

  v5 = -1;
  v6 = (unsigned __int8 *)a1;
  v89 = a5 - 1;
  v91 = a1;
  v7 = (unsigned __int8 *)(a1 + a2);
  v92 = (unsigned __int8 *)(a1 + a2);
  if ( a2 < 0xF )
  {
    v8 = a3;
    v67 = (unsigned __int8 *)a1;
  }
  else
  {
    v90 = (unsigned int)(v7 - 15);
    v8 = a3;
    v82 = *(unsigned __int8 *)(a1 + 1) | ((*(unsigned __int8 *)(a1 + 2) | ((*(unsigned __int8 *)(a1 + 3) | (*(unsigned __int8 *)(a1 + 4) << 8)) << 8)) << 8);
    v83 = a1;
LABEL_3:
    v101 = v6;
    a1 = (int)(v6 + 1);
    v9 = *(unsigned __int16 *)(v6 + 7);
    v10 = __PAIR64__((unsigned __int64)v6[8] >> 24, v9) >> 24;
    v11 = *(unsigned __int8 *)(a1 + 5) | (v9 << 8);
    v12 = __PAIR64__(v10, v11) >> 24;
    v13 = *(unsigned __int8 *)(a1 + 4) | (v11 << 8);
    v14 = __PAIR64__(v12, v13) >> 24;
    v15 = *(unsigned __int8 *)(a1 + 3) | (v13 << 8);
    v16 = __PAIR64__(v14, v15) >> 24;
    v17 = *(unsigned __int8 *)(a1 + 2) | (v15 << 8);
    v18 = __PAIR64__(v16, v17) >> 24;
    v19 = *(unsigned __int8 *)(a1 + 1) | (v17 << 8);
    v20 = __PAIR64__(v18, v19) >> 24;
    v21 = 32;
    v22 = (unsigned __int8 *)a1;
    v23 = *(unsigned __int8 *)a1 | (v19 << 8);
    v94 = v20;
    v24 = -a1;
    v100 = v23;
    if ( (int)(v90 - a1) >= 16 )
    {
      v86 = 0;
      v25 = (unsigned __int8 *)(a1 + 10);
      v84 = (unsigned __int16 *)(a1 + 10);
      do
      {
        v88 = 0;
        v26 = (int)&v25[v24 - 10];
        v95 = v26;
        do
        {
          v27 = v82;
          if ( v26 )
            v27 = v23;
          v28 = *(_DWORD *)&v89 & ((0x1E35A7BD * v27) >> 18);
          v29 = (unsigned __int8 *)(v91 + *(unsigned __int16 *)(a4 + 2 * v28));
          ...
      ...
    ...
  ...

It is also not hard to find this function statically through sheer exploration because there aren’t too many nested function calls.

Ok so now that we know where the compression occurs, how do we identify the compression format? One common method is to look for magic constants in the code and search them up.

In the code this interesting 0x1E35A7BD is used a few times. If you Google or Github search it, results will show that it is associated with Google’s Snappy compression format. Technically the constant is also used in Google’s WebP and Brotli compression formats, but we can rule WebP out because that’s for image compression and we can exclude Brotli by doing a quick comparison of the compression code on GitHub with the IDA Pro pseudocode.

Below is a snippet of snappy.cc at https://github.com/google/snappy/blob/main/snappy.cc where the magic constant is used for compression:

 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
inline uint32_t HashBytes(uint32_t bytes, uint32_t mask) {
  constexpr uint32_t kMagic = 0x1e35a7bd;
  return ((kMagic * bytes) >> (32 - kMaxHashTableBits)) & mask;
}

char* CompressFragment(const char* input, size_t input_size, char* op,
                       uint16_t* table, const int table_size) {
  // "ip" is the input pointer, and "op" is the output pointer.
  const char* ip = input;
  assert(input_size <= kBlockSize);
  assert((table_size & (table_size - 1)) == 0);  // table must be power of two
  const uint32_t mask = table_size - 1;
  const char* ip_end = input + input_size;
  const char* base_ip = ip;

  const size_t kInputMarginBytes = 15;
  if (SNAPPY_PREDICT_TRUE(input_size >= kInputMarginBytes)) {
    const char* ip_limit = input + input_size - kInputMarginBytes;

    for (uint32_t preload = LittleEndian::Load32(ip + 1);;) {
      // Bytes in [next_emit, ip) will be emitted as literal bytes.  Or
      // [next_emit, ip_end) after the main loop.
      const char* next_emit = ip++;
      uint64_t data = LittleEndian::Load64(ip);


      uint32_t skip = 32;

      const char* candidate;
      if (ip_limit - ip >= 16) {
        auto delta = ip - base_ip;
        for (int j = 0; j < 4; ++j) {
          for (int k = 0; k < 4; ++k) {
            int i = 4 * j + k;
            // These for-loops are meant to be unrolled. So we can freely
            // special case the first iteration to use the value already
            // loaded in preload.
            uint32_t dword = i == 0 ? preload : static_cast<uint32_t>(data);
            assert(dword == LittleEndian::Load32(ip + i));
            uint32_t hash = HashBytes(dword, mask);
            ...
          }
          ...
        }
        ...
      }
      ...
    }
    ...
  }
  ...
}
...

We can then use https://github.com/andrix/python-snappy or https://pypi.org/project/cramjam/ to make a Python Snappy decompressor script!

Final Traffic Parsing Script

  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
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# Script modified from https://github.com/manojpandey/rc4/blob/master/rc4-3.py by rainbowpigeon


import sys
import cramjam
import pyshark
from binascii import unhexlify, hexlify

MOD = 256


def bit_not(n, numbits=8):
    return (1 << numbits) - 1 - n


def KSA(key):
    key_length = len(key)
    S = list(range(MOD))
    j = 0
    for i in range(MOD):
        j = (j + S[i] + key[i % key_length]) % MOD
        S[i], S[j] = S[j], S[i]

    return S


def PRGA(S):
    i = 0
    j = 0
    while True:
        i = (i + 1) % MOD
        j = (j + S[i]) % MOD

        S[i], S[j] = S[j], S[i]
        K = S[(S[i] + S[j]) % MOD]
        K = K ^ j  # MODIFICATION: extra XOR
        yield K


def get_keystream(key, drop):
    """
    Takes the encryption key to get the keystream using PRGA
    return object is a generator
    """
    S = KSA(key)
    i = 0
    j = 0

    # MODIFICATION: drop n
    for _ in range(drop):
        i = (i + 1) % MOD
        j = (j + S[i]) % MOD
        S[i], S[j] = S[j], S[i]

    return PRGA(S)


def encrypt_logic(key, input, shift, drop):
    """
    :key -> encryption key used for encrypting, as hex string
    :input -> array of unicode values/ byte string to encrpyt/decrypt
    """
    key = [ord(c) for c in key]

    keystream = get_keystream(key, drop)

    result = ""
    for c in input:
        # MODIFICATION: shift, bitwise negation
        if shift >= 0:
            val = "%02X" % (bit_not((c + shift) & 0xFF) ^ next(keystream) & 0xFF)
        elif shift:
            val = "%02X" % (((bit_not(c) ^ next(keystream)) + shift) & 0xFF)
        result += val
    return result


def decrypt(key, ciphertext, shift, drop):
    """
    :key -> encryption key used for encrypting, as hex string
    :ciphertext -> hex encoded ciphered text using RC4
    """
    ciphertext = unhexlify(ciphertext)
    result = encrypt_logic(key, ciphertext, shift, drop)
    if not len(result) % 2:
        return unhexlify(result)
    else:
        return unhexlify("0" + result)


def main():
    key = "FtkvWPDfIdKGWvP5788D4kNeO6FMCXsO"

    if len(sys.argv) != 2:
        print("Specify pcap filename as argument")
        exit(1)

    filename = sys.argv[1]
    needs_decompressing = False

    with pyshark.FileCapture(filename) as cap:
        for packet in cap:
            try:
                data = packet.data.data
            except AttributeError:
                # TCP packets like SYN, SYN ACK, ACK won't carry any data
                pass
            else:
                # if server to client
                if packet[packet.transport_layer].srcport == "1337":
                    decrypted = decrypt(key, data, -3, 3840)
                # if client to server
                else:
                    decrypted = decrypt(key, data, -2, 3328)
                    needs_decompressing = True

                if decrypted:
                    if needs_decompressing:
                        try:
                            decompressed = cramjam.snappy.decompress_raw(decrypted)
                        except Exception as e:
                            print("decrypted:", hexlify(decrypted))
                            print(e)
                        else:
                            print("decompressed: ", bytes(decompressed).decode())
                    else:
                        print("decrypted:", decrypted.decode())
                print("_____________________________")
                needs_decompressing = False

    print("Done")


if __name__ == "__main__":
    main()

Output snippet:

 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
C:\Downloads>py traffic_decrypter.py traffic_capture.pcapng
decompressed:  Microsoft Windows [Version 10.0.19044.1288]
(c) Microsoft Corporation. All rights reserved.

FLARE Sun 05/15/2022 14:01:44.72
C:\Users\a\AppData\Local\Temp>
_____________________________
#...
# omitted for brevity
#...
_____________________________
decrypted: cd flag
_____________________________
decompressed:  cd flag

FLARE Sun 05/15/2022 14:02:14.14
C:\flag>
_____________________________
decrypted: dir
_____________________________
decompressed:  dir
 Volume in drive C has no label.
 Volume Serial Number is 7EAB-E766

 Directory of C:\flag

05/12/2022  11:30 PM    <DIR>          .
05/12/2022  11:30 PM    <DIR>          ..
05/08/2022  10:18 AM               224 flag.txt
               1 File(s)            224 bytes
               2 Dir(s)  24,372,260,864 bytes free

FLARE Sun 05/15/2022 14:02:18.77
C:\flag>
_____________________________
decrypted: type flag.txt
_____________________________
decompressed:  type flag.txt
01010011010001010100010101111011010011010011010001101100011101110011010001110010001100110010110100110100011100100011001100101101001000010110111001110100001100110111001000110011011100110111010000110001011011100110011101111101
FLARE Sun 05/15/2022 14:02:28.99
C:\flag>
_____________________________
decrypted: ipconfig /all
_____________________________
#...
# omitted for brevity
#...
_____________________________
decrypted: exit
_____________________________
Done

Decoding 01010011010001010100010101111011010011010011010001101100011101110011010001110010001100110010110100110100011100100011001100101101001000010110111001110100001100110111001000110011011100110111010000110001011011100110011101111101 gives you SEE{M4lw4r3-4r3-!nt3r3st1ng}!

Flag: SEE{M4lw4r3-4r3-!nt3r3st1ng}


Thanks for reading! I hope you were able to learn something from this challenge or this writeup :)

Share on

rainbowpigeon
WRITTEN BY
rainbowpigeon
OSCP | Burp Suite Certified Practitioner