Plaid CTF 2024 - [DHCPPP] - Misc & Crypto
Difficulty: Medium
Category: Misc and Cryptography
Flag: PCTF{d0nt_r3u5e_th3_n0nc3_d4839ed727736624}
Challenge: DHCPPP
The local latin dance company is hosting a comp. They have a million-dollar wall of lava lamps and prizes so big this must be a once-in-a-lifetime opportunity.
Hypotheses. It’s not DNS // There’s no way it’s DNS // It was DNS
Attachment - dhcppp.py
1 |
|
Analysis
At first glance, we can make out a couple of helper functions, a main function as well as two server classes: A DhcpServer and a FlagServer. Let’s take a deep dive into the code to understand the provided funtions.
Main
The main function creates an instance of the DhcpServer class and an instance of the FlagServer class (which is using the DhcpServer instance). Then it enters an infinite loop of reading hex encoded packet data from the command line, passing it to both server instances and printing their outputs / return values as hex. Additionally, we can see that both servers declare a process_pkt(packet)
function, which seems to handle the incoming requests for the respective server.
1 |
|
Helper functions and static data
When analyzing the helper functions and static data, we can see two randomly-generated byte strings that are generated in a cryptographically secure way: CHACHA_KEY
and the RNG_INIT
, with lengths of 32 and 512 bytes, respectively.
1 |
|
Additionally, there are helper functions to compute a crc32 checksum and a SHA256 hash - nothing out of the ordinary.
1 |
|
We also have some functions to encrypt and decrypt data using the ChaCha20_Poly1305 algorithm, which uses a key
and a nonce
to encrypt a message
and produces a ciphertext
and an authentication tag
(a.k.a. message authentication code or MAC for short - not to be confused with a MAC address though).
1 |
|
A couple of things to note here:
- Typically you’re able to reuse the key for ChaCha20_Poly1305, as long as you’re using a different nonce for each message encryption; otherwise this algorithm is known to lose confidentiality for messages encrypted using the same nonce.
- Assuming an attacker can control the first 32 bytes of the
message
and thenonce
parameters, the nonce used during the encryption can suffer from akey and nonce reuse attack
depending on the provided parameters. - The
decrypt_msg
function does not validate that thenonce
, supplied with the encrypted messagemsg
, adheres to the pattern used to contruct thenonce
in theencrypt_msg
function. - Both the
encrypt_msg
and thedecrypt_msg
functions use the same static keyCHACHA_KEY
for the ChaCha20_Poly1305 cipher.
Lastly, there’s the custom curl
function, which looks like it’s trying to resolve a domain using a specified DNS server instance and then sending an http GET request to the corresponding IP address. We’ll come back to this once we analyze the FlagServer.
1 |
|
DhcpServer
Now that we’ve seen the helper functions, let’s come back to the server classes: Starting with the DhcpServer.
The __init__
function of this class declares two lists leases
and ips
which will contain the provided leases and the available ips for this DhcpServer. Additionally, it sets its MAC address to the static hex value 1b 7d 6f 49 37 c9
, sets its gateway to 192.168.1.1
, and leases out the IP address 192.168.1.2
to a server named rngserver_0
.
1 |
|
Now let’s analyze the process_pkt
function:
- We can deduce some of the structure of the packet data: Each packet is supposed to start with a 6 byte MAC address corresponding to the sender of the packet, followed by another 6 byte MAC address of the receiver of the packet. The rest of the packet will contain the actual message.
- The DhcpServer will only process packets that are meant to be routed to it and don’t originate from itself.
- If the message does not start with a
0x01
-byte, the DhcpServer will reject the packet. - Otherwise it will interpret the message-content following the
0x01
-byte, which is reminiscent of a DHCP discovery message, as the server name, try to lease an IP address to it and construct and return a response packet, that follows the same structure of sender MAC address followed by receiver MAC address and the actual message, to the sender of the current packet.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24def process_pkt(self, pkt):
assert pkt is not None
src_mac = pkt[:6]
dst_mac = pkt[6:12]
msg = pkt[12:]
if dst_mac != self.mac:
return None
if src_mac == self.mac:
return None
if len(msg) and msg.startswith(b"\x01"):
# lease request
dev_name = msg[1:]
lease_resp = self.get_lease(dev_name)
return (
self.mac +
src_mac + # dest mac
lease_resp
)
else:
return None
The get_lease
function, which processes the DHCP discovery message, will try to handout any unused IP address from the ips
-list first and if all of them are in use, it will relinquish the oldest lease and reuse and handout that same IP address once more.
Note: If an actual DHCP server was to behave like this, modern networks would break all the time, because multiple devices would use the same IP address, which is supposed to be unique to a single device for the timeframe of the lease, which this DhcpServer implementation does not respect.
Finally this function will construct a response packet similar to a DHCP offer message, even replying with some of the possible DHCP options.
- The main difference from the official DHCP protocol is that the majority of the response is encrypted. Additionally, a checksum computed on the unencrypted packet is appended to the end of the response.
- Another notable piece of information are the two DNS server IP addresses given inside the response packet, which are statically set to
8.8.8.8
and8.8.4.4
by the DhcpServer. We’ll come back to this when analyzing the FlagServer.Taking a deeper dive into the packet encryption, we’ll notice two things:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23def get_lease(self, dev_name):
if len(self.ips) != 0:
ip = self.ips.pop(0)
self.leases.append((ip, dev_name, time.time(), []))
else:
# relinquish the oldest lease
old_lease = self.leases.pop(0)
ip = old_lease[0]
self.leases.append((ip, dev_name, time.time(), []))
pkt = bytearray(
bytes([int(x) for x in ip.split(".")]) +
bytes([int(x) for x in self.gateway_ip.split(".")]) +
bytes([255, 255, 255, 0]) +
bytes([8, 8, 8, 8]) +
bytes([8, 8, 4, 4]) +
dev_name +
b"\x00"
)
pkt = b"\x02" + encrypt_msg(pkt, self.get_entropy_from_lavalamps()) + calc_crc(pkt)
return pkt
- The
dev_name
part of the response packet is fully controlled by the sender of the DHCP discovery message, as we’ll see when analyzing the FlagServer and the intended structure of such a discovery message. The only pseudo restriction on this part is a trailing0x00
-byte, which the sender is not supposed to change, but there’s no actual check for that byte. - The
nonce
passed into theencrypt_msg
function is generated using theget_entropy_from_lavalamps
function, which we’ll take a look at right now:
There are two cases for this function:
- If the DhcpServer instance does not contain an active
lease
for a server containingrngserver
in the server name, then theget_entropy_from_lavalamps
function will just returnsha256(RNG_INIT)
. - On the other hand, if the DhcpServer does contain an active
lease
for a server containingrngserver
in its name, then the result of theget_entropy_from_lavalamps
function will be altered by appending some additional data to the argument of the SHA256 computation.
Thus, for the sake of simplicity, we should try to achieve the first case and get a static return value from this funtion to incur a key and nonce reuse in the encrypt_msg
function.
1 |
|
FlagServer
Finally let’s analyze the FlagServer:
Similarly to the DhcpServer, the FlagServer also initializes its own MAC address. Additionally it sets up its own DNS resolver and initializes this dns
instance and its own network settings by talking to the DhcpServer and asking it for the appropriate settings.
1 |
|
The FlagSever is going to process the response of the DhcpServer using its own process_pkt
function. This function, similar to the DhcpServer‘s analogue, also parses the first 12 bytes into two different MAC addresses and checks those to see if it even has to process the received packet - in the same way, the DhcpServer does. And again, the remainder of the packet data is being considered the actual message part of the packet.
Besides that, the FlagServer can execute two functions depending on the incoming message msg
.
- If the
msg
starts with a0x02
-byte, it will parse and process the supplied DHCP offer message:- After stripping the static
0x02
-byte from the front and the checksum from the end of the message, the FlagServer decrypts the provided ciphertext packetpkt
and veryfies the containedtag
using the also contianednonce
and the previously defineddecrypt_msg
function with the shared static encryption keyCHACHA_KEY
. - After decrypting the DHCP offer message
pkt
, the FlagServer also verifies the checksum attached to the message. - If all the checks pass, the FlagServer will then continue to configure its own settings based on the DHCP offer message and also configure their
dns
server to use the supplied DNS server ip addresses.
- After stripping the static
- If the
msg
starts with a0x03
-byte, then the FlagServer is reading the fileflag.txt
and sending a http GET request tohttp://example.com/{flag}
using its own DNS server. - Any other message will be rejected by this server.
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
44def process_pkt(self, pkt):
assert pkt is not None
src_mac = pkt[:6]
dst_mac = pkt[6:12]
msg = pkt[12:]
if dst_mac != self.mac:
return None
if src_mac == self.mac:
return None
if len(msg) and msg.startswith(b"\x02"):
# lease response
pkt = msg[1:-4]
pkt = decrypt_msg(pkt)
crc = msg[-4:]
assert crc == calc_crc(pkt)
self.ip = ".".join(str(x) for x in pkt[0:4])
self.gateway_ip = ".".join(str(x) for x in pkt[4:8])
self.subnet_mask = ".".join(str(x) for x in pkt[8:12])
self.dns1 = ".".join(str(x) for x in pkt[12:16])
self.dns2 = ".".join(str(x) for x in pkt[16:20])
self.dns.nameservers = [self.dns1, self.dns2]
assert pkt.endswith(b"\x00")
print("[FLAG SERVER] [DEBUG] Got DHCP lease", self.ip, self.gateway_ip, self.subnet_mask, self.dns1, self.dns2)
return None
elif len(msg) and msg.startswith(b"\x03"):
# FREE FLAGES!!!!!!!
self.send_flag()
return None
else:
return None
def send_flag(self):
with open("flag.txt", "r") as f:
flag = f.read().strip()
curl("example.com", f"/{flag}", self.dns)
Solution
After puzzling all of the pieces together, we are left with a clear plan of action:
- Forge a DHCP offer message and send it to the FlagServer to reconfigure their DNS server settings and have them point to us.
- Setup our own DNS server, which maliciously tells the FlagServer that the domain
example.com
also belongs to us. - Setup our own web server, which is supposed to receive the messages send to
example.com
- coming from the FlagServer. - Send an other request to the FlagServer - this time using the
0x03
-byte message type - to make the FlagServer send the flag to, what they think is,example.com
, i.e. us. - Receive the flag.
Now let’s explore the details of each of those steps in a slightly different order - we’ll start off by setting up the required infrastructure:
The infrastructure
1. Our DNS server
To be able to receive the flag, we’ll need to trick the FlagServer into thinking we’re their actual DNS server, such that we can also tell it that all the requests meant for example.com
should be send to us - including the flag!
So let’s set up our own DNS server, using a somewhat easy to configure DNS-Server implemented in python, that perfectly suits our needs:
1.1. Clone the respective git repository:
1 |
|
1.2. Configure the server to run on the appropriate ip address:
1 |
|
I’m running the entire exploit on a Kali virtual machine, which is configured to be bridged on my host machine, thus I’ll have to use my eth0
-interface ip 192.168.178.79
of that VM in this case.
1 |
|
1.3. Configure a Zone file for the example.com
domain, such that the DNS server will actually respond to requests for example.com
and will return our public ip address:
1 |
|
Replace the ip address 127.0.0.1
in the A record with your public iaddress! Note, in my setup, this is not the same as the ip address used fomy virtual machine!
1 |
|
1.4. Running the DNS server
1 |
|
2. Our web server
Running our own web server is as simple as running the following command:
1 |
|
3. Port forwarding
If you’re sitting behind a firewall, you’ll need to configure it to forward the ports following ports to your machine - in my case to the VM: 80/tcp
and 53/udp
. Based on your firewall/hardware configuration, you’ll have to find your own instructions on how to do this.
The logic
4. Forge a DHCP offer message and send it to the FlagServer to reconfigure their DNS server settings and have them point to us
To reconfigure the DNS settings of the FlagServer, we need to forge a packet that’s supposedly encrypted and signed by the DhcpServer such that the FlagServer will successfully decrypt and verify the signature of it to reconfigure their own DNS settings.
4.1. Retrieve a KeyStream of the desired length including the corresponding nonce
, that we want to reuse
Remembering our analysis of the encrypt_msg
function, we’ve noticed a couple of important things about the key and the nonce, both of which are used to compute the final KeyStream in the ChaCha20 cipher.
The cipher key is shared between the encryption on the DhcpServer and the decryption on the FlagServer, so we don’t have to worry about that one, if we can retrieve the KeyStream somehow.
The final nonce for the encryption is computed using the first 32 bytes of each the packet content and the primary nonce created by the get_entropy_from_lavalamps
function.
So let’s take an other look at the get_entropy_from_lavalamps
function. If we can manage to remove all leases, that contain the string rngserver
in their name, we can assure a static primary nonce. To do so, we’ll define a short packet, that fit’s our needs to request a lease from the DhcpServer:
1 |
|
We can also verify that packets received from the DhcpServer matches the expected IP addresses by computing the crc32 values for the expected packet for all the possible IPs and comparing the precomputed crc32 values to the crc32 value appended to the message sent by the DhcpServer.
1 |
|
And let’s just setup a small function to send the crafted packets to the DhcpServer:
1 |
|
Remembering that the IP 192.168.1.1
is static to the gateway, 192.168.1.2
is assigned to rngserver_0
, and 192.168.1.3
is assigned to the FlagServer, we need to request leases for all the remaining unassigned IP addresses ending in a number of the range [4 .. 63]. These 60 requests will consume all the remaining unassigned IP addresses. If we now request an additional 61th IP address, we’ll kick out the rngserver_0
from the list of leases:
1 |
|
Thus form now on, we can assume a static primary nonce with the value of sha256(RNG_INIT)
, as long as we don’t hide the string rngserver
in any future device name.
Considering that the content of the DHCP offer message - which is the part, that will get encrypted - has 19 out of their 20 first bytes fixed and we can control the subsequent bytes, we actually can manipulate the first 32 bytes of this message to pull of a key and none reuse attack, especially since we can indirectly control the one remaining byte, which in fact is the last octet of the leased ip address, which we just need to cycle through all the other IP addresses again, as we’ve done before.
4.2. Forge a message to the FlagServer to override their DNS-server entries
4.2.1. Craft the desired packet
We’ll focus on forging a packet that leases the IP address 192.168.1.3
(arbitrarily-chosen) and has the following structure for reasons that will become clear later on:
1 |
|
4.2.2. Encrypt the desired packet
To encrypt this packet pkt3
, we’ll need a KeyStream of length greater or equal to 48.
Because ChaCha20 is just an XOR encryption using the KeyStream as its XOR key, we can perform a known-plaintext attack against the encrypted packets from the DhcpServer, allowing us to recover the relevant bytes to encrypt pkt3
of the KeyStream from just a single known plaintext - ciphertext pair.
To be able to double check our work and because we’re going to need it to forge the tag anyways, let’s get two different plaintext - ciphertext pairs:
1 |
|
Since both the nonce and key are reused, and we know both the plaintext and ciphertext, we can directly recover the keystream via XOR and use this to encrypt arbitrary messages, i.e. pkt3
in our use case:
1 |
|
4.3. Attempt to forge the tag for the encrypted message, compute the corresponding checksum and send the message to the FlagServer
This section addresses the following question: Given the Poly1305 authentication tags tag1, tag2
for 2 known, distinct messages msg1, msg2
which are MAC’ed with the same secret 32-byte Poly1305 key (r,s)
, how can we recover (r,s)
to forge arbitrary messages?
To implement the Poly1305 key/nonce reuse forgery attack, we used the following references:
- The Poly1305 Wikipedia page (here)
- The ChaCha20-Poly1305 Wikipedia Page (here)
- RFC 7539, the specification for ChaCha20 and Poly1305 (here)
- This Crypto Stack Exchange post (poncho’s answer) (here)
- The PyCryptodome library’s implementation of ChaCha20-Poly1305 and Poly1305 (here and here)
From Ref. 2, we can see the detailed structure of the ChaCha20-Poly1305 AEAD algorithm:
ChaCha20 is used to generate a keystream that is XORed with the plaintext to produce the ciphertext. The ciphertext (C) and the associated data (AD) are then authenticated using Poly1305 to provide an authentication tag to ensure integrity. Poly1305 takes as its input a message with the following field structure: AD || pad(AD) || C || pad(C) || len(AD) || len(C)
. The server code in this challenge doesn’t use any AD, so in our scenario we only have C || pad(C) || len(AD) || len(C)
. For the forgery attack to work, we need two messages encrypted/authenticated with the same nonce/key - and we already have these from Section 4.2.2! Let’s use the ciphertexts to construct the full input messages to the Poly1305 authenticator:
1 |
|
Now, with the messages msg1, msg2
and tags tag1, tag2
, how can we recover the secret 32-byte Poly1305 key (r,s)
? From this section of the Wikipedia page on Poly1305 (Ref. 1 above) and also the crypto stack exchange answer (Ref. 4 above), we learn that reuse of the same (r,s)
for msg1 != msg2
gives us
Subtracting the two:
Which can be rewritten as:
for k ∈−4,…,4. This gives us 9 polynomials (for the 9 possible k
values) whose coefficients are known, and we know that the correct value of r
is a zero for one of them. For any candidate r
value we can directly compute the associated s
value using equation (1) above. Thus, we will have a small list of possible (r,s)
keys to forge a new message with; experimentally, for our case the number of pairs was only ever between 1-3. Therefore, with high probability the forgery will succeed if we take a random (r,s)
from the candidate list. With any (r,s)
pair, we can directly compute the Poly1305 authentication tag tag3
for an arbitrary message of our choosing msg3
by manually evaluating the Poly1305 polynomial as described here (Ref. 1).
These steps are shown in the following sagemath code:
1 |
|
5. Send an other request to the FlagServer - this time using the 0x03
-byte message type - to make the FlagServer send the flag to, what they think is, example.com
, i.e. us.
1 |
|
6. Receive the flag.
After sending the previous request to the FlagServer, we’ll receive an incoming request to our DNS server:
1 |
|
Right after that, we’ll notice an incoming request to our web server:
1 |
|
Of course, we won’t know the flag in advance, so we couldn’t setup a file named like the flag; thus the web server will clearly respond with a 404 when being asked for the specified ressource, but we can still extract the name of the requested ressource and url-decode it to receive the flag:
1 |
|
And finally, there we go: PCTF{d0nt_r3u5e_th3_n0nc3_d4839ed727736624}
Resources
Networking related
- Wikipedia: DHCP - Dynamic Host Configuration Protocol
- Wikipedia: DNS - Domain Name System
- Wikipedia: IP address
- Wikipedia: MAC address