Points: 1000
Description
The recovered data indicates the APT is using a DNS server as a part of their operation. The triage team easily got the server running but it seems to reply to every request with errors.
You decide to review past SIGINT reporting on the APT. Why might the APT be targeting the Guardian Armaments JCTV firmware developers? Reporting suggests the APT has a history of procuring information including the location and movement of military personnel.
Just then, your boss forwards you the latest status update from Barry at GA. They found code modifications which suggest additional DNS packets are being sent via the satellite modem. Those packets probably have location data encoded in them and would be sent to the APT.
This has serious implications for national security! GA is already working on a patch for the firmware, but the infected version has been deployed for months on many vehicles.
The Director of the NSA (DIRNSA) will have to brief the President on an issue this important. DIRNSA will want options for how we can mitigate the damage.
If you can figure out how the DNS server really works maybe we will have a chance of disrupting the operation.
Find an example of a domain name (ie. foo.example.com.) that the DNS server will handle and respond with NOERROR and at least 1 answer.
Prompt
- Enter a domain name which results in a NOERROR response. It should end with a ‘.’ (period)
Solution
After unlocking the data with the password from the last task, we can see three files.
root@ba3d9d38016e:/mnt# ls -la data
total 151776
drwxr-xr-x 2 root root 32768 Jul 31 20:02 .
drwxr-xr-x 5 root root 32768 Dec 31 1969 ..
-rwxr-xr-x 1 root root 225 Jul 31 20:02 Corefile
-rwxr-xr-x 1 root root 58829952 Jul 31 20:02 coredns
-rwxr-xr-x 1 root root 95253887 Jul 31 20:02 microservice
Both coredns and microservice are ELF binaries while Corefile is a a configuration file.
cobra@arch:~/codebreaker/task6$ file coredns
coredns: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
cobra@arch:~/codebreaker/task6$ file microservice
microservice: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[xxHash]=aabe50550fc8fc36, stripped
cobra@arch:~/codebreaker/task6$ cat Corefile
.:1053 {
acl {
allow type A
filter
}
view firewall {
expr type() == 'A' && name() matches '^x[^.]{62}\\.x[^.]{62}\\.x[^.]{62}\\.net-vwit67xv\\.example\\.com\\.$'
}
log
cache 3600
errors
frontend
}
Looking up “coredns”, yields a GitHub repo for a “DNS server that chains plugins” which uses a Corefile for configuration.
When I first solved this task, I ended up reversing microservice first since after looking into each of the binaries, it seemed easier. However, this binary is not very helpful in solving this task and isn’t needed until Task 7, so I will go over it then.
Instead, we can start digging into CoreDNS.
The configuration file doesn’t tell us much about what’s going on but the large regular expression is interesting. It seems to be filtering for a specific format of domains with large amounts of data.
With some quick research into these configuration files, another thing that may be helpful is adding the debug plugin to the configuration. This will ensure that as much information is logged as possible which will help as we try reverse engineer what’s going on with this binary.
.:1053 {
acl {
allow type A
filter
}
view firewall {
expr type() == 'A' && name() matches '^x[^.]{62}\\.x[^.]{62}\\.x[^.]{62}\\.net-vwit67xv\\.example\\.com\\.$'
}
log
cache 3600
errors
debug
frontend
}
Running the binary also gives us some helpful information.
root@8163bc2feaec:/challenge# ./coredns
.:1053
CoreDNS-1.11.3
linux/amd64, go1.21.8, a7ed346-dirty
We can see the git commit hash in front of “dirty” which means the code at that commit hash was modified before being compiled into this binary. Given the description of this software calls it a “DNS server that chains plugins” we can assume that this addition is most likey the addition of some sort of plugin for the APT. This matches up with the frontend plugin included in the Corefile which is not a known CoreDNS plugin.
Unfortunately, this binary is stripped, so reversing is not going to be as easy as the binary from Task 3. Regardless, we can start by dropping it into Binary Ninja.
As soon as the binary finishes analyzing, there is almost nothing useful visible and all the symbols are just automatically generated names.

