NSEC21 ZenCastle - Fri, May 28, 2021 - Lixtelnis
Ticket to the ZenCastle | Crypto | Nsec21
ZenCastle
We are given a tcp server and a hint about jester
being a valid username.
Logging in
Having a look at the source code and wikipedia for the CFB block mode,
we seem to need to satisfy aes(sha256(X|client_challenge|server_challenge), 0) ⊕ auth_challenge = client_challenge
for - let’s say 16 byte - values where server_challenge is random but announced by the server, X is unknown and {client,auth}_challenge are values of our choice.
This can’t be right we would need to know the full aes block produced with an unkown sha256
key.
Not enough 禅 .
Maybe there’s a way to get decrypt("") == ""
? No, the only way to have file.read(16)
return an empty string is to close the connection and we need our connection.
We try some local values (why not zero) and find something odd
>>> cipher = AES.new(b"B"*16, AES.MODE_CFB, b"\x00"*16)
>>> cipher.decrypt(b"\x00"*16)
b'ssssssssssssssss'
Oops what happened here? Shouldn’t the output of aes look random on the full block?
Anyhow, this is enough for this first step. As long as auth_challenge == 0
and client_challenge
is the same byte repeated 16 times
we can log in by trying until the server let’s us in! (remember the key changes every connection)
The source code being given we setup the server locally changing TICKET_MESSAGE to “ALLO”.
from pwn import *
while True:
r = remote("localhost", 9090)
def tos(s):
return p32(len(s))+s
r.send(b"\x00"*16)
chall = r.recv(numb=16)
print(chall)
r.send(tos(b"jester"))
r.send(b"\x00"*16)
x = r.recv(numb=1)
if x != b"\x00":
break
r.recv(numb=4)
r.recv(numb=len("Auth failed !"))
r.close()
Leaking the flag
Ok, so we made progress and can now reach the following loop:
while True:
data = self.decrypt(key, self.read_string())
while not data == b"":
# Create a ticket
if data[0] == 1:
#ticketId = generate_new_ticket()
self.write_string(self.encrypt(key, "ALLO"))
data = data[1:]
# View a ticket status
elif data[0] == 2:
ticketId = data[1:17]
#if ticket_valid(ticketId):
if True:
self.write_string(self.encrypt(key, b"Ticket is valid."))
else:
self.write_string(self.encrypt(key, b"Ticket is not valid."))
data = data[17:]
# Command not found
else:
self.write_error(("Command '%d' not found." % data[0]).encode())
data = b""
We still don’t know the key and yet need to produce ciphertexts that decrypt to valid commands.
It’s time to have a look at the source code and figure out what this CFB mode does exactly. Code comments themselves refer to the spec which says that
Moreover by default s
is a single byte and b
is the size of a full aes block meaning that C_j
and P_j
are also single bytes (maybe that’s what # means who knows).
It turns out that wikipedia had this information all along we were just in to much of a hurry to notice. This mode is sometimes called CFB-8.
From having logged in, we know O_1 == client_challenge == aes(key,0)
(== 0
in our case)
and sending C_1 = client_challenge ⊕ 1
(or ⊕ 2
) will create a ticket (or validate a ticket).
Hurray! The server gives us an encrypted “ALLO” (well an encrypted flag on the real thing).
So what next? One thing we notice is the Command not found case
kindly tells us the value of the first decrypted byte (remember data[0]
came straight out of cipher.decrypt
).
Indeed, if we feed the encrypted ticked back we get Command A not found
.
Other observations are:
data[0]
does not always mean the first byte since after valid commands the server setsdata = data[17:]
ordata = data[1:]
.- on
ticket status
commands we control bytesC[1:17]
and the matching plaintext is skipped. I_j
isb"\x00"*16
then ciphertext bytes (bytes we control) are introduced at one end.
The plan is then to do a ticket status command with C[1:17] = I_j
, the next byte producing a Command not found
message with its value.
import re
flag = b""
ivflag = b"\x00"*16 + ticket
for j in range(len(ticket)):
r.send(tos(b"\x02" + ivflag[j:j+16+1]))
# skip response
l = u32(r.recv(numb=4))
r.recv(numb=l)
# parse leaked plaintext
l = u32(r.recv(numb=4))
t = r.recv(numb=l)
if b"Command" in t:
m = re.search('([0-9]+)', t.decode())
v = int(m.group(0))
flag += bytes([v])
print(flag)
That’s it, lean back while the flag appears byte-by-byte: FLAG-ac1d12cf22dac7ab1ee63764dd8cad0f
.