AES NULL S-BOX Exploit

Write-up du challenge CryptoFlow du CTF ECW (édition 2023)

Le

Pour ce troisième et dernier write-up du CTF ECW édition 2023, je vais aborder la résolution d'un autre challenge de la catégorie crypto qui a l'originalité de mélanger du pwn et de la cryptographie, j'ai nommé "CryptoFlow".

Plateforme ECW : fiche du challenge crypto CryptoFlow, difficulté hard, 244 points, conçu par Thales

Vous pouvez télécharger le programme ici.

Découverte du challenge

Dans ce challenge nous avons accès à un binaire. On commence par le désassembler rapidement sur IDA.

IDA : pseudo-code du main qui charge la clé AES, chiffre le flag puis chiffre chaque message reçu en boucle

Le programme effectue les actions suivantes:

  • Il charge une clé de 128 bits (16 octets) depuis un fichier "aes_ecb_key.txt" dans un buffer que j'ai nommé aes_key (fonction LoadKey).
  • Il charge le contenu d'un fichier "flag", le chiffre en utilisant de l'AES128 et affiche le contenu chiffré (fonction SendEncryptedFlag).
  • Il chiffre ensuite tout les prochains inputs qu'on lui envoie en utilisant la même clé.

On comprend rapidement que notre objectif est de déchiffrer le flag fourni au lancement du programme.

Avant de commencer, il est important de bien comprendre comment l'algorithme de chiffrement AES fonctionne pour ne pas être trop perdu. Pour cela je vous conseille cette vidéo qui présente très bien son fonctionnement.

La vulnérabilité

Buffer overflow

En jouant un peu avec le programme, on s'aperçoit qu'il y a une faille dans la fonction GetMessage. En effet, celle-ci copie toutes les données qu'on lui envoie dans un buffer situé dans le segment .data sans contrôler si l'on dépasse la taille dudit buffer (224 octets). On peut donc réaliser un buffer-overflow dans le segment .data du programme.

IDA : GetMessage lit l'entrée octet par octet jusqu'au retour à la ligne sans limite de taille, d'où le buffer overflow

On s'intéresse tout naturellement aux données que l'on peut overwrite dans le segment .data afin de voir ce que cette faille peut nous permettre de faire.

IDA : segment .data où le canary à valeur fixe précède directement la table sbox de 256 octets

Les données qui suivent le buffer stockant le message à chiffrer sont les suivantes:

  • Un buffer de 16 bits intitulé "mycanary" dont le rôle est de vérifier s’il y a buffer-overflow via une comparaison avec un autre buffer situé plus loin dans le segment. Seulement comme la valeur de ce canary est fixe et connue, la protection n'est pas effective.

  • Après le canary on trouve un buffer de 256 bytes, nommé "s-box". Il s'agit d'une matrice permettant le bon fonctionnement de la fonction cryptographique SubBytes dans l’algorithme AES.

On peut donc réécrire la matrice S-BOX en envoyant la payload suivante:

[224 octets][ MYCANARY = 0xAABBCCDD00112233][ S-BOX]

Mais que peut-il se passer si on change la valeur des coefficients de la matrice s-box ?

La matrice S-BOX

Il est important de comprendre le rôle de cette matrice au sein de l'AES. Comme dit précédemment, celle-ci assure le bon fonctionnement de la fonction SubBytes. La fonction SubBytes effectue une opération de substitution sur les coefficients d'une colonne d'une matrice. Elle associe à chaque coefficient un nouvelle valeur, suivant la règle suivante: pour une coefficient de la forme 0xA****B, par exemple 0x19, elle associe la valeur se situant à la Aième ligne et à la Bième colonne, c'est-à-dire 0xD4 pour 0x19.

Table S-BOX de l'AES : l'octet 0x19 est substitué par 0xD4 via la ligne 1 et la colonne 9