One way to fix this is to use GoReSym to extract as much metadata as possible to try and get some symbol names that we can go off of. Additionally, in order to load the output from this tool into Binary Ninja, I found this plugin to be helpful.
First, we can recover the symbols.
cobra@arch:~/codebreaker/task6$ GoReSym/GoReSym coredns > coredns-dump.json
Next, we can use the plugin to add our GoReSym info and let Binary Ninja analyze the binary again.
Although a lot of symbols still have generic names, we now have a lot of meaningful ones.

Searching for “frontend” in the symbols brings up a few functions in the github.com_coredns_example folder. If we look at the rest of the symbols in this folder, we can see a lot of functions that appear to be associated with the addded plugin.

Starting out with the ServeDNS function which appears to be the starting point for every DNS request, we can see that it calls name2buffer.

The contents of this function aren’t easily decipherable from static analysis, so we can jump into interacting with the binary dynamically and come back to this part once we have a better understanding.
We can use the standard dig command line tool to send DNS requests to the server and see what it’s doing with the provided domain. We can format the domain we use according to the regular expression we noticed in the Corefile.
root@8163bc2feaec:/challenge# ./coredns
root@8163bc2feaec:/challenge# dig -p 1053 @127.0.0.1 A xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.net-vwit67xv.example.com.
We can immediately see a message in the logs indicating that the plugin is looking for base32 data. Adding debug to our Corefile ended up being super helpful here since without it we would of had to do a lot more digging to determine the server is looking for base32 encoded data.
[DEBUG] plugin/frontend: type: 1
[ERROR] plugin/frontend: error decoding input: xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.net-vwit67xv.example.com. as: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
[DEBUG] plugin/frontend: bad name: illegal base32 data at input byte 184
[INFO] 127.0.0.1:45469 - 31553 "A IN xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.net-vwit67xv.example.com. udp 257 false 1232" NXDOMAIN qr,aa,rd 464 0.001427509s
Given this, it appears that the DNS plugin is configured to use FQDNs to transfer data. This is actually a really common technique used by malware to exilftrate data without getting caught. This SANS article describes a process that is very similar to what is occuring here.
We can write a quick Python script to parse our own data into a domain through base32. Noticing that each segment begins with an “x” character, we can also try using this character for empty space since the regular expression binds us to an exact length of input.
import base64
message = input('Enter message: ').encode()
b32_message = base64.b32encode(message).decode()
if len(b32_message) > 186:
print('Message too long!')
exit()
if len(b32_message) < 186:
diff = 186 - len(b32_message)
b32_message += 'x' * diff
print(f'dig -p 1053 @127.0.0.1 A x{b32_message[0:62]}.x{b32_message[62:124]}.x{b32_message[124:]}.net-vwit67xv.example.com.')
root@1affbf4e2698:/challenge# python3 test.py
Enter message: asdf
dig -p 1053 @127.0.0.1 A xMFZWIZQ=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.net-vwit67xv.example.com.
This is a bit weird since the = character is typically not allowed in FQDNs. Regardless, we can run the command to see what happens.
root@1affbf4e2698:/challenge# dig -p 1053 @127.0.0.1 A xMFZWIZQ=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.net-vwit67xv.example.com.
Although the “x” characters succesfully work as filler data, our base32 is not succesfully decoded by the server.
[DEBUG] plugin/frontend: type: 1
[ERROR] plugin/frontend: error decoding input: xMFZWIZQ=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.net-vwit67xv.example.com. as: MFZWIZQ=
[DEBUG] plugin/frontend: bad name: illegal base32 data at input byte 2
This means that there is some kind of expected encoding or special formatting on top of the base32 data.
Our long string of As got much further (byte 184) than our actually base32-encoded data (byte 2). Let’s revisit that and try to figure out why it got further.
That string was 186 characters long, so with whatever format their expecting, something is wrong with the very last two bytes. Most likely, this is the padding of the data. Base32 strings need to be multiples of 8, so the = character is used as padding to ensure this is the case.
When I originally solved this challenge, I assumed that the = character wouldn’t work for padding since it shouldn’t be allowed according to various DNS RFCs. However, in the process of working on this writeup, I noticed that it appeared to work as padding in the queries. I’m not sure if its allowed in the final submission given its technically not a valid FQDN, but it might be.
Regardless, by testing different characters as input, we can notice that the character z is converted to an = character when included in the domain name.
root@1affbf4e2698:/challenge# dig -p 1053 @127.0.0.1 A xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.net-vwit67xv.example.com.
[ERROR] plugin/frontend: error decoding input: xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.net-vwit67xv.example.com. as: ==========================================================================================================================================================================================
So, we can just use z as replacement for the = characters when creating padding.
In order to make our data a multiple of 8, we can use the following domain which gives us a length of 184.
root@1affbf4e2698:/challenge# dig -p 1053 @127.0.0.1 A xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzxx.net-vwit67xv.example.com.
The server finally decodes our data and displays the hex of what was received in the debug log.
[DEBUG] plugin/frontend: got data: 5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a5294a529
Piping this into CyberChef to decode from hex and re-encode as base32, we can see how its encoding the data we send it before sending it to its base32 decode function.

