The Compromise (300 pts)

The SOC team of the BrighSoul QPL (Quantum Physic Labs) is continuously monitoring HTTP proxy and DNS outbound traffic and has identified suspicious DNS traffic to the server authoritative (NS) for the domain thedarkestside.org.

Upon investigation, they presume that an internal windows workstation with has been compromised with a Colbalt Strike beacon running as the executable named ntupdate.exe. The workstation belongs to the R&D team and they are suspicions that files containing critical Intellectual Property information have been exfiltrated.

You are a member of the CSIRT team and your objective is to identify which data has been leaked. You receive the following information:

  • A pcap file of the DNS traffic that transitted through the internal DNS server during the estimated timespan of the attack.
  • A bin file with the memory dump of the ntupdate.exe process (procdump –ma ntupdate.exe) on the victim workstation.

As the challenge’s description says, we are given a pcap file, along with a memory dump. A first looks at the pcap file show a lot of traffic, and instead of going blindly, let’s have a look on the dump file instead.

The dump file is a classic Minidump file, that opens nicely in WinDBG (I used WinDBG preview because it has a lot nicer interface). We can see there are two threads launched, both sleeping. So we have to analyze the backtrace of the threads to have a clue on what’s going on.

The first thread seems to call SleepEx from ntupdate.exe directly, which can be legitimate. But, in the second thread, SleepEx is called from 0x0018ef06 which seem more suspicious. To understand what happens, let’s dump ntupdate.exe (with .writemem z:\ntupdate.mem 400000 L?004e000 WinDBG command, module size is 0x4e000 according to windbg’s module view), and “unmap” the PE with this, yeah I was lazy to code my own tool.

