title: Writeup DG'hAck 2021 - Secure FTP Over UDP
date: Nov 27, 2021
tags: DGhAck writeups programming
This challenge is splitted in 3 parts.
Dev, 77 validations
Description:
Votre société a acheté un nouveau logiciel de serveur FTP. Celui-ci est un peu spécial et il n'existe pas de client pour Linux !
À l'aide de la documentation fournie ci-dessous, implémentez un client allant jusqu'à l'établissement d'une session.
Accès à l'épreuve
URL: udp://secure-ftp.dghack.fr:4445/
Files:
dghack2021-secure-ftp-over-udp-protocol.md
sha256: 45dbfa59cb127d8148ba9c07c7378f0ccc0cb18d348f4977b140b8b1365dafcd
Here we are with a documentation of a FTP protocol over UDP, and we need to establish a session. Let's see how it works.
Let's see each section one by one. Note that I will be programming the entire client in Python
.
In the first 2 bits of the header (little endian), we have the number of bytes of the lenght of data. The other 14 bits are used to store the packet type.
Here is a example with a size of 2 and a type of 1921.
from struct import pack
size = b"10" # 2
type = 1921 # ConnectMessage
# size length
header = len(size)
print("size: " + bin(header)[2:].rjust(16, "0"))
print("type: " + bin(type)[2:].rjust(14, "0").ljust(16, "0"))
# message type
header+= type << 2
print("header: " + bin(header)[2:].rjust(16, "0"))
header = pack("!I", header).lstrip(b"\x00").rjust(2, b"\x00") # convert to Big endian and pack into 2 bytes
Result:
size: 0000000000000010
type: 0001111000000100
header: 0001111000000110
The final header is sent in big endian format.
It's just the lenght of content with the null bytes stripped:
content = "Sample content"
# size of data
size = pack("!I", len(content)).lstrip(b"\x00")
As the same as before, the bytes are in big endian.
We need to add the size of content before the content. There is a example.
5Hello
If we need to have multiple Strings, we can just concat them like that:
5Hello6World!
To compute the checksum of the whole packet, we can use the zlib
library like so:
from zlib import crc32
computed_crc = pack("!I", crc32(header + size + content))
Nice! Now we can create packets for the server. There is a full code snippet to send a connect message to the server.
#!/bin/python3
import socket
from zlib import crc32
from struct import pack
# constants
serverAddressPort = ("secure-ftp.dghack.fr", 4445)
bufferSize = 2048
# Create a UDP socket at client side with timeout of 0.5s
UDPClientSocket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
UDPClientSocket.settimeout(0.5)
# Messages types
messagesType = {"RsaKeyMessage": 78,
"PingMessage": 10,
"AuthMessage": 4444,
"GetFileMessage": 666,
"GetFilesMessage": 45,
"ErrorMessage": 1,
"ConnectMessage": 1921,
"SessionKeyMessage": 1337,
"RsaKeyReply": 98,
"ConnectReply": 4875,
"PingReply": 11,
"SessionKeyReply": 1338,
"AuthReply": 6789,
"GetFileReply": 7331,
"GetFilesReply": 46}
messagesKeyList = list(messagesType.keys())
messagesValueList = list(messagesType.values())
# Send message of type 'type' to the server, then receive and parse the reply from the server. Retry up to 5 times
def sendMessage(idType, data, timeout = 4):
print(f"\n\033[32mSend message: {idType}\033[39m")
# serialize data
if type(data) is str or type(data) is bytes:
# append the lenght of string at the beginning
content = pack("!I", len(data)).lstrip(b"\x00").rjust(2, b"\x00")
if type(data) is bytes:
content+= data
else:
content+= data.encode()
# send strings if tuple
elif type(data) is tuple:
content = b""
for el in data:
content+= pack("!I", len(el)).lstrip(b"\x00").rjust(2, b"\x00")
if type(el) is bytes:
content+= el
else:
content+= el.encode()
else:
content = b""
# size of data
size = pack("!I", len(content)).lstrip(b"\x00")
# create header
header = b""
if idType in messagesType:
# size length
header = len(size)
# message type
header+= messagesType[idType] << 2
header = pack("!I", header).lstrip(b"\x00").rjust(2, b"\x00")
# compute the CRC control sum
computed_crc = pack("!I", crc32(header + size + content))
UDPClientSocket.sendto(header + size + content + computed_crc, serverAddressPort)
packet = header + size + content + computed_crc
print(f"[DEBUG] sent: ", end="")
print(packet)
sendMessage("ConnectMessage", "CONNECT")
print("[DEBUG] received: ", end="")
print((UDPClientSocket.recvfrom(bufferSize))[0])
Result:
Send message: ConnectMessage
[DEBUG] sent: b'\x1e\x05\t\x00\x07CONNECT\xcf\x9d\xaeg'
[DEBUG] received: b'L-A\x00$77ffbc12-3532-499f-bcd9-a174332d1b3f\x00\x19DGA{746999b743b91605261e}\xaaGg\xb9'
Great! We can see the first flag in the response, and a session token. But we can't realy decypher the whole message and verify it's integrity using the CRC. For the next part, we will add a parser for the received messages. It will also be useful to test out crafted payloads.
Flag: DGA{746999b743b91605261e}
Dev, 45 validations
Description:
Suite de l'épreuve Secure FTP Over UDP 1
Votre société a acheté un nouveau logiciel de serveur FTP. Celui-ci est un peu spécial et il n'existe pas de client pour Linux !
À l'aide de la documentation fournie ci-dessous, implémentez un client allant jusqu'à la connexion utilisateur.
Accès à l'épreuve
URL: udp://secure-ftp.dghack.fr:4445/
Files:
dghack2021-secure-ftp-over-udp-protocol.md
sha256: 45dbfa59cb127d8148ba9c07c7378f0ccc0cb18d348f4977b140b8b1365dafcd
Ok, now we need to create a function to parse responses from the server. Since it's just the oposite thing than for creating the packet, I will not describe all the process, here is the code:
print("\n\033[32mReceive message... \033[39m", end="")
msg = (UDPClientSocket.recvfrom(bufferSize))[0]
print(msg)
# parse header, lenSize, size, content and CRC
header = msg[0:2]
idMessage = (int.from_bytes(header, byteorder="big") & 0b1111111111111100) >> 2
idType = messagesKeyList[messagesValueList.index(idMessage)]
sizeLen = int.from_bytes(header, byteorder="big") & 0b11
size = int.from_bytes(msg[2:2 + sizeLen], byteorder="big")
message = msg[2 + sizeLen: 2 + sizeLen + size]
print("\033[32m" + idType + "\033[39m")
# cut message if multiple string or array
messageTmp = message
computedMessage = []
while len(messageTmp) != 0:
tmpSize = int.from_bytes(messageTmp[:2], byteorder="big")
computedMessage.append(messageTmp[2:tmpSize + 2])
messageTmp = messageTmp[tmpSize + 2:]
# compute the CRC value
crc = msg[-4:]
computed_crc = pack("!I", crc32(msg[:-4]))
if computed_crc == crc:
is_valid = "Valid"
else:
is_valid = "Invalid"
# print debug informations
#print_debug(f"[DEBUG] idMessage: {idMessage}")
print(f"[DEBUG] type: {idType} ({idMessage})")
#print_debug(f"[DEBUG] SizeLen: {sizeLen}")
print(f"[DEBUG] Size: {size}")
print(f"[DEBUG] message: {computedMessage}")
#print_debug(int.from_bytes(message[:2], byteorder="big"))
print(f"[DEBUG] CRC: {is_valid}")
let's add it to the sendMessage
function and return from the function the message and the type. You can have the full code at the end for reference.
If we now send sendMessage("ConnectMessage", "CONNECT")
, There is what we get:
Looks great! It wil now be so much easer to send and receive messages. The last big obstacle remaining is encryption and decryption.
We will also need to un-xor the RSA key sent by the server at the next step.
To authenticate ourself, we need to:
ConnectMessage
with content CONNECT
Session ID
RsaKeyMessage
with content Session ID
servPubKey
SessionKeyMessage
with content Session ID
and a generated AES 256 bits key encrypted using servPubKey
10 octets salt
encrypted using our AES keyAuthMessage
with content Session ID
, encrypted salt
, encrypted username
(GUEST_USER) and encrypted password
(GUEST_PASSWORD).AUTH_OK
message and the second flagAlready made:
# get session token
(sessionID, idType) = sendMessage("ConnectMessage", "CONNECT")
if idType != "ConnectReply":
exit(1)
we need to add this to sendMessage
in order to handle the unxoring of the key:
if idType == "RsaKeyReply":
computedMessage[0] = xor_rsa_key(computedMessage[0])
And we also need to create the xor_rsa_key
function:
from pwn import xor
# decrypt RSA public key using given xor key
def xor_rsa_key(encoded):
encoded = b64decode(encoded)
pattern = b"ThisIsNotSoSecretPleaseChangeIt"
decoded = xor(encoded, pattern)
return decoded
We can now get the server public key as follow:
# get Public server RSA key
(servPubKey , idType) = sendMessage("RsaKeyMessage", sessionID[0])
if idType != "RsaKeyReply":
exit(1)
We can use the server RSA key with the Crypto.Cipher
library to encrypt our message:
# Compute new AES key
AES_key = get_random_bytes(32)
# Encrypt AES Key with Server public RSA Key
keyPub = RSA.importKey(servPubKey[0])
cipher = Cipher_PKCS1_v1_5.new(keyPub)
cipher_text = cipher.encrypt(AES_key)
cipher_text = b64encode(cipher_text)
# id session + 256 bit AES key -> encrypted with servPubKey -> base64
(msg, idType) = sendMessage("SessionKeyMessage", (sessionID[0], cipher_text))
enc_salt = msg[0]
We now have to deal with AES encryption and decrytion. We can create a function who will decrypt a base64 encoded message and a function who will encrypt and base64 a message:
from Crypto.Cipher import AES
from base64 import b64decode, b64encode
# Decrypt message received from server
def decryptMessageB64(AES_key, dec):
dec = b64decode(dec)
IV = dec[:16]
dec = dec[16:]
cipher = AES.new(AES_key, AES.MODE_CBC, IV)
dec = cipher.decrypt(dec)
return dec, IV
# Encrypt message to send to server
def encryptMessageB64(AES_key, enc, IV):
cipher = AES.new(AES_key, AES.MODE_CBC, IV)
enc = IV + cipher.encrypt(pad(enc).encode())
return b64encode(enc)
And then:
enc_salt = msg[0]
# get salt
(salt, IV) = decryptMessageB64(AES_key, enc_salt)
# Send auth message
username_enc = encryptMessageB64(AES_key, username, IV)
password_enc = encryptMessageB64(AES_key, password, IV)
(msg, idType) = sendMessage("AuthMessage", (sessionID[0], msg[0], username_enc, password_enc))
if idType != "AuthReply":
exit(1)
Ok, we can now test our whole code and try to authenticate and getting the flag.
The code used can be found here: auth_script.py
Flag: DGA{bc3fc7a1a08d5749aa01}
Dev, 44 validations
Description:
Suite de l'épreuve Secure FTP Over UDP 2
Votre société a acheté un nouveau logiciel de serveur FTP. Celui-ci est un peu spécial et il n'existe pas de client pour Linux !
À l'aide de la documentation fournie ci-dessous, implémentez un client allant jusqu'à la récupération du dernier flag stocké dans un fichier.
Accès à l'épreuve
URL: udp://secure-ftp.dghack.fr:4445/
Files:
dghack2021-secure-ftp-over-udp-protocol.md
sha256: 45dbfa59cb127d8148ba9c07c7378f0ccc0cb18d348f4977b140b8b1365dafcd
The documentation say that the flag is stored into /opt/
. We will need to use GetFilesMessage
and GetFileMessage
commands.
Code to request the content of /opt/
:
# get files in /opt/
path = "/opt/"
path_enc = encryptMessageB64(AES_key, path, IV)
(path_enc, __) = sendMessage("GetFilesMessage", (sessionID[0], path_enc))
# decode path
(path, IV) = decryptMessageB64(AES_key, path_enc[0])
# Print folder content
print("\n" + unpad(path.decode()).rstrip("\x00"))
Result:
There is only one element, dga2021. If we try to use the GetFileMessage
on /opt/dga2021
, the server just don't respond. Maybe it's a folder?
We try to get the content of /opt/dga2021
:
it was a folder! We can now get the content of this file like so:
path = "/opt/dga2021/flag"
path_enc = encryptMessageB64(AES_key, path, IV)
# Read file /opt/dga2021/flag
(file_content_enc, __) = sendMessage("GetFileMessage", (sessionID[0], path_enc))
# decode file content
(file_content, IV) = decryptMessageB64(AES_key, file_content_enc[0])
# Third flag
flag3 = file_content.rstrip(b"\x08").rstrip(b"\x07").rstrip(b"\x06").rstrip(b"\x05").decode()
print(f"\n\033[33m[+] FLAG 3: {flag3}\033[39m")
Result:
We get the last flag!
Flag: DGA{222df851d8a68bda4a85}
Thank you for reading all through the end!
Since the UDP protocol does not guarantee the integrity of data transmitted, Program can crash due to wrong data received. I added a try catch statement and a retry system when data sent or received is malformated.
#!/bin/python3
from pwn import args
import socket
from zlib import crc32
from struct import pack
from base64 import b64decode, b64encode
from pwn import xor
from Crypto.Cipher import AES
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5 as Cipher_PKCS1_v1_5
from Crypto.Random import get_random_bytes
# constants
serverAddressPort = ("secure-ftp.dghack.fr", 4445)
bufferSize = 2048
username = "GUEST_USER"
password = "GUEST_PASSWORD"
# padding for AES encryption and decryption
BS = 16
pad = lambda s: s + (BS - len(s) % BS) * chr(BS - len(s) % BS)
unpad = lambda s : s[0:-ord(s[-1])]
# Create a UDP socket at client side with timeout of 0.5s
UDPClientSocket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
UDPClientSocket.settimeout(0.5)
# Messages types
messagesType = {"RsaKeyMessage": 78,
"PingMessage": 10,
"AuthMessage": 4444,
"GetFileMessage": 666,
"GetFilesMessage": 45,
"ErrorMessage": 1,
"ConnectMessage": 1921,
"SessionKeyMessage": 1337,
"RsaKeyReply": 98,
"ConnectReply": 4875,
"PingReply": 11,
"SessionKeyReply": 1338,
"AuthReply": 6789,
"GetFileReply": 7331,
"GetFilesReply": 46}
messagesKeyList = list(messagesType.keys())
messagesValueList = list(messagesType.values())
# use -DEBUG for verbose output
def print_debug(msg, end = "\n"):
if args.VERBOSE:
print(msg, end=end)
# Send message of type 'type' to the server, then receive and parse the reply from the server. Retry up to 5 times
def sendMessage(idType, data, timeout = 4):
try:
print_debug(f"\n\033[32mSend message: {idType}\033[39m")
# serialize data
if type(data) is str or type(data) is bytes:
# append the lenght of string at the beginning
content = pack("!I", len(data)).lstrip(b"\x00").rjust(2, b"\x00")
if type(data) is bytes:
content+= data
else:
content+= data.encode()
# send array if list, never used
elif type(data) is list:
content = b""
totallen = -1
for el in data:
if type(el) is bytes:
content+= el + b"\x00"
else:
content+= el.encode() + b"\x00"
totallen+= len(el) + 1
content = content[:-1]
content = pack("!I", totallen).lstrip(b"\x00").rjust(2, b"\x00") + content
# send strings if tuple
elif type(data) is tuple:
content = b""
for el in data:
content+= pack("!I", len(el)).lstrip(b"\x00").rjust(2, b"\x00")
if type(el) is bytes:
content+= el
else:
content+= el.encode()
else:
content = b""
# size of data
size = pack("!I", len(content)).lstrip(b"\x00")
# create header
header = b""
if idType in messagesType:
# size length
header = len(size)
# message type
header+= messagesType[idType] << 2
header = pack("!I", header).lstrip(b"\x00").rjust(2, b"\x00")
# compute the CRC control sum
computed_crc = pack("!I", crc32(header + size + content))
UDPClientSocket.sendto(header + size + content + computed_crc, serverAddressPort)
packet = header + size + content + computed_crc
print_debug(f"[DEBUG] sent: ", end="")
print_debug(packet)
# RECEIVING
print("\n\033[32mReceive message... \033[39m", end="")
msg = (UDPClientSocket.recvfrom(bufferSize))[0]
print_debug(msg)
# parse header, lenSize, size, content and CRC
header = msg[0:2]
idMessage = (int.from_bytes(header, byteorder="big") & 0b1111111111111100) >> 2
idType = messagesKeyList[messagesValueList.index(idMessage)]
sizeLen = int.from_bytes(header, byteorder="big") & 0b11
size = int.from_bytes(msg[2:2 + sizeLen], byteorder="big")
message = msg[2 + sizeLen: 2 + sizeLen + size]
print("\033[32m" + idType + "\033[39m")
# cut message if multiple string or array
messageTmp = message
computedMessage = []
while len(messageTmp) != 0:
tmpSize = int.from_bytes(messageTmp[:2], byteorder="big")
computedMessage.append(messageTmp[2:tmpSize + 2])
messageTmp = messageTmp[tmpSize + 2:]
if idType == "RsaKeyReply":
computedMessage[0] = xor_rsa_key(computedMessage[0])
# compute the CRC value
crc = msg[-4:]
computed_crc = pack("!I", crc32(msg[:-4]))
if computed_crc == crc:
is_valid = "Valid"
else:
is_valid = "Invalid"
# print debug informations
#print_debug(f"[DEBUG] idMessage: {idMessage}")
print_debug(f"[DEBUG] type: {idType} ({idMessage})")
#print_debug(f"[DEBUG] SizeLen: {sizeLen}")
print_debug(f"[DEBUG] Size: {size}")
print_debug(f"[DEBUG] message: {computedMessage}")
#print_debug(int.from_bytes(message[:2], byteorder="big"))
print_debug(f"[DEBUG] CRC: {is_valid}")
return computedMessage, idType
except socket.timeout:
if timeout == 0:
print("\033[31mRetry limit exceeded, exiting.\033[39m")
exit(1)
else:
print("\033[31mError while receiving, retrying.\033[39m")
return sendMessage(idType, data, timeout - 1)
# decrypt RSA public key using given xor key
def xor_rsa_key(encoded):
encoded = b64decode(encoded)
pattern = b"ThisIsNotSoSecretPleaseChangeIt"
decoded = xor(encoded, pattern)
return decoded
# Encrypt message to send to server
def encryptMessageB64(AES_key, enc, IV):
cipher = AES.new(AES_key, AES.MODE_CBC, IV)
enc = IV + cipher.encrypt(pad(enc).encode())
return b64encode(enc)
# Decrypt message received from server
def decryptMessageB64(AES_key, dec):
dec = b64decode(dec)
IV = dec[:16]
dec = dec[16:]
cipher = AES.new(AES_key, AES.MODE_CBC, IV)
dec = cipher.decrypt(dec)
return dec, IV
"""
Main Flow
"""
# get session token
(sessionID, idType) = sendMessage("ConnectMessage", "CONNECT")
if idType != "ConnectReply":
exit(1)
# First flag
flag1 = sessionID[1].decode()
print(f"\n\033[33m[+] FLAG: {flag1}\033[39m\n")
# get Public server RSA key
(servPubKey , idType) = sendMessage("RsaKeyMessage", sessionID[0])
if idType != "RsaKeyReply":
exit(1)
# Compute new AES key
AES_key = get_random_bytes(32)
# Encrypt AES Key with Server public RSA Key
keyPub = RSA.importKey(servPubKey[0])
cipher = Cipher_PKCS1_v1_5.new(keyPub)
cipher_text = cipher.encrypt(AES_key)
cipher_text = b64encode(cipher_text)
# id session + 256 bit AES key -> encrypted with servPubKey -> base64
(msg, idType) = sendMessage("SessionKeyMessage", (sessionID[0], cipher_text))
enc_salt = msg[0]
# get salt
(salt, IV) = decryptMessageB64(AES_key, enc_salt)
# Send auth message
username_enc = encryptMessageB64(AES_key, username, IV)
password_enc = encryptMessageB64(AES_key, password, IV)
(msg, idType) = sendMessage("AuthMessage", (sessionID[0], msg[0], username_enc, password_enc))
if idType != "AuthReply":
exit(1)
# Second flag
flag2 = msg[1].decode()
print(f"\n\033[33m[+] FLAG: {flag2}\033[39m\n")
# get files in /opt/
path = "/opt/"
path_enc = encryptMessageB64(AES_key, path, IV)
(path_enc, __) = sendMessage("GetFilesMessage", (sessionID[0], path_enc))
# decode path
(path, IV) = decryptMessageB64(AES_key, path_enc[0])
# Print folder content
print(unpad(path.decode()).rstrip("\x00"))
# get files in /opt/dga2021/
path = "/opt/dga2021/"
path_enc = encryptMessageB64(AES_key, path, IV)
(path_enc, __) = sendMessage("GetFilesMessage", (sessionID[0], path_enc))
# decode path
(path, IV) = decryptMessageB64(AES_key, path_enc[0])
# Print folder content
print(unpad(path.decode()).rstrip("\x00"))
path = "/opt/dga2021/flag"
path_enc = encryptMessageB64(AES_key, path, IV)
# Read file /opt/dga2021/flag
(file_content_enc, __) = sendMessage("GetFileMessage", (sessionID[0], path_enc))
# decode file content
(file_content, IV) = decryptMessageB64(AES_key, file_content_enc[0])
# Third flag
flag3 = file_content.rstrip(b"\x08").rstrip(b"\x07").rstrip(b"\x06").rstrip(b"\x05").decode()
# Win!!
print(f"\n\033[33m[+] FLAG 1: {flag1}\033[39m")
print(f"\n\033[33m[+] FLAG 2: {flag2}\033[39m")
print(f"\n\033[33m[+] FLAG 3: {flag3}\033[39m")
Example of output:
Example of verbose output: