keyboard_arrow_up

title: Writeup DG'hAck 2021 - Secure FTP Over UDP
date: Nov 27, 2021
tags: DGhAck writeups programming


Writeup DG'hAck 2021 - Secure FTP Over UDP

This challenge is splitted in 3 parts.


Part 1 (100 points)

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

description_1


The protocol

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.

Paquet structure

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.

Size

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.

content

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!

CRC

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}


Part 2 (100 points)

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

description_2


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:
first_receive

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:


Connect message

Already made:

# get session token
(sessionID, idType) = sendMessage("ConnectMessage", "CONNECT")
if idType != "ConnectReply":
    exit(1)

RSA key message

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)

Session key message

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]

Auth message

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

auth

Flag: DGA{bc3fc7a1a08d5749aa01}


Part 3 (100 points)

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

description_3


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:
get_files_opt

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: get_files_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:
get_flag

We get the last flag!

Flag: DGA{222df851d8a68bda4a85}

Thank you for reading all through the end!


Full code

client.py

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_script

Example of verbose output:
example_script_verbose