FCSC 2024 CTF - PTSD Init

"Main"

On nous fournit le binaire server, ainsi que 3 fichier de test redacted (keys.db, lv1.flag, …) 2 crypto sont également fournit : libcrypto et libssl. Il est nécéssaire de les utiliser, sinon la communication avec le serveur est impossible : taille de clé pas simillaire, … (ou alors il faut recompiler openssl 3.0.0)

Je pensais que LD_LIBRARY_PATH suffirait pour ce challenge, malheuresement EVP_PKEY_fromdata_init semble crasher quoiqu’il arrive avec le setup actuel, j’ai du faire des horreurs…

Analyse du server

"Main"

Le main est plutot petit, c’est un wrapper vers d’autres fonctions. Chaque fonction renvoie un retcode qui en fonction de son succès permet d’accéder à la suite ou non.

La 1ère fonction appelé est openkeys() qui va ouvrir ./data/keys.db Elle renvoie une structure qui contients les clés (ici 2) et le nombre de clés. La prochaine fonction qui est appelé est init_secure_channels()

"Main"

Au tout début, le serveur va initialiser de son coté une pair de clé ECC via generate_ecc_key() la génération de clé se fait via les courbes elliptiques. Il effectuera :

EVP_PKEY *ppkey;
EVP_PKEY *pkey = NULL;

EVP_PKEY_CTX * evx = EVP_PKEY_CTX_new_id(408, NULL);
EVP_PKEY_paramgen_init(evx);
EVP_PKEY_CTX_set_ec_paramgen_curve_nid(evx, 415);
EVP_PKEY_paramgen(evx, &ppkey);

//printf("ppkey = 0x%lx\n", ppkey);

EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new(ppkey, 0);
EVP_PKEY_keygen_init(ctx);
EVP_PKEY_keygen(ctx, &pkey);

//printf("pkey = 0x%lx\n", pkey);

Une 2ème fonction est immédiatement appelé afin d’obtenir la clé publique de la paire de clés.


ssize_t pubkeysize;
EVP_PKEY_get_octet_string_param(pkey, "pub", 0LL, 0LL, &pubkeysize);

char * pubkeybuf = CRYPTO_malloc(pubkeysize, "src/ecdh.c", 128LL);
EVP_PKEY_get_octet_string_param(pkey, "pub", pubkeybuf, 0x41, &pubkeysize);

printf("our pubkey(%d):\n-->", pubkeysize);
for (int i = 0; i < pubkeysize; ++i ) {
    printf("%02hhX", (uint8_t)pubkeybuf[i]);
}
printf("\n");

Cette manipulation peut se faire en python via :

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes

# 1. Génération de la paire de clés ECC avec la courbe prime256v1
private_key = ec.generate_private_key(ec.SECP256R1())

# 2. Obtention de la clé publique
public_key = private_key.public_key().public_bytes(
    encoding=Encoding.X962,
    format=PublicFormat.UncompressedPoint
)

Les clés sont ensuites parsé 1 par 1 dans le main. La première clé ne sert à rien, car elle vaut FFFFFFFFFF, elle est donc skip. La 2ème clé va être utilisé pour l’établissement d’une connection avec le client.

La 1ère fonction appelé après l’intialisation des clés cotés serveurs est sendata() avec passé en argument notre structure de context, et la clé publique ECC du serveur.

"Main"

Nous pouvons déja créer la structure request que l’on pouvons définir :

struct __attribute__((packed)) __attribute__((aligned(1))) request
{
  __int16 channel;
  __int16 req_count;
  char action;
  char size;
  __int64 data;
};
  • Channel est un id qui est propre à la communication actuelle. Dans notre cas il est de 6 et le restera.
  • req_count est un entier qui augmente à chaque communication de paquet, il doit être augmenté coté client également pour ne pas se faire fermer la connection par le serveur.
  • action est un id qui définit une action (send data, ack, …), en réalité il sert juste a vérifier la cohérence du paquet reçu.
  • size : taille de la donnée
  • data: donnée brut

"Main"

Le format d’envoie de la donnée est un peu particulier (chiffre casté en str au lieu de bytes).

coté client, on peut déja réceptionner la clé publique du serveur et la parser :


p= remote("challenges.france-cybersecurity-challenge.fr", 2251)

def get_pubkey_from_msg(msg:bytes):
    channel = msg[0:2]
    assert(channel == '06')

    rcount = msg[2:6]
    action = msg[6:8]
    
    size = msg[8:10]
    pubkey = msg[10:]
    
    assert(int(size, 16) == len(pubkey)//2)
    return binascii.unhexlify(pubkey)


p.recvuntil(b"SEND: ")

rsend = p.recvline().strip().decode()

pubkey_bytes_server = get_pubkey_from_msg(rsend)
server_public_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), pubkey_bytes_server)

Le serveur demande ensuite un ACK de réception, on doit donc lui envoyer le paquet réseau suivant:

response = "06" #channel
response += "0002" #req_count
response += "02" #action=recvack
response += "01" #size data
response += '01' #must be equals to 01 pour preauth/recvack