So, A translates to K. We just need to figure out what the rest of the characters translate to.
With some manual testing of different characters, we can create a valid payload that encompasses as many valid characters as allowed then use it to build a mapping.
root@1affbf4e2698:/challenge# dig -p 1053 @127.0.0.1 A x023456789ABCDEFGHIJKLMNOPQRSTUVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzxx.net-vwit67xv.example.com.
Basically, A-V and 0-9 work. Upper/lowercase doesn’t matter, but I opted to use uppercase to distinguish the actual data from special characters like x and z.
Plugging the result into CyberChef allows us to see what each character maps to.

Comparing the result to our payload, we can add the mapping to our script and allow it to encode any message.
import base64
mapping = {
'A': '0',
'B': '1',
'C': '2',
'D': '3',
'E': '4',
'F': '5',
'G': '6',
'H': '7',
'I': '8',
'J': '9',
'K': 'A',
'L': 'B',
'M': 'C',
'N': 'D',
'O': 'E',
'P': 'F',
'Q': 'G',
'R': 'H',
'S': 'I',
'T': 'J',
'U': 'K',
'V': 'L',
'W': 'M',
'X': 'N',
'Y': 'O',
'Z': 'P',
'2': 'Q',
'3': 'R',
'4': 'S',
'5': 'T',
'6': 'U',
'7': 'V',
'=': 'z'
}
message = input('Enter message: ').encode()
b32_message = base64.b32encode(message).decode()
if len(b32_message) > 186:
exit('Message too long!')
enc_message = ''.join([mapping[x] for x in b32_message])
if len(enc_message) < 186:
diff = 186 - len(enc_message)
enc_message += 'x' * diff
print(f'dig -p 1053 @127.0.0.1 A x{enc_message[0:62]}.x{enc_message[62:124]}.x{enc_message[124:]}.net-vwit67xv.example.com.')
root@1affbf4e2698:/challenge# python3 encode.py
Enter message: asdf
dig -p 1053 @127.0.0.1 A xC5PM8PGzxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.net-vwit67xv.example.com.
However, when running the payload, the server panics and crashes since it tries to slice the first 32 bytes and fails.
root@1affbf4e2698:/challenge# dig -p 1053 @127.0.0.1 A xC5PM8PGzxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.net-vwit67xv.example.com.
[DEBUG] plugin/frontend: got data: 61736466
panic: runtime error: slice bounds out of range [:32] with capacity 8
goroutine 114 [running]:
github.com/coredns/example.NoiseRecv({0xc0008283d0?, 0x4, 0x8}, {{0xaf, 0xd2, 0x5a, 0xce, 0x7, 0xff, 0x48, ...}, ...}, ...)
github.com/coredns/[email protected]/noise_api.go:12 +0x268
github.com/coredns/example.checkAndDecrypt(...)
github.com/coredns/[email protected]/frontend.go:143
github.com/coredns/example.Frontend.ServeDNS({{0xc0002b2ba8?, 0x7fe325?}}, {0x29c6648?, 0x0?}, {0x29e5c18, 0xc000516300}, 0xc0007f22d0)
github.com/coredns/[email protected]/frontend.go:104 +0x74a
...
This is actually beneficial to us since it helps visualize which functions are being called after the data is received and decoded.
Regardless, submitting a longer message works to finally parse our data properly.
root@1affbf4e2698:/challenge# dig -p 1053 @127.0.0.1 A xC5PM8PJ1EDI6COBJCHJ62SR4CPGN6P36C5PM8PJ1EDI6COBJCHJ62SR4CPGN6P.x36C5PM8PJ1EDI6COBJCHJ62SR4CPGN6P36C5PM8PJ1EDI6COBJCHJ62SR4CPGN.x6P36C5PM8PJ1EDI6Czzzxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.net-vwit67xv.example.com.
[DEBUG] plugin/frontend: got data: 61736466617364666173646661736466617364666173646661736466617364666173646661736466617364666173646661736466617364666173646661736466617364666173646661736466617364666173646661736466
[DEBUG] plugin/frontend: bad decrypt: bad handshake
The notable thing now is the “bad handshake” error. The server is looking for some kind of encrypted data, so we need to figure out how that works next.
Looking back at the panic we triggered, we can see that it references a file named noise_api.go. Additionally, digging through some of the functions in Binary Ninja, we can see the string “Noise_K_25519_ChaChaPoly_BLAKE2s” in the initializeInitiator and initializeResponder functions.

