DHCPPP -- PlaidCTF 2024
DHCPPP – PlaidCTF 2024
Given
Investigation Title. DHCPPP
Reporter. anish
Type. Dance Competition
Description. 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
Notes.
nc dhcppp.chal.pwni.ng 1337
Source
TL;DR
-
We are presented with a server that uses
ChaCha20-Poly1305
. The goal is to change the value of the variabledns.nameservers
so that the request with the flag is done to a server controlled by us. However, we can only do that by sending an encrypted message with a valid authentication tag. -
The
key
and thenonce
are both reused, allowing us to create valid tags for any message and therefore encrypt arbitrary messages.
Code Analysis
This server allows us to connect to a TCP connection and send packets, that will be processed by the DHCPServer
and FlagServer
, and the responses are printed out.
if __name__ == "__main__":
dhcp = DHCPServer()
flagserver = FlagServer(dhcp)
while True:
pkt = bytes.fromhex(input("> ").replace(" ", "").strip())
out = dhcp.process_pkt(pkt)
if out is not None:
print(out.hex())
out = flagserver.process_pkt(pkt)
if out is not None:
print(out.hex())
DHCPServer
The server constructor defines a list of IP addresses, a MAC address, a gateway IP, among others.
def __init__(self):
self.leases = []
self.ips = [f"192.168.1.{i}" for i in range(3, 64)]
self.mac = bytes.fromhex("1b 7d 6f 49 37 c9")
self.gateway_ip = "192.168.1.1"
self.leases.append(("192.168.1.2", b"rngserver_0", time.time(), []))
To process each DHCP packet, we use the function process_pkt
.
def 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
From the code above we immediately notice that in order to get a response, msg
must start with \x01
.
The content of dev_name
is then passed to the function get_lease
.
def 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 get_lease
function appends dev_name
to the leases
list, takes the next available ip
from the ips
list and uses that to generate a packet. This packet is then encrypted using the result from get_entropy_from_lavalamps
as a key and a CRC code at the end.
def get_entropy_from_lavalamps(self):
# Get entropy from all available lava-lamp RNG servers
# Falling back to local RNG if necessary
entropy_pool = RNG_INIT
for ip, name, ts, tags in self.leases:
if b"rngserver" in name:
try:
# get entropy from the server
output = requests.get(f"http://{ip}/get_rng", timeout=TIMEOUT).text
entropy_pool += sha256(output.encode())
except:
# if the server is broken, get randomness from local RNG instead
entropy_pool += sha256(secrets.token_bytes(512))
return sha256(entropy_pool)
Analyzing the get_entropy_from_lavalamps
function, we noticed that if the name of the lease does not contain rngserver
, then the result will always be the same, since RNG_INIT
is fixed. This ended up being the source of the vulnerability.
FlagServer
This server is very similar to the DHCP Server.
def __init__(self, dhcp):
self.mac = bytes.fromhex("53 79 82 b5 97 eb")
self.dns = dns.resolver.Resolver()
self.process_pkt(dhcp.process_pkt(self.mac+dhcp.mac+b"\x01"+b"flag_server"))
The flag is used when msg
starts with \x03
.
def send_flag(self):
with open("flag.txt", "r") as f:
flag = f.read().strip()
curl("example.com", f"/{flag}", self.dns)
def 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
The flag is sent to http://example.com/<flag>
and the domain example.com
is resolved using the dns
object. We must then gain control of the nameserver of dns
, so that example.com
resolves to a URL controlled by us, sending us the flag.
In order to change the dns.nameservers
value, we must send a message that starts with \x02
. However, we cannot send our message directly, since it needs to be encrypted using ChaCha20-Poly1305
with a key we do not know.
Solution
The server uses ChaCha20-Poly1305
. We noticed that the nonce is equal to sha256(msg[:32] + nonce[:32])[:12]
. However, since we control the value of msg
, and the nonce
is the result of get_entropy_from_lavalamps
which as we have seen before can always be the same, this implies that we can ensure that the nonce used in ChaCha20-Poly1305
is always the same and perform a reused-nonce attack.
We just need to find a way of generating a valid tag for the encrypted message we want to send to FlagServer
.
Poly1305
works by creating a polynomial over \(\mathbb{F}_p\), with prime \(p = 2^{130} - 5\).
where the \(c_i\)’s are the bytes of the message to be authenticated in 16-byte blocks.
The tag is then generated as
\[tag = (P(r) + s) \pmod{2^{128}}\]where \(r,s\) are the secret key values.
However, in our case, we can encrypt with constant key and nonce, therefore
\[t = (\sum_{n=1}^{q} c_ir^{q-i} \pmod p + s) \pmod{2^{128}}\] \[t' = (\sum_{n=1}^{q} c_i'r^{q-i} \pmod p + s) \pmod{2^{128}}\]To get rid of \(\pmod {2^{128}}\)
\[t = (\sum_{n=1}^{q} c_ir^{q-i} \pmod p + s) + k_1 \cdot 2^{128}\] \[t' = (\sum_{n=1}^{q} c_i'r^{q-i} \pmod p + s) + k_2 \cdot 2^{128}\]with \(0 \leq k1,k2 \leq 5\). The possible values for each \(k_i\) are small due to the fact that \(3\cdot 2^{128} \leq p \leq 4\cdot 2^{128}\). In order to eliminate \(s\),
\[t - t' = \sum_{n=1}^{q} (c_i - c_i')r^{q-i} \pmod p + (k_1 - k_2) \cdot 2^{128}\]Therefore we can test the possible \(k_1, k_2\) values and solve the equation above for \(r\). By looking at the source code of the ChaCha20-Poly1305
libraries, we also added one extra test for r
: r & 0x0ffffffc0ffffffc0ffffffc0fffffff == r
, as r
is clamped before being used.
Once we know \(r\), we know \(s\), since
\[s = tag - P(r) \pmod{2^{128}}\]Having this, we can then forge a valid tag for any message of our choice and therefore change the dns.nameservers
variable and get the flag.
Conclusion
Finally, after being able to change the dns.nameservers
variable, we get the flag. The solution script can be found here.
Flag: PCTF{d0nt_r3u5e_th3_n0nc3_d4839ed727736624}