Skip to content

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é
  • cipher est 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:

ECB mode

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 :

CTR mode

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 pas GOST34132015Cipher car 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 avec super().__init__(algorithm,key) qui va définir :
  • L'algo, donc 'kuznechik' pour nous
  • self._cipher_obj: CipherObjType = GOST34122015Kuznechik(key), défini _cipher_obj avec kuznechik qui 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