With some research, we can piece these clues together and realize that this code is an implementation of the Noise Protocol Framework.
Based on this string we found, we know that we are using a one-way pattern as described in the speficication, the K pattern.

In the process of trying understand how this pattern works, I stumbled across an amazing find. Noise Explorer. Not only does this explain how each pattern works, but it also has the option to “Generate Secure Protocol Implementation Code” for each pattern.
The code for the K pattern can be downloaded here and the detailed analysis can be viewed here.
To my surprise when solving this task, the generated code had the exact same function names visible in Binary Ninja. The APT used this code to implement their protocol!
So, now we have the source code for the majority of the functions in the plugin. We just need to analyze how they work and try to get a message through.
Going through the code, we can understand how the pattern works.
First, InitSession is called. Depending on whether the session is for the inititator or responder, it calls the corresponding function. In this case, the server is the responder and we are the initiator.
func InitSession(initiator bool, prologue []byte, s keypair, rs [32]byte) noisesession {
var session noisesession
psk := emptyKey
if initiator {
session.hs = initializeInitiator(prologue, s, rs, psk)
} else {
session.hs = initializeResponder(prologue, s, rs, psk)
}
session.i = initiator
session.mc = 0
return session
}
Using it, the server would call it with a statement like this. It needs to provide its own key pair in addition to knowing the inititator’s public key prior to the session. Additionally, both the initator and responder need to use the same prologue.
responderSession := InitSession(false, prologue, responderKeyPair, initiatorKeyPair.public_key)
For the server, this function calls initializeResponder which sets up the symmetric state and starts updating the handshake hash to verify integrity of the handshake later.
func initializeResponder(prologue []byte, s keypair, rs [32]byte, psk [32]byte) handshakestate {
var ss symmetricstate
var e keypair
var re [32]byte
name := []byte("Noise_K_25519_ChaChaPoly_BLAKE2s")
ss = initializeSymmetric(name)
mixHash(&ss, prologue)
mixHash(&ss, rs[:])
mixHash(&ss, s.public_key[:])
return handshakestate{ss, s, e, rs, re, psk}
}
The initiator can similarly call InitSession except with the initiator’s key pair and the responder’s public key. The functions called by this will behave in the same way as they did for the responder.
initiatorSession := InitSession(true, prologue, initiatorKeyPair, responderKeyPair.public_key)
The initiator can now call SendMessage. Because this will be (and will always be) the first message of the initiator’s session, writeMessageA will always be called since session.mc will always equal 0.
func SendMessage(session *noisesession, message []byte) (*noisesession, messagebuffer, error) {
var err error
var messageBuffer messagebuffer
if session.mc == 0 {
session.h, messageBuffer, session.cs1, _, err = writeMessageA(&session.hs, message)
session.hs = handshakestate{}
}
if session.mc > 0 {
if session.i {
_, messageBuffer, err = writeMessageRegular(&session.cs1, message)
} else {
_, messageBuffer, err = writeMessageRegular(&session.cs1, message)
}
}
session.mc = session.mc + 1
return session, messageBuffer, err
}
In writeMessageA, an ephemeral public key is generated and returned with the message buffer. This is why the panic occured earlier for us when the server tried to split the first 32 bytes off the front of our message.
func writeMessageA(hs *handshakestate, payload []byte) ([32]byte, messagebuffer, cipherstate, cipherstate, error) {
var err error
var messageBuffer messagebuffer
ne, ns, ciphertext := emptyKey, []byte{}, []byte{}
hs.e = generateKeypair()
ne = hs.e.public_key
mixHash(&hs.ss, ne[:])
/* No PSK, so skipping mixKey */
mixKey(&hs.ss, dh(hs.e.private_key, hs.rs))
mixKey(&hs.ss, dh(hs.s.private_key, hs.rs))
_, ciphertext, err = encryptAndHash(&hs.ss, payload)
if err != nil {
cs1, cs2 := split(&hs.ss)
return hs.ss.h, messageBuffer, cs1, cs2, err
}
messageBuffer = messagebuffer{ne, ns, ciphertext}
cs1, cs2 := split(&hs.ss)
return hs.ss.h, messageBuffer, cs1, cs2, err
}
After updating the handshake hash, Diffie-Hellman is used to create a key and encryptAndHash is called. This function just checks for the key and passes it into encryptWithAd which is responsible for including associated data in the encryption process.
func encryptAndHash(ss *symmetricstate, plaintext []byte) (*symmetricstate, []byte, error) {
var ciphertext []byte
var err error
if hasKey(&ss.cs) {
_, ciphertext, err = encryptWithAd(&ss.cs, ss.h[:], plaintext)
if err != nil {
return ss, []byte{}, err
}
} else {
ciphertext = plaintext
}
ss = mixHash(ss, ciphertext)
return ss, ciphertext, err
}
encryptWithAd and encrypt format this associated data into the ChaCha20-Poly1305 algorithm and use it along with the session nonce to finally encrypt the plaintext.
func encryptWithAd(cs *cipherstate, ad []byte, plaintext []byte) (*cipherstate, []byte, error) {
var err error
if cs.n == math.MaxUint64-1 {
err = errors.New("encryptWithAd: maximum nonce size reached")
return cs, []byte{}, err
}
e := encrypt(cs.k, cs.n, ad, plaintext)
cs = setNonce(cs, incrementNonce(cs.n))
return cs, e, err
}
func encrypt(k [32]byte, n uint64, ad []byte, plaintext []byte) []byte {
var nonce [12]byte
var ciphertext []byte
enc, _ := chacha20poly1305.New(k[:])
binary.LittleEndian.PutUint64(nonce[4:], n)
ciphertext = enc.Seal(nil, nonce[:], plaintext, ad)
return ciphertext
}
Once the message is sent to the server, a similar process repeats again with RecvMessage to decrypt the message.
The above is just a very basic overview of what functions are being called. There is a lot more going on that I don’t feel like going into in this writeup.
At this point, we can try to extract all the necessary data and complete a handshake properly. However, this ends up being relatively complicated as we need to determine the prologue, the initiator’s static public key, the initiator’s static private key, and the responder’s public key. We can’t even get the static private key we’re supposed to have since the server only has our static public key.
Instead, we can try a different approach.
The only piece of data the server includes from us in its decryption besides the data itself is our ephemeral public key. As mentioned previously, this is just randomly generated in writeMessageA. As a result, we know that if we include the same ephemeral public key in two messages, the server will try to decrypt both messages in the exact same way.
So, looking back at the encrypt function, we know that we need the shared key, nonce, and associated data in addition to the plaintext message we want to send.
The nonce is initialized to minNonce (which is equal to 0) in initializeKey, so we know the nonce will be 0.
var minNonce = uint64(0)
func initializeKey(k [32]byte) cipherstate {
return cipherstate{k, minNonce}
}
This means that all we need to extract for a given ephemeral public key is the shared key and associated data.
We can start by generating our ephemeral public key in Python.
from cryptography.hazmat.primitives.asymmetric import x25519
from cryptography.hazmat.primitives import serialization
ephemeral_private_key = x25519.X25519PrivateKey.generate()
ephemeral_public_key = ephemeral_private_key.public_key()
print("Ephemeral Public Key:", ephemeral_public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw
).hex())
Running it gives us our key.
cobra@arch:~/codebreaker/task6$ python gen_key.py
Ephemeral Public Key: 8a7fdede087b2fc93adf90d7fe75a51a10223a406deeef63fdd9a40847535377
With a modification to our encoding script, we can have it take hex then input the public key into it followed by some junk (I just used the alphabet).
import base64
mapping = { ... }
message = bytes.fromhex(input('Enter hex: '))
b32_message = base64.b32encode(message).decode()
if len(b32_message) > 186:
exit('Message too long!')
enc_message = ''.join([mapping[x] for x in b32_message])
if len(enc_message) < 186:
diff = 186 - len(enc_message)
enc_message += 'x' * diff
print(f'dig -p 1053 @127.0.0.1 A x{enc_message[0:62]}.x{enc_message[62:124]}.x{enc_message[124:]}.net-vwit67xv.example.com.')
cobra@arch:~/codebreaker/task6$ python encode.py
Enter hex: 8a7fdede087b2fc93adf90d7fe75a51a10223a406deeef63fdd9a408475353776162636465666768696a6b6c6d6e6f707172737475767778797a
dig -p 1053 @127.0.0.1 A xH9VTTNG8FCNSIEMVI3BVSTD538824EI0DNNEUOVTR6I0GHQJADRM2OJ3CHIMCP.xR8D5L6MR3DDPNN0SBIEDQ7ATJNF1SNKzzzxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.net-vwit67xv.example.com.
Before we submit this to the server, we’ll need to set breakpoints in parts of the code where we think the associated data and shared key will be easily visible. This part unfortunately takes a lot of trial and error since the easiest way to tell whether the extracted values are correct is to send encrypted data with them. Additionally, the variables we’re looking for aren’t always immediately present in the registers, making them difficult to locate.
First, let’s pinpoint the associated data.
One place that it’s visible is in the decryptWithAd function where its provided as an argument and passed into the decrypt function.
func decryptWithAd(cs *cipherstate, ad []byte, ciphertext []byte) (*cipherstate, []byte, bool, error) {
var err error
if cs.n == math.MaxUint64-1 {
err = errors.New("decryptWithAd: maximum nonce size reached")
return cs, []byte{}, false, err
}
valid, ad, plaintext := decrypt(cs.k, cs.n, ad, ciphertext)
if valid {
cs = setNonce(cs, incrementNonce(cs.n))
}
return cs, plaintext, valid, err
}
Looking at decryptWithAd in Binary Ninja, we can use the first address as our breakpoint in GDB.