Seulement si S-BOX est nulle, alors peu importante la valeur du coefficient, sa valeur associée sera nulle. Le résultat de SubBytes sera donc toujours une matrice nulle. Cela implique qu'à la sortie de la fonction ShiftRows, qui suit SubBytes et dont le rôle est d'effectuer des opérations de permutation de ligne, aura une sortie nulle également car permuter les lignes d'une matrice nulle donnera toujours une matrice nulle. La fonction suivante, AddRoundKey, qui effectue une opération XOR terme à terme entre la matrice State et la roundkey renverra la roundkey car A xor 0 = A. Cela implique que le résultat de notre chiffrement sera égal à la Roundkey n°10 comme on peut le voir sur l'image ci-dessous.

Schéma AES : avec une S-BOX nulle, le dernier tour SubBytes, ShiftRows, AddRoundKey renvoie la Round key 10

On obtient donc après chiffrement la Roundkey n°10, la dernière des Roundkey. Et c'est très fort, car on peut retrouver la CipherKey, la clé principale, à partir d'une ième Roundkey, car les opérations les générant sont réversibles.

Revenons un peu sur le code de la fonction "main". On s'aperçoit qu'il y a un appel à KeyExpension qui est effectué avant que l'on overwrite S-BOX. C'est un point très important, car c'est la fonction KeyExpension qui génère les 10 RoundKeys, et que celle-ci utilise la matrice S-BOX. Tous les appels à KeyExpension qui seront effectués après l'overwrite de S-BOX génèreront des clés qui ont perdu l'information de la CipherKey, c'est-à-dire des clés inexploitables. C'est donc bien le résultat du premier message chiffré qui est égal à la 10ième RoundKey.

IDA : KeyExpansion est appelée avant GetMessage, donc le premier chiffrement utilise encore les vraies RoundKeys

KeyExpension inverse

J'ai commencé par coder la fonction générant la ième Roundkey à partir de la (i-1)ème clé.

def round(mat: list, round_number : int = 0):
	new_mat = [0]*(KEY_MAT_SIZE*KEY_MAT_SIZE)

	col_0 = [SubBytes(i) for i in RotWord(get_col(mat,3))]
	col_0 = Xor(col_0, get_col(mat,0))
	col_0 = Xor(col_0, [Rcon[round_number], 0, 0, 0])

	col_1 = Xor(col_0, get_col(mat,1))
	col_2 = Xor(col_1, get_col(mat,2))
	col_3 = Xor(col_2, get_col(mat,3))

	set_col(new_mat, 0, col_0)
	set_col(new_mat, 1, col_1)
	set_col(new_mat, 2, col_2)
	set_col(new_mat, 3, col_3)
	return new_mat

Puis j'ai codé sa fonction inverse.

def inv_round(mat : list, round_number : int = 0):
	new_mat = [0]*(KEY_MAT_SIZE*KEY_MAT_SIZE)
	col_3 = Xor(get_col(mat,2), get_col(mat,3))
	col_2 = Xor(get_col(mat,1), get_col(mat,2))
	col_1 = Xor(get_col(mat,0), get_col(mat,1))
	
	col_0 = [SubBytes(i) for i in RotWord(col_3)]
	col_0 = Xor(col_0, [Rcon[round_number], 0, 0, 0])
	col_0 = Xor(col_0, get_col(mat,0))

	set_col(new_mat,0, col_0)
	set_col(new_mat,1, col_1)
	set_col(new_mat,2, col_2)
	set_col(new_mat,3, col_3)
	return new_mat

Le code complet est disponible sur mon github ici (bientôt).

Il suffit maintenant de le lancer pour obtenir la CipherKey et pouvoir déchiffré le flag.

Accès Restreint

Cet article n'est pas (encore) publique.
Pour obtenir un accès anticipé, contactez-moi.

[[ passwordError ]]

[[ post.title ]]

[[ post.description ]]

Le [[ post.date ]]

[[ tag ]]
[[ currentMatchIndex + 1 ]] / [[ searchMatches.length ]] 0 / 0

Sommaire

Page principale