Write-up CTF Insomni'hack 2025 – Unchained
Le vendredi 14 mars a eu lieu l'Insomni'hack 2025, un concours de hacking organisé à l'issue d'une semaine de conférences sur la cybersécurité en Suisse.
Pour celles et ceux qui ne connaissent pas ce type d'événement, il s'agit d'un CTF (Capture-The-Flag) au format "Jeopardy", réalisé en présentiel.
Concrètement, un CTF est semblable à un Escape Game informatique. Vous avez un objectif : récupérer un "flag". Comment y parvenir ? À vous de le découvrir !
Le format "Jeopardy" propose de nombreux petits challenges répartis en catégories précises. Par exemple, les défis de type reverse
nécessitent généralement de comprendre l'assembleur et la décompilation de binaires.
Cette année, un challenge en particulier m'a beaucoup plu. J'ai donc décidé de vous partager son "write-up", c'est-à-dire un compte-rendu détaillé expliquant méthodiquement les outils et étapes utilisés pour résoudre ce défi et récupérer le flag.
Préparation
Pour ce challenge, nous avions à disposition un nom, une description, une URL web ainsi qu'un fichier Python.
Le nom et la description du challenge sont souvent utiles, car ils donnent généralement des indices sur la faille à exploiter. Par exemple, lors de cette édition, le challenge nommé Hawkta
faisait explicitement référence à Okta
et son piratage utilisant une vulnérabilité liée à bcrypt
.
Dans notre cas, le nom du challenge était Unchained, accompagné de la description suivante : "Some rogue web admin is building a web site for dogs but has no time for beer and push ups :( A very (in)secure double encryption is used to secure the Session Cookie 💪"
Ce challenge appartient à la catégorie web
, mais même sans ouvrir l'URL fournie ou analyser le fichier Python joint, on devine aisément que le sujet portera sur de la cryptographie.
Le lien fourni n'était rien d'autre que l'exécution en ligne du fichier Python mis à disposition. Vous pouvez le retrouver directement à cette adresse : https://gist.github.com/RomainLanz/82ffcf6231608f40da371f594f6b76c5
Ce script peut tout à fait être exécuté sur votre propre machine afin de le modifier et mieux comprendre son fonctionnement. Attention cependant : il est important de ne pas s'appuyer sur une version modifiée de ce fichier pour tester votre attaque, puisque la version officielle en ligne restera inchangée durant le CTF.
Si vous souhaitez tenter ce challenge par vous-même, je vous conseille de suspendre votre lecture ici afin de ne pas gâcher le plaisir de la découverte.
Analyse préliminaire de l'application Flask
La seule chose qui nous importe est de réussir à obtenir le flag. En parcourant rapidement le fichier, on remarque qu'il s'agit d'une application Flask possédant plusieurs endpoints, dont un particulièrement intéressant : /secret
.
@app.route("/secret")
def get_secret():
plain_text = decrypt(MAIN_KEY, session["status"])
role = plain_text.split("::")[2]
if role == 'admin':
return os.environ['INS']
return f'You are a just a {role}. Only admins can see the secret!'
Ce qui attire notre attention est évidemment os.environ['INS']
, qui correspond à notre flag stocké dans une variable d'environnement nommée INS
côté serveur.
Mais comment y accéder ?
La condition d'accès à cette variable est claire : il faut obtenir role == 'admin'
. On comprend ainsi qu'il existe un système de rôles dans cette application et que seul un utilisateur avec le rôle admin
pourra voir le flag.
Le rôle semble extrait de la session, comme le montre la ligne suivante :
plain_text = decrypt(MAIN_KEY, session["status"])
role = plain_text.split("::")[2]
Si on se rend à la route de connexion (ligne 60 du script), on constate la création de cette session et son contenu initial :
user_id = gen_userid()
user = re.sub(r'\W+', '', request.form['username'])
status_data = user_id + \
"::" + user + \
"::" + "guest"
cipher_text = encrypt(data=status_data.encode('ascii'),MAIN_KEY=MAIN_KEY)
session['status'] = cipher_text
Décomposons rapidement ce processus pour mieux comprendre son fonctionnement : Tout d'abord, un identifiant unique (UUID v4) est généré (gen_userid()
). Ensuite, le nom d'utilisateur entré par le joueur est assaini (on supprime tout caractère non-alphanumérique). Enfin, ces informations sont concaténées pour former une chaîne de caractères ressemblant à ceci :
e55f87af-c8a6-4c34-9488-b1338825307f::romainlanz::guest
Pour terminer, cette chaîne est chiffrée avec la clé MAIN_KEY
et stockée dans la session utilisateur.
Identification des vulnérabilités exploitables
Jusqu'à présent, le code analysé présente des éléments assez inhabituels, voire peu orthodoxes.
Si on reprend notre objectif initial, il faut réussir à obtenir le rôle admin
. Pour rappel, ce rôle est récupéré en séparant la valeur de status
avec ::
, puis en prenant l'élément d'index 2.
Actuellement, notre statut est équivalent à la valeur mentionnée plus haut, ce qui signifie qu'après avoir effectué un split
, le rôle retourné serait systématiquement guest
.
La question qui se pose alors est : comment modifier cette valeur ?
Dans le code, à aucun moment cette valeur n'est modifiée après sa création. Elle est définie dès le début sur guest
, sans moyen évident de la changer par la suite.
La seule entrée utilisateur capable d'influencer le comportement du script est le username
, lequel est simplement concaténé dans la chaîne du statut.
Secret Key
À première vue, rien ne semble directement exploitable à ce niveau. Il faut donc élargir notre analyse. On remarque notamment que la définition de la clé secrète (app.secret_key
) utilisée par Flask est réalisée d'une façon plutôt étrange :
app.secret_key = ''.join(["{}".format(randint(0, 9)) for num in range(0, 6)])
Cette clé secrète est générée aléatoirement, mais elle se compose uniquement de six chiffres. En consultant la documentation de Flask, on apprend que cette clé sert notamment à signer les cookies.
Puisque l'application utilise une session Flask, on peut en déduire que les données de session sont stockées directement dans un cookie signé par cette clé secrète. Pour vérifier cette hypothèse, lançons le code, rendons-nous sur la page de connexion, connectons-nous, et inspectons le cookie généré.
On obtient alors quelque chose comme :
eyJzdGF0dXMiOiJTSkdGWTVHbHZvaFRCK2lEUGp6cXJicVJrbTdCaU8rR2hHZmxENUh3dTM5VjNvb2NvNkRMOCtwNDBsZUNpYkR1SjR5QlM1OG5nTmlLcXl2aENsd0pYZz09In0.Z9TbgQ.jjqeCFXCrW9XyAHUgOlJQ_KwhH0
Ce cookie se présente en trois parties distinctes, chacune séparée par un point (.), ressemblant ainsi fortement à un JWT.
En examinant le code de Flask sur la gestion des sessions, on remarque qu'il utilise la librairie itsdangerous.
On apprend également que le salt par défaut utilisé est cookie-session
, le digest par défaut est SHA1
, et que la dérivation de la clé utilise HMAC
.
Pour signer le cookie, Flask utilise le code suivant :
URLSafeTimedSerializer(
keys,
salt=self.salt,
serializer=self.serializer,
signer_kwargs={
"key_derivation": self.key_derivation,
"digest_method": self.digest_method,
},
)
Dans notre cas précis, ces paramètres n'ont pas été modifiés. Par conséquent, la seule donnée dynamique réellement utilisée pour signer le cookie est la clé (keys
).
Compte tenu de la façon dont la clé secrète est générée (une simple suite de 6 chiffres), il devient envisageable de tenter une attaque par brute force.
En effet, même si la clé change à chaque démarrage, l'espace des possibilités reste très limité (seulement 1 million de combinaisons possibles, de 000000 à 999999).
Testons cette théorie en écrivant un script rapide pour retrouver la clé utilisée :
from itsdangerous import URLSafeTimedSerializer, BadSignature, BadTimeSignature
COOKIE = "eyJzdGF0dXMiOiJTSkdGWTVHbHZvaFRCK2lEUGp6cXJicVJrbTdCaU8rR2hHZmxENUh3dTM5VjNvb2NvNkRMOCtwNDBsZUNpYkR1SjR5QlM1OG5nTmlLcXl2aENsd0pYZz09In0.Z9TbgQ.jjqeCFXCrW9XyAHUgOlJQ_KwhH0"
SALT = "cookie-session"
MAX_RANGE = 1000000
def main():
for i in range(MAX_RANGE):
secret_candidate = str(i).zfill(6)
serializer = URLSafeTimedSerializer(
secret_key=secret_candidate,
salt=SALT,
signer_kwargs={"key_derivation": "hmac"},
)
try:
data = serializer.loads(COOKIE)
print(f"[+] secret_key='{secret_candidate}'")
break
except (BadSignature, BadTimeSignature):
pass
except Exception as e:
pass
if __name__ == "__main__":
main()
[+] secret_key='256321'
BINGO ! Nous avons trouvé la clé secrète qui permet de signer les cookies générés par Flask.
Cependant, que peut-on concrètement faire avec cette information ?
Analyse du cookie de session Flask
Maintenant que nous connaissons la clé secrète utilisée pour signer les cookies, nous devrions être en mesure de modifier leur contenu avant de les signer à nouveau. Ainsi, le serveur considèrera que les données du cookie sont authentiques, puisqu'elles seront signées avec sa propre clé.
En consultant le code source de la librairie itsdangerous, on remarque que le token est encodé en base64. Essayons donc de décoder le contenu pour observer à quoi il ressemble concrètement.
La première partie du cookie devrait contenir la session utilisateur :
echo -n "eyJzdGF0dXMiOiJTSkdGWTVHbHZvaFRCK2lEUGp6cXJicVJrbTdCaU8rR2hHZmxENUh3dTM5VjNvb2NvNkRMOCtwNDBsZUNpYkR1SjR5QlM1OG5nTmlLcXl2aENsd0pYZz09In0==" | base64 -d
Ce qui nous donne bien la session sous forme JSON :
{"status":"SJGFY5GlvohTB+iDPjzqrbqRkm7BiO+GhGflD5Hwu39V3ooco6DL8+p40leCibDuJ4yBS58ngNiKqyvhClwJXg=="}
Nous constatons que les deux autres parties du cookie correspondent au timestamp et à la signature, des données supplémentaires non visibles en ASCII clair.
Logiquement, nous devrions pouvoir altérer le contenu du status
puis re-signer ce token pour obtenir un rôle administrateur.
Cependant, lorsque nous tentons de décoder la valeur actuelle du status
, nous confirmons qu’il est bien chiffré, comme évoqué précédemment :
echo -n "SJGFY5GlvohTB+iDPjzqrbqRkm7BiO+GhGflD5Hwu39V3ooco6DL8+p40leCibDuJ4yBS58ngNiKqyvhClwJXg==" | base64 -d
Hc��S�><ꭺn�g��Uފ�����x�W��'K'؊�+�
\ ^
Cette vérification nous rappelle donc clairement que le contenu du status
ne peut pas être modifié directement puisqu’il est chiffré côté serveur avec une clé que nous ignorons toujours à ce stade.
Le contenu est effectivement chiffré avec l'algorithme AES
en mode ECB
, à l'aide d'une clé (MAIN_KEY
) que nous ne connaissons pas :
def encrypt(data,MAIN_KEY):
cipher = AES.new(MAIN_KEY, AES.MODE_ECB)
cipher_text_bytes = cipher.encrypt(pad(data, 16,'pkcs7'))
cipher_text_b64 = b64encode(cipher_text_bytes)
cipher_text = cipher_text_b64.decode('ascii')
return cipher_text
ECB cut-and-paste
Un développeur ordinaire pourrait penser à ce stade qu'il n'y a plus rien à exploiter. Certes, nous avons réussi à découvrir la clé secrète utilisée par Flask pour signer les cookies, mais le contenu du champ status
reste chiffré par une clé inconnue (MAIN_KEY
). Cette clé possède une taille minimale de 32 octets, ce qui rend son brute-force pratiquement impossible.
Cependant, dans le cadre d'un CTF, il est raisonnable d'imaginer qu'il existe une faille ou une vulnérabilité à exploiter. Ici, cette vulnérabilité réside dans l'utilisation de l'algorithme AES
avec le mode ECB
.
En cherchant quelques informations sur le mode ECB
, on découvre rapidement que celui-ci chiffre les données par blocs de 16 octets. Cela signifie que la chaîne à chiffrer est divisée en blocs indépendants, chacun chiffré séparément. Les blocs chiffrés sont ensuite concaténés pour former le résultat final.
Cette approche présente une faiblesse connue sous le nom d'attaque "ECB cut-and-paste". Comme son nom l'indique, il est possible d'effectuer des opérations de "copier-coller" sur les blocs chiffrés sans casser l'intégrité globale du chiffrement.
Examinons maintenant concrètement le contenu réel de status
. Si nous décidons de le découper en blocs de 16 octets, nous obtenons les blocs suivants :
[
'e55f87af-c8a6-4c', // faecb7bf037ed4b1cf6ea75de31f16b4
'34-9488-b1338825', // 5d3943e3f46fc0cbf02122df6d78e82f
'307f::romainlanz', // b1df043d759f8eeb502d88aaeb09f78d
'::guest' // ae4121ed8979a4f2700b4666d8b156f7
]
Ca vous donne une idée ?
Reprenons la vérification effectuée sur la route /secret
:
role = plain_text.split("::")[2]
Le code récupère le troisième élément (index 2) après avoir découpé la chaîne sur ::
. On note immédiatement qu'il n'y a aucune vérification sur la taille finale du tableau ni sur le nombre d'occurrences de ::
.
Notre objectif sera donc de créer artificiellement un statut contenant le rôle admin à l'emplacement attendu par l'application, sans avoir besoin de déchiffrer la donnée.
Pour réussir cela, nous allons jouer sur la taille du nom d'utilisateur afin d'aligner précisément nos blocs de 16 octets.
Par exemple, en utilisant le nom d'utilisateur __________admin
, nous obtiendrions les blocs suivants :
[
'e55f87af-c8a6-4c', // faecb7bf037ed4b1cf6ea75de31f16b4
'34-9488-b1338825', // 5d3943e3f46fc0cbf02122df6d78e82f
'307f::__________', // 66d53e725337016b82b5c793f6916db3
'admin::guest' // 5e640c69e90154865d63370e3bed05e3
]
On remarque que le quatrième bloc commence précisément par le mot admin
suivi de ::
.
Pour compléter notre attaque, il nous faudrait également obtenir un bloc se terminant exactement par ::
. Cela pourrait se faire avec un nom d'utilisateur légèrement plus long comme ___________________admin
:
[
'e55f87af-c8a6-4c', // faecb7bf037ed4b1cf6ea75de31f16b4
'34-9488-b1338825', // 5d3943e3f46fc0cbf02122df6d78e82f
'307f::__________', // 66d53e725337016b82b5c793f6916db3
'_________admin::', // 824819dddb047ebdd8b3634aa2a07518
'guest' // 3494913e67b7d59fca00b29c1e833eba
]
Maintenant, imaginons que nous remplacions le dernier bloc (guest
) de cette dernière séquence par le bloc contenant admin::guest
de la séquence précédente. Nous obtiendrions alors :
[
'e55f87af-c8a6-4c', // faecb7bf037ed4b1cf6ea75de31f16b4
'34-9488-b1338825', // 5d3943e3f46fc0cbf02122df6d78e82f
'307f::__________', // 66d53e725337016b82b5c793f6916db3
'_________admin::', // 824819dddb047ebdd8b3634aa2a07518
'admin::guest' // 5e640c69e90154865d63370e3bed05e3
]
Remis en une seule ligne, cela donne la chaîne suivante :
e55f87af-c8a6-4c34-9488-b1338825307f::___________________admin::admin::guest
De cette manière, lorsqu'on fera plain_text.split("::")[2]
, nous obtiendrons bel et bien le rôle recherché : admin
.
Nous allons donc pouvoir forger un nouveau statut valide contenant le rôle admin, puis signer ce cookie avec la clé secrète que nous avons obtenue précédemment.
Voici comment procéder concrètement pour signer ce cookie modifié :
import json
from itsdangerous import URLSafeTimedSerializer
SECRET_KEY = "256321"
SALT = "cookie-session"
NEW_STATUS = "/nvWvKfpD6eYJsxinSA6Aquu1q4jMRQDOY1SsWTNxMeXS59t+O7quBo07Oq2TiTrzl4QVQ2kgr89kZRJ969QtW8EqdWEQByxl6WY8vgkuRI="
payload = {"status":NEW_STATUS}
serializer = URLSafeTimedSerializer(
secret_key=SECRET_KEY,
salt=SALT,
signer_kwargs={"key_derivation": "hmac"},
)
print(serializer.dumps(payload))
En exécutant ce script, on obtient un cookie valide et signé contenant notre nouveau statut avec le rôle admin.
eyJzdGF0dXMiOiIvbnZXdktmcEQ2ZVlKc3hpblNBNkFxdXUxcTRqTVJRRE9ZMVNzV1ROeE1lWFM1OXQrTzdxdUJvMDdPcTJUaVRyemw0UVZRMmtncjg5a1pSSjk2OVF0VzhFcWRXRVFCeXhsNldZOHZna3VSST0ifQ.Z9TyGg.2Ld0tOj6KSOYnSMHg1I18HESD4Q
Il suffit alors de remplacer le cookie original par celui-ci dans le navigateur, d'accéder à nouveau à la route /secret
, et nous obtenons enfin le flag !
INS{#Com3_Br34k_th3_Cha1n_Aga1N!!!}
Conclusion
Ce challenge aura permis d'illustrer concrètement comment l'utilisation imprudente du chiffrement AES-ECB
et une clé secrète faible peuvent mener à des attaques simples, mais dévastatrices. Une belle piqûre de rappel sur l'importance d'implémentations cryptographiques solides !