gef➤ b *0x01d84e64
gef➤ r
root@1affbf4e2698:/challenge# dig -p 1053 @127.0.0.1 A xH9VTTNG8FCNSIEMVI3BVSTD538824EI0DNNEUOVTR6I0GHQJADRM2OJ3CHIMCP.xR8D5L6MR3DDPNN0SBIEDQ7ATJNF1SNKzzzxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.net-vwit67xv.example.com.
Immediately, we can see rax or rdx/rbx (same value) could point to the associated data we’re looking for. We can also see our junk “ciphertext” pointed to by rsi.

This is where the trial/error comes in. Although we could do some extra analsyis to see how each register is used, we can just try both values and determine that the value pointed to by rbx and rdx is the associated data. The tricky part is that with each decryption fail either the associated data or shared key could be incorrect. When I solved this challenge, I also tested lots of functions, so I quickly came to recognize recurring hex values. Plus, seeing the same values in multiple parts of the code helped add some reasoning behind my guesses.
gef➤ x/32xb $rdx
0xc0007e2048: 0xbf 0xca 0x79 0xb7 0xab 0x03 0xed 0x8e
0xc0007e2050: 0x80 0x4a 0xf3 0x2a 0x52 0x13 0x3e 0xd2
0xc0007e2058: 0xf1 0x2b 0x33 0x80 0xdd 0xda 0x4a 0x67
0xc0007e2060: 0x34 0xcd 0xd7 0x22 0x7d 0x73 0x10 0x1a
Formatted as one string, we have the following hex value for our associated data.
0xbfca79b7ab03ed8e804af32a52133ed2f12b3380ddda4a6734cdd7227d73101a
Next, we need to find the shared key. Unfortunately, it isn’t immediately pointed to by any registers in the decrypt functions, but diving deeper into the nested functions in decrypt, we can find it in the chacha20poly1305 setupState function where it’s passsed as an argument. The large number of operations directly using the key here make it easily readable.
func setupState(state *[16]uint32, key *[32]byte, nonce []byte) {
state[0] = 0x61707865
state[1] = 0x3320646e
state[2] = 0x79622d32
state[3] = 0x6b206574
state[4] = binary.LittleEndian.Uint32(key[0:4])
state[5] = binary.LittleEndian.Uint32(key[4:8])
state[6] = binary.LittleEndian.Uint32(key[8:12])
state[7] = binary.LittleEndian.Uint32(key[12:16])
state[8] = binary.LittleEndian.Uint32(key[16:20])
state[9] = binary.LittleEndian.Uint32(key[20:24])
state[10] = binary.LittleEndian.Uint32(key[24:28])
state[11] = binary.LittleEndian.Uint32(key[28:32])
state[12] = 0
state[13] = binary.LittleEndian.Uint32(nonce[0:4])
state[14] = binary.LittleEndian.Uint32(nonce[4:8])
state[15] = binary.LittleEndian.Uint32(nonce[8:12])
}
Looking at the function in Binary Ninja, we can once again use the the function’s first address as our breakpoint in GDB.