p.sendline(response.encode())
p.recvline()

Le serveur attend ensuite la clé publique du client. On peut en générer une et l’envoyer.


private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key().public_bytes(
    encoding=Encoding.X962,
    format=PublicFormat.UncompressedPoint
)

pubkey_hex = binascii.hexlify(public_key).decode()
assert(len(pubkey_hex) == len(public_key)*2)

response = "06" #channel
response += "0003" #req_count
response += "04" #action
response += ssize #size data
response += pubkey_hex #data

Le serveur ensuite renvoie un ACK de réception à son tour, que l’on peut réceptionner (pas d’utilité de notre coté)

Le serveur ensuite passe par gen_sharedkey() qui permet de dériver notre clé publique et ses clés pour faire une clé commune.

"Main"

De mon coté, je voulais faire la même chose avec mon script de résolution en C mais comme mon setup openssl est cassé, il m’étais impossible de générer une pkey sans faire crash le programme. (et j’en ai perdu du temps **)

en python on peut donc également générer notre clé commune (C’est du diffie hellman) :

pubkey_bytes_server = get_pubkey_from_msg(rsend)
server_public_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), pubkey_bytes_server)
shared_key = private_key.exchange(ec.ECDH(), server_public_key)

# Hash the shared key using SHA1
sha1 = hashes.Hash(hashes.SHA1(), backend=default_backend())
sha1.update(shared_key)
hashed_key = sha1.finalize()
truncated_key = hashed_key[:16]

Afin de vérifier que le client a bien initialiser le protocole réseau, le serveur envoie ‘HELOEHLO’, qu’il va chiffrer avec la clé commune. Puis demande un ACK de réception.

Cette partie m’a fait perdre desespoir plus d’une fois, j’arrivais a déchiffrer facilement le message, mais pas la validation du tag (signature du message). Le serveur va update le message avec:

"Main"

Ici le serveur génére un IV de taille 16, puis initialise le chiffrement avec la clé commune et l’iv. Une première update est fait avec un buffer de taille 2, qui est en fait le req_count actuel.

cependant, en python je n’ai jamais réussi a faire cette update sans casser le chiffrement ou valider le tag.

Pour la réception d’un msg serveur, cela ne pose pas de problème car je veux juste le contenu, mais pour envoyer un msg chiffré que demande le serveur pour authentifier la communication, je me fais fermer la connection car mon tag n’est pas valide.

J’ai du donc faire une pirouette entre C et python :

def get_param_from_msg(msg:bytes):
    channel = msg[0:2]
    assert(channel == '06')

    rcount = msg[2:6]
    action = msg[6:8]
    
    c_a1 = 8+(0xc*2)
    c_a2 = c_a1 + (0x10*2)

    iv = msg[8:c_a1]
    tag = msg[c_a1:c_a2]
    size= msg[c_a2:c_a2+2]
    cipher = msg[c_a2+2:]

    
    assert(int(size, 16) == len(cipher))
    return iv, tag, cipher, size

iv, tag, cipher_, size = get_param_from_msg(cipher)
iv_ = binascii.unhexlify(iv)
tag_ = binascii.unhexlify(tag)
cipher_bytes = binascii.unhexlify(cipher_)
cipher = Cipher(algorithms.AES(truncated_key
                            ), modes.GCM(iv_, tag_), backend=default_backend())

decryptor = cipher.decryptor()
decrypted_msg = decryptor.update(cipher_bytes)
#msg_uncipher = HELOOHLE

code = "char sharedkey[16] = {"
for i in range(len(truncated_key)-1):
    v = truncated_key[i]
    code += hex(v)+","

v = truncated_key[-1]
code += hex(v)+'};\n'

print(code) #char sharedkey[16] = {...};
    char * cipher = malloc(9);
    uint32_t size_d;
    uint32_t unused;

    // oui c'est sale
    char sharedkey[16] = {0xcb,0x9c,0xba,0xc3,0xea,0xa9,0x1a,0xb3,0x82,0xe8,0x63,0xc2,0x78,0xdc,0x42,0x19};
    char iv[0xC] = {0x05,0xB3, 0x10, 0xA9, 0xC6, 0xF7, 0x7F, 0xD1, 0x51, 0x54, 0xBA, 0x45};
    char tag[16] = {0};
    char msg[8] = "HELOAAAA";

    EVP_CIPHER_CTX *encryptor = EVP_CIPHER_CTX_new();

    unsigned char inbuf[1000] = {0};
    inbuf[0] = 7; //reqcount=7

    EVP_EncryptInit_ex(encryptor, EVP_aes_128_gcm(), 0, 0, 0);
    EVP_EncryptInit_ex(encryptor, 0, 0, sharedkey, iv);

    EVP_EncryptUpdate(encryptor, NULL, &unused, inbuf,2);  
    EVP_EncryptUpdate(encryptor, cipher, &size_d, msg, 8);
    EVP_EncryptFinal_ex(encryptor, (char*)msg+size_d+1, &unused);

    EVP_CIPHER_CTX_ctrl(encryptor, 16, 16, tag);

    printf("TOSEND-->06000709");
    for (int i = 0; i < 0xC; ++i ) {
        printf("%02hhX", (uint8_t)iv[i]);
    }
    for (int i = 0; i < 16; ++i ) {
        printf("%02hhX", (uint8_t)tag[i]);
    }
    printf("08");
    for (int i = 0; i < 8; ++i ) {
        printf("%02hhX", (uint8_t)cipher[i]);
    }
    printf("\n");
  

Il faut également envoyer un ACK au serveur pour sa bonne réception. Le serveur accepte donc notre message chiffré (salement, désolé)

On a finalement le serveur qui va ouvrir lv1flag et l’envoyer sous forme de paquet chiffré. On peut utiliser la clé commune et obtenir le flag de PTSD Init!

Script finale Python:

from pwn import *
import binascii
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes


"""
clé : 

idchannel:VINT:VSTR:cléBYTES:cléSTR

"""

p= remote("challenges.france-cybersecurity-challenge.fr", 2251)

def get_param_from_msg(msg:bytes):
    channel = msg[0:2]
    assert(channel == '06')

    rcount = msg[2:6]
    action = msg[6:8]
    
    c_a1 = 8+(0xc*2)
    c_a2 = c_a1 + (0x10*2)

    iv = msg[8:c_a1]
    tag = msg[c_a1:c_a2]
    size= msg[c_a2:c_a2+2]
    cipher = msg[c_a2+2:]

    
    assert(int(size, 16) == len(cipher)//2)
    return iv, tag, cipher, size

def get_pubkey_from_msg(msg:bytes):
    channel = msg[0:2]
    assert(channel == '06')

    rcount = msg[2:6]
    action = msg[6:8]
    
    size = msg[8:10]
    pubkey = msg[10:]
    
    assert(int(size, 16) == len(pubkey)//2)
    return binascii.unhexlify(pubkey)


p.recvuntil(b"SEND: ")

rsend = p.recvline().strip().decode()

# 1. Génération de la paire de clés ECC avec la courbe prime256v1
private_key = ec.generate_private_key(ec.SECP256R1())

# 2. Obtention de la clé publique au format PEM
public_key = private_key.public_key().public_bytes(
    encoding=Encoding.X962,
    format=PublicFormat.UncompressedPoint
)


pubkey_bytes_server = get_pubkey_from_msg(rsend)
server_public_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), pubkey_bytes_server)
shared_key = private_key.exchange(ec.ECDH(), server_public_key)

# Hash the shared key using SHA1
sha1 = hashes.Hash(hashes.SHA1(), backend=default_backend())
sha1.update(shared_key)
hashed_key = sha1.finalize()
truncated_key = hashed_key[:16]



response = "06" #channel
response += "0002" #req_count
response += "02" #action
response += "01" #size data
response += '01' #must be equals to 01 pour preauth

print("tosend->" ,response)

p.sendline(response.encode())
p.recvline()



ssize = hex(len(public_key)).replace("0x","")
assert(len(ssize)==2)

pubkey_hex = binascii.hexlify(public_key).decode()
assert(len(pubkey_hex) == len(public_key)*2)

response = "06" #channel
response += "0003" #req_count
response += "04" #action
response += ssize #size data
response += pubkey_hex #data

print("tosend->" ,response)

p.recvline()
p.sendline(response.encode()) #send our pubkey
print(p.recvline())

response = "06" #channel
response += "0006" #req_count
response += "02" #action
response += "01" #size data
response += '01' #must be equals to 01 pour preauth

print("tosend->" ,response)

cipher = p.recvline().strip().decode().replace("SEND: " ,'')


iv, tag, cipher_, size = get_param_from_msg(cipher)

iv_ = binascii.unhexlify(iv)
tag_ = binascii.unhexlify(tag)
cipher_bytes = binascii.unhexlify(cipher_)


cipher = Cipher(algorithms.AES(truncated_key
                            ), modes.GCM(iv_, tag_), backend=default_backend())

decryptor = cipher.decryptor()


decrypted_msg = decryptor.update(cipher_bytes)


code = "char sharedkey[16] = {"
for i in range(len(truncated_key)-1):
    v = truncated_key[i]
    code += hex(v)+","

v = truncated_key[-1]
code += hex(v)+'};\n'

print(code)

p.sendline(response.encode())
print(p.recvline())
print(p.recvline())

r = input("cipher2>>>")

p.sendline(r.encode())
print(p.recvline())

flag= p.recvline().strip().decode().replace("SEND: " ,'')
iv, tag, cipher_, size = get_param_from_msg(flag)


iv_ = binascii.unhexlify(iv)
tag_ = binascii.unhexlify(tag)
cipher_bytes = binascii.unhexlify(cipher_)


cipher = Cipher(algorithms.AES(truncated_key
                            ), modes.GCM(iv_, tag_), backend=default_backend())

decryptor = cipher.decryptor()

decrypted_msg = decryptor.update(cipher_bytes)
print(decrypted_msg)

et au final :

"Main"

conclusion

Chall très sympathique, cela fait reviser la crypto + faire du protocole réseau custom.

La suite?

flemme :=)