After opening the “reconstructed” PE in IDA, the first thing we notice is the %c%c%c%c%c%c%c%c%cMSSE-%d-server, which indicates it seems to be a Cobalt Stike stager (and code really looks like this https://blog.nviso.eu/2021/04/26/anatomy-of-cobalt-strike-dll-stagers/). Good luck, the binary embeds the payload, which can be decrypted using this IDAPython script (don’t judge my high quality variable naming):

sheep = []
key = b"\x13\x4a\x5b\x22"
for i in range(0x40000):
    sheep.append(idaapi.get_byte(0x404008 + i) ^ key[i % 4])

f = open("/tmp/payload.dump", "wb")
f.write(bytes(sheep))
f.close()

We get some garbage “headers”, followed by a classic MZ header: after removing the garbage, we get a PE file that loads nicely in IDA, and we are lucky because it’s that PE which is mapped in 0x180000 (the mysterious code in the second thread). So we can see that the sleep is called in a function that does DNS resolving.

A few moments later, after having analyzed a part of the implant, I figured that it contacted its C&C with DNS, and had several “handlers” for it:

  • if the requested name starts by “www.”, the malware sends RSA-encrypted “fingerprint” of the machine to the C&C, along with an AES sesion key to talk to the server
  • if the requested name start directly by a random hex number, the bot queries orders from the C&C. The response is in a A DNS record and xored with “8.8.4.4” IP in big endian
  • If the requested name starts by “api.”, the client first sends a A request to get the payload size (always xored by 8.8.4.4), and then issues TXT queries, and server replies with base64 data
  • If the request starts by “cdn.”, then the first A request contains payload size, and further A requests the payload data xored by 8.8.4.4 (big endian DWORD)
  • There is also “www6.” handler, which seem to be the same with cdn. but with AAAA records (IPv6 addresses are longer, so more data can be sent as response)
    if ( (unsigned int)QueryCnCOrder(fetchOrderDns, &ip_obf) )
      {
        v15 = v12 ^ ntohl(ip_obf);
        ip_obf = v15;
        if ( v15 && (v15 & 0xFFFFFFF0) == 240 )
        {
          sub_18000EF14(v15);
          v16 = ip_obf;
          if ( (ip_obf & 1) != 0 )
          {
            exfiltrateData(v13, a1, (__int64)&encryptedFingerprint, encryptedFingerprintSize);
            v16 = ip_obf;
          }
          if ( (v16 & 2) != 0 )
          {
            cncLen = get_encrypted_int(4u);
            v18 = sendTxtRequest(a1, (char *)bufferFromServer, cncLen);
          }
          else if ( (v16 & 4) != 0 )
          {
            www6Len = get_encrypted_int(4u);
            v18 = sendWww6Typ(a1, bufferFromServer, www6Len);
          }
          else
          {
            cdnLen = get_encrypted_int(4u);
            v18 = sendCdnRequest(a1, bufferFromServer, cdnLen);
          }
          if ( v18 > 0 )
          {
            v21 = decryptResponse(bufferFromServer, v18);
            if ( v21 > 0 )
              doStuffWithResponse(bufferFromServer, (unsigned int)v21);
          }
        }

Unfortunately, this data is encrypted by the AES session key, and since we only have the last part of the “fingerprint”, we’ll need to figure out where the AES key is generated/stored, and extract it from the dump with WinDBG. Before the “main” loop of DNS queries, some initialization function are quite interesting:

void *__fastcall GrabInfo(GetInfoStruct *Src, unsigned int len)
{
  DWORD CurrentProcessId; // ebx
  DWORD TickCount; // eax
  char someflags; // bl
  HANDLE CurrentProcess; // rax
  DWORD v8; // eax
  unsigned int v9; // edi
  __int64 pubKey; // rax
  block_iterator a1; // [rsp+30h] [rbp-20h] BYREF
  BYTE rand1[16]; // [rsp+40h] [rbp-10h] BYREF
  __int16 a2; // [rsp+90h] [rbp+40h] BYREF
  __int16 OEMCP; // [rsp+98h] [rbp+48h] BYREF

  a2 = GetACP();
  OEMCP = GetOEMCP();
  randomGenerator(rand1, 0x10u, 0i64);
  aesCreateContext((__int64)rand1);
  CurrentProcessId = GetCurrentProcessId();
  TickCount = GetTickCount();

  /* some snipped useless stuff */

  /* construct the blob to be sent to the C&C */
  make_block_iterator(&a1, Src, len);
  packMem(&a1, rand1, 16);
  packMem(&a1, &a2, 2);
  packMem(&a1, &OEMCP, 2);
  pack_int_bigendian(&a1, sessionRandIdentifier);
  v8 = GetCurrentProcessId();
  pack_int_bigendian(&a1, v8);
  pack_short_bigendian(&a1, 0);
  pack_char(&a1, someflags);
  sub_180014750(&a1);
  v9 = set_buf(&a1);
  memset(&encryptedFingerprint, 0, 0x400ui64);
  encryptedFingerprintSize = 128;
  memmove(&encryptedFingerprint, Src, v9);

  /* decode pubkey and encrypt blob */
  pubKey = cryptoshitFunc(7i64);
  rsa_encrypt(pubKey, (int)Src, v9, (int)&encryptedFingerprint, (__int64)&encryptedFingerprintSize);
  /* wipe cleartext blob */
  return memset(Src, 0, v9);
}

We can see that a random 16-byte buffer is filled, which is then passed to a “aesCreateContext” function which does this:

__int64 __fastcall aesCreateContext(__int64 a1)
{
  __int64 result; // rax
  __int128 hash[2]; // [rsp+30h] [rbp-28h] BYREF
  unsigned int hashlen; // [rsp+68h] [rbp+10h] BYREF

  hashlen = 32;
  addHashTable(&sha256_crypt_callback);
  hashMethodIdx = lookup_hash_func("sha256");
  if ( (unsigned int)call_hash_method(hashMethodIdx, a1, 0x10u, hash, &hashlen) )
    exit(1);
  aes_key = hash[0];
  hmac_key = hash[1];
  iv = *(_OWORD *)"abcdefghijklmnop";
  constructCryptoTable(&aes_crypt_callback);
  cryptoMethodIdx = lookup_crypto_func("aes");
  result = aes_create_context((unsigned int *)&aes_key, 16, 0, aes_ctx);
  if ( (_DWORD)result )
    exit(1);
  return result;
}

While the initial seed is forever lost because stored as local var, the AES key and HMAC keys derived from SHA256 hash of the “initial seed” are stored in the .data section of the binary, making them “easy” to extract from the dump (just convert imagebase from 0x180000000 to 0x180000).

Now come the final part of the challenge, parsing the pcap and reconstructing and decrypting the traffic between the C&C and the bot. For this, I made this quick and dirty Python script using pcapy and ImPacket:

#!/usr/bin/python

import base64
import struct
import pcapy
import impacket.ImpactDecoder as Decoders
import impacket.ImpactPacket as Packets
from impacket.dns import DNS
from Crypto.Cipher import AES

pattern = 0x08080404
key = b"U\n\xe2\x988\xb3\xdc(X\rl\x0f\xf1\x96\xde\xb2\x0e3\xaf[\xf1\x9f\xe0\x16\x1e\x0b\xa9x\x00fp\xe8"

pcap = pcapy.open_offline("the_compromise/traffic.pcap")
CLIENT_IP = '192.168.111.6'

def decrypt(data):
    aeskey = key[0:16]
    iv = b"abcdefghijklmnop"
    aes = AES.new(aeskey, AES.MODE_CBC, iv)
    return aes.decrypt(data[:-16])

def get_int_raw_resp(dns_resp: DNS):
    data = dns_resp.get_body_as_string()
    
    offset   = 0
    qdcount = dns_resp.get_qdcount()
    for _ in range(qdcount): # number of questions
        offset, qname = dns_resp.parseCompressedMessage(data, offset)
        qtype  = data[offset:offset+2]
        offset  += 2
        qclass = data[offset:offset+2]
        offset  += 2
        qtype  = struct.unpack("!H", qtype)[0]
        qclass = struct.unpack("!H", qclass)[0]

    offset, _ = dns_resp.parseCompressedMessage(data, offset)
    qtype  = data[offset:offset+2]
    qtype  = struct.unpack("!H", qtype)[0]
    offset  += 2

    qclass = data[offset:offset+2]
    qclass = struct.unpack("!H", qclass)[0]
    offset  += 2

    qttl_raw = data[offset:offset+4]
    qttl = struct.unpack("!L", qttl_raw)[0]
    offset  += 4

    qrdlength = data[offset:offset+2]
    qrdlength = struct.unpack("!H", qrdlength)[0]
    offset  += 2

    return qtype, data[offset:offset+qrdlength]

def get_next_dns_pkt():
    ethernet_decoder = Decoders.EthDecoder()
    ip_decoder = Decoders.IPDecoder()

    hdr = True
    packets = 0

    while hdr is not None:
        hdr, body = pcap.next()
        eth = ethernet_decoder.decode(body)
        if eth.get_ether_type() != Packets.IP.ethertype:
            continue
        ip = ip_decoder.decode(eth.get_data_as_string())
        if CLIENT_IP not in [ip.get_ip_src(), ip.get_ip_dst()]:
            continue
        if ip.get_ip_p() != Packets.UDP.protocol:
            continue
        udp = ip_decoder.udp_decoder.decode(ip.get_data_as_string())
        if udp.get_uh_dport() != 53 and udp.get_uh_sport() != 53:
            continue
        if b"thedarkestside\x03org" not in body:
            continue
        dns = DNS(udp.get_data_as_string())
        if (dns.get_flags() & 0x8000) == 0:
            continue
        packets += 1
        if packets < 2:
            continue
        yield (ip.get_ip_src(), ip.get_ip_dst(), dns)

pkts = get_next_dns_pkt()
while True:
    buffer = b""
    (src, dst, dns) = next(pkts)
    qry = dns.get_questions()[0]
    qry_array = qry[0].split(b".")
    if qry_array[0] == b"api":
        # base64 sent information
        type, rawdata = get_int_raw_resp(dns)
        if type == 1:
            buflen = struct.unpack("!L", rawdata)[0] ^ pattern
        trunc = buflen
        while buflen > 0:
            src, dst, resp = next(pkts)
            type, rawdata = get_int_raw_resp(resp)
            rawdata = base64.b64decode(rawdata)
            buflen -= len(rawdata)
            buffer += rawdata
        buffer = buffer[:trunc]
        print(decrypt(buffer))
    elif qry_array[0] == b"cdn":
        type, rawdata = get_int_raw_resp(resp)
        if type == 1:
            buflen = struct.unpack("!L", rawdata)[0] ^ pattern
            while len(buffer) < buflen:
                src, dst, resp = next(pkts)
                type, rawdata = get_int_raw_resp(resp)
                buffer += rawdata
            print(decrypt(buffer))
    elif qry_array[0] == b"post":
        alen = qry_array[1]
        len_chunk = int(str(alen[1:], "ascii"), 16)
        while len(buffer) < len_chunk:
            (src, dst, dns) = next(pkts)
            qry = dns.get_questions()[0]
            qry_array = qry[0].split(b".")
            num_chunks = qry_array[1][0]
            num_chunks -= 0x30
            buffer += bytes.fromhex(str(qry_array[1][1:], 'ascii'))
            if num_chunks >= 2:
                buffer += bytes.fromhex(str(qry_array[2], 'ascii'))
            if num_chunks == 3:
                buffer += bytes.fromhex(str(qry_array[3], 'ascii'))
        print(decrypt(buffer))

which gives us the flag: CSR{Schro3dinger%%3quation_}

Author: supersnail