gef➤ b *0x00912468
gef➤ c
root@1affbf4e2698:/challenge# dig -p 1053 @127.0.0.1 A xH9VTTNG8FCNSIEMVI3BVSTD538824EI0DNNEUOVTR6I0GHQJADRM2OJ3CHIMCP.xR8D5L6MR3DDPNN0SBIEDQ7ATJNF1SNKzzzxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.net-vwit67xv.example.com.
This time, only rbx appears to be potentially pointing to the shared key.

So, we can extract it and proceed.
gef➤ x/32xb $rbx
0xc0005015e0: 0xc5 0x9a 0x6a 0x56 0x1c 0x3b 0x16 0x92
0xc0005015e8: 0xb0 0xdb 0x54 0x5e 0x00 0x88 0x02 0x0a
0xc0005015f0: 0x52 0x48 0x6e 0x0f 0x61 0x06 0x04 0xfc
0xc0005015f8: 0x5c 0x15 0x4c 0xcf 0x26 0x7f 0xc5 0x9c
Formatted as one string, we have the following hex value for our shared key.
0xc59a6a561c3b1692b0db545e0088020a52486e0f610604fc5c154ccf267fc59c
We can add both the associated data and shared key into the generated code’s main function to encrypt our message.
func main() {
initiatorEphemeralPublicKey, _ := hex.DecodeString("8a7fdede087b2fc93adf90d7fe75a51a10223a406deeef63fdd9a40847535377")
sharedKey, _ := hex.DecodeString("c59a6a561c3b1692b0db545e0088020a52486e0f610604fc5c154ccf267fc59c")
associatedData, _ := hex.DecodeString("bfca79b7ab03ed8e804af32a52133ed2f12b3380ddda4a6734cdd7227d73101a")
message := []byte("Hello world.")
encryptedMessage := encrypt([32]byte(sharedKey), uint64(0), associatedData[:], message)
fmt.Printf("Initiator Ephemeral Public Key: %x\n", initiatorEphemeralPublicKey)
fmt.Printf("Encrypted Message: %x\n", encryptedMessage)
}
Running it gives us the encrypted message.
root@1affbf4e2698:/challenge# go run K.noise.go
Initiator Ephemeral Public Key: 8a7fdede087b2fc93adf90d7fe75a51a10223a406deeef63fdd9a40847535377
Encrypted Message: 3a654971bc262c63f89db42759de7d8d519e8e7a87aee41f5fc5db68
We can place our ephemeral key directly in front of the encrypted message when we encode it.
root@1affbf4e2698:/challenge# python3 encode.py
Enter hex: 8a7fdede087b2fc93adf90d7fe75a51a10223a406deeef63fdd9a408475353773a654971bc262c63f89db42759de7d8d519e8e7a87aee41f5fc5db68
dig -p 1053 @127.0.0.1 A xH9VTTNG8FCNSIEMVI3BVSTD538824EI0DNNEUOVTR6I0GHQJADRJKPA9E6U2CB.x33V2ER89QPRPUOQKCUHPT8FBN43TFSBMR8xxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.net-vwit67xv.example.co
m.
Finally, we can start the server and use the generated command to send our encrypted message.
root@1affbf4e2698:/challenge# ./coredns
root@1affbf4e2698:/challenge# dig -p 1053 @127.0.0.1 A xH9VTTNG8FCNSIEMVI3BVSTD538824EI0DNNEUOVTR6I0GHQJADRJKPA9E6U2CB.x33V2ER89QPRPUOQKCUHPT8FBN43TFSBMR8xxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxx.net-vwit67xv.example.com.
We finally get the NOERROR response that we have been after.
; <<>> DiG 9.18.30-0ubuntu0.24.04.1-Ubuntu <<>> -p 1053 @127.0.0.1 A xH9VTTNG8FCNSIEMVI3BVSTD538824EI0DNNEUOVTR6I0GHQJADRJKPA9E6U2CB.x33V2ER89QPRPUOQKCUHPT8FBN43TFSBMR8xxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxx.net-vwit67xv.example.com.
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 16299
;; flags: qr aa rd; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 2
;; WARNING: recursion requested but not available
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
Additionally, the server succesfully receives our encrypted data.
[DEBUG] plugin/frontend: type: 1
[DEBUG] plugin/frontend: got data: 8a7fdede087b2fc93adf90d7fe75a51a10223a406deeef63fdd9a408475353773a654971bc262c63f89db42759de7d8d519e8e7a87aee41f5fc5db68
[DEBUG] plugin/frontend: got decrypted data: 48656c6c6f20776f726c642e
To complete the task, we can simply submit the domain name that we created.
xH9VTTNG8FCNSIEMVI3BVSTD538824EI0DNNEUOVTR6I0GHQJADRJKPA9E6U2CB.x33V2ER89QPRPUOQKCUHPT8FBN43TFSBMR8xxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxx.net-vwit67xv.example.com.
Result
Great job!