HeroCTF v6 - Halloween
Description
Write Up: Tyron
Créateur: alol
Difficulté: difficile
Points: 432
Format du flag: Hero{printable}
Enoncé
- Français: Boo! Est-ce que tu crois aux fantomes ? Moi non.
- English: Boo! Do you believe in ghost ? I sure don't.
Pièce(s) jointe(s):
Solution détaillée
Pour ce challenge, un serveur nous renvoie un message de bienvenu avec le flag chiffré l'intérieur. Ensuite, il va chiffrer chaque message (encodé en hexadécimal) qu'on lui envoie.
L'algorithme utilisé est une implémentation de GOST, un algorithme symétrique comme AES.
Code source du challenge
Le code du challenge est assez simple.
#!/usr/bin/env python3
import gostcrypto
import os
with open("flag.txt", "rb") as f:
flag = f.read()
key, iv = os.urandom(32), os.urandom(8)
cipher = gostcrypto.gostcipher.new(
"kuznechik", key, gostcrypto.gostcipher.MODE_CTR, init_vect=iv
)
print(f"It's almost Halloween, time to get sp00{cipher.encrypt(flag).hex()}00ky 👻!")
while True:
print(cipher.encrypt(bytes.fromhex(input())).hex())
- Une clé aléatoire de 32 octets est générée
- Un vecteur d'initialisation de 8 octets est généré
cipherest initialisé avec gostcrypto avec le mode CTR, la clé, l'IV et l'algorithme qu'on ne connait pas encore 'kuznechik'
Une fois ça, le serveur nous envoie une chaine de caractère avec le flag chiffré dedans, example :
It's almost Halloween, time to get sp00361e2084c41bd4ddf3748a49442d2c99670f6d235d2429e3d6e445f07d5415f2ebd38bee6bf50fecd23ae33db8a76c48d1ab7bb7591512b354762afbfa7153e494f8eb3bd93ad1151d3c36f4c1913400ky 👻!
Jusque là il n'y a pas de vulnérabilité qui saute au yeux alors il faudrait plonger dans le code source c'est un challenge difficile quand meme !)
Et après on peut envoyer des chaines de caractères encodées en hex et le serveur nous repondra sa version chiffrée.
Code source du module gostcrypto
Pour les modules python il suffit de regarder sur pypi. Après une recherche google rapide sur pypi on trouve cette page. Et dans "liens du projet" on retrouve un lien vers github avec tout le code source et une doc d'utilisation mais ca va pas nous servir.
Lecture des parties utilisées pour chiffrer
On va directement plonger dans le code source donc le module gostcrypto.gostcipher à cette page.
On a deux fichiers :
- gost_34_12_2015.py
- gost_34_13_2015.py
Dans le premier fichier après une lecture rapide on voit que ce sont ici que sont définis les algo 'kuznechik' et 'magma'. Ici on y a le code source de quand on va chiffrer un texte clair en mode ECB. C'est à dire un chiffrement classique sans mode, comme avec AES:
Dans le deuxième fichier, il y a tous les modes qui sont définis notamment le CTR à la ligne 802 dans la classe GOST34132015ctr : class GOST34132015ctr(GOST34132015Cipher):
Il contient les méthodes encrypt(), decrypt(), counter(), _inc_ctr().
La plus simple est la méthode decrypt() :
def decrypt(self, data: bytearray) -> bytearray:
"""
Ciphertext decryption in CTR mode.
Args:
data: Ciphertext data to be decrypted (as a byte object).
Returns:
Plaintext data (as a byte object).
Raises:
GOSTCipherError('GOSTCipherError: invalid ciphertext data'): In
case where the ciphertext data is not byte object.
"""
data = super().decrypt(data)
return self.encrypt(data)
super().decrypt(data) ne va pas altérer data, il correspond à la méthode decrypt() de la classe GOST34132015Cipher dont à hérité GOST34132015ctr qui revient à :
def decrypt(self, data: bytearray) -> bytearray:
if not isinstance(data, (bytes, bytearray)):
self.clear()
raise GOSTCipherError('GOSTCipherError: invalid ciphertext data')
return data
Donc super().decrypt(data) vérifie si la data est bien de type bytes ou bytearray et ensuite la donnée est chiffrée avec self.encrypt(data).
Chiffrer un texte chiffrée avec le mode CTR revient à le dechiffrer. C'est la particularité du mode CTR :
Le mode CTR est un chiffrement par flot ce qui signfie la chiffrée et xorer avec le clair et donc que la taille du clair est égale à la taille du chiffré. Si on connait le clair, on peut le xorer avec la chiffré pour retrouver le keystream et si on connait le keystream, on peut la xorer avec le chiffré pour récupérer le clair.
Ensuite, la méthode counter() renvoie juste le compteur et _inc_ctr() incrémente le compteur.
A l'initialisation de la classe, gostcrypto.gostcipher.new("kuznechik", key, gostcrypto.gostcipher.MODE_CTR, init_vect=iv) dans le challenge revient à éxécuter la méthode __init__() qui va prendre en paramètre l'algo, la clé, et l'IV:
- Un compteur est défini
iv + b"\x00"*8, c'est-à-dire que la première moitié du compteur est un nonce aléatoire et la partie basse est le compteur qui commence à 0. - La classe
GOST34132015(celle-là et pasGOST34132015Ciphercar cette dernière n'a pas de méthode__init__donc c'est la classe encore au-dessus qui est initialisée) est initialisée avecsuper().__init__(algorithm,key)qui va définir : - L'algo, donc 'kuznechik' pour nous
self._cipher_obj: CipherObjType = GOST34122015Kuznechik(key), défini_cipher_objaveckuznechikqui va servir à chiffrer nos textes clairs.
Flot d'éxecution du mode CTR
Le flot d'execution est défini dans la méthode encrypt() ligne 855
- Le compteur actuel (commence à 0 ) est chiffré et le résultat stocké dans
gamma:gamma = self._cipher_obj.encrypt(self._counter) - Le compteur est incrémenté :
self._counter = self._inc_ctr(self._counter) - Application d'une opération de xor avec le compteur chiffré et le bloc actuel :
result + add_xor(self._get_block(data, i), gamma)
Le bloc 'if' correspond à la meme chose mais pour un bloc qui ne fait pas 16 charactères. D'ailleurs la taille d'un bloc est définie ici
if len(data) % self.block_size != 0:
gamma = self._cipher_obj.encrypt(self._counter)
self._counter = self._inc_ctr(self._counter)
result = result + add_xor(
data[self.block_size * self._get_num_block(data)::], gamma
)
On pourrait aller analyser en profondeur le fonctionnment de kuznechik mais il n'y avait aucun papier qui parlait d'une vulnérabilité exploitable la-dessus.
Donc j'ai décidé de tester un peu toute les fonctions de la classe GOST34132015ctr.
Analyse de l'incrémentation du compteur
En testant _inc_ctr(), on remarque une chose intéressante. Voici le code :
def _inc_ctr(self, ctr: bytearray) -> bytearray:
internal = 0
bit = bytearray(self.block_size)
bit[self.block_size - 1] = 0x01
for i in range(self.block_size):
internal = ctr[i] + bit[i] + (internal << 8)
ctr[i] = internal & 0xff
return ctr
En prenant un compteur exemple : 12916ac66cc1cbfe0000000000000000, la partie qui doit s'incrémenter est 0000000000000000 faisant longueur de 8 octets et donc $ 256^{8} = 18446744073709551616 $ possibilités de compteur.
Sauf que dans cette implémentation, le compteur monte jusqu'à 00000000000000ff puis à la prochaine incrémentation il se réinitialise à 0000000000000000.
Ainsi, il n'y a que 256 valeurs de compteur possible ce que réduit fortement la liste des possiblités et nous permet récupérer le keystream qui a servi à chiffrer le flag.
Sachant que le compteur est incrémenté à chaque bloc, il faut trouver combien de fois le compteur a été incrémenté pour chiffrer le flag, soustraire se nombre à 256, réincrémenter le compteur du nombre qu'on obtient et récupérer le keystream en envoyant une chaine de null byte de la meme taille que le flag (vu que le keystream est chiffré avec le clair, envoyer des null byte permet de directement récupérer la clé car 0 xor n = n)
Exploit
from pwn import remote, context, xor
import re
BLOCK_SIZE = 16
context.log_level = 'CRITICAL'
host = "crypto.heroctf.fr"
port = 9001
conn = remote(host, port)
welcome_message = conn.recvS()
print(f"Got message : {welcome_message}")
flag_ct = re.search(r"(?<=sp00).*(?=00ky)", welcome_message).group() # regex to extract the encrypted flag, test it here: https://regex101.com/r/fb0QSo/1
flag_ct = bytes.fromhex(flag_ct)
print(f"Got ciphertext : {flag_ct.hex()}\n")
counter_offset = len(flag_ct)//BLOCK_SIZE + 1 if len(flag_ct)%BLOCK_SIZE!=0 else 0
print("Sending payload")
# payload to reset the counter
payload_size = 256 - counter_offset
reset_counter_payload = b"\x00"*payload_size*BLOCK_SIZE
conn.sendline(reset_counter_payload.hex().encode())
conn.recvuntilS("\n")
# payload to get the keystream
keystream_payload = b"\x00" * counter_offset * BLOCK_SIZE
conn.sendline(keystream_payload.hex().encode())
keystream = bytes.fromhex(conn.recvS().rstrip())
print(f"Got keystream {keystream.hex()} with length {len(keystream)}")
flag = xor(keystream, flag_ct)
print(f"Recovered : {flag}")
Flag : Hero{5p00ky_5c4ry_fl4w3d_...
Retex
Le challenge était vraiment intéressant et j'ai pu m'essayer à l'audit de code source.
Le code était bien écrit et facile à comprendre et il n'y avait pas de mathématiques horrible uniquement compréhensibles par des doctorants.
Lien(s) utile(s)
- https://en.wikipedia.org/wiki/GOST_(block_cipher)
- https://pypi.org/project/gostcrypto/
- https://gostcrypto.readthedocs.io/en/stable/intro.html#overview
- https://github.com/drobotun/gostcrypto
- https://fr.wikipedia.org/wiki/Chiffrement_de_flux