J’ai décidé de me lancer et de tenter un premier CTF. Pour être honnête je ne savais pas du tout à quoi m’attendre. Je me suis inscrit à l’avance au Cyber Apocalypse 2024, mais pour avoir une idée de à quoi ça ressemble j’ai sauté sur l’occasion quand j’ai vu que le premier CTF organisé par GCC était pile une semaine avant.
Je n’ai clairement pas eu le temps de m’y mettre sérieusement, mais j’ai pu au moins me mettre sur le premier challenge Web: Find the compass. Voici donc mon tout premier WriteUp !
Phase 1: Exploration et bypass du login
On démarre sur une page de login simple, pas de register, pas d’autre page en apparence dispo. On peut donc supposer que la première étape va se jouer sur le bypass de cette partie. Regardons donc si on a quelque part un token quelconque ou s’il faudra bruteforce une combinaison. Et en regardant les cookies, Bingo ! On obtient ceci:
.eJwNzLEOhCAMANB_6ewApS3qz1xoLcTE4ABOl_v3c3vT-8J1t-bH5-yw13INX6Df3Rx2yDWXQKyYdIuWtpo1kpAxJgnMBVddAxcRqaQoRvpSKcVsjkQHLDBmmc94r_b4mPD7AzlsIHM.ZeMjWA.WqN5xSSRJSoKlTuwclfz9fC3lAQ
Pour être déjà tombé dessus sur un challenge Root-Me, je suis directement parti sur la piste d’un token Flask vu le header manquant. En utilisant flask-unsign, on peut donc facilement décoder:
{'logged_in': False, 'nonce': '7f7a045b23b91c39f7b1464c5236055a28b805a666f4b26c4b666b4317ce244d', 'status': 'guest'}
Parfait. Donc il faudra à minima renplacer la valeur « logged_in » et la valeur « status« . Mais pour ça il va d’abord falloir obtenir la clé de chiffrement. Je teste rapidement un bruteforce avec une liste de 10 millions de passwords communs mais rien. Lisons donc légèrement le code (miam du python) ! On voit que la secret key utilisée est déclarée dans le __init__.py:
app.config['SECRET_KEY'] = generate_key()
En cherchant la fonction de génération, on finit par tomber sur le code suivant:
def generate_key() -> str:
"""Generate a random key for the Flask secret (I love Python!)"""
return (''.join([str(x) for x in [(int(x) ^ (int(time()) % 2 ^ randint(0, 2))) for x in [int(char) for char in str(digits[randint(0, 9)]) * 4]]])).rjust(8, '0')
Bon, étant un parfait expert en python, je n’ai bien sûr aucune idée de ce que ça peut donner (mis à part le fait que notre token fera 8 caractères), donc on va lancer un petit « print(generate_key()) » pour voir un peu à quoi ressemble notre clé. En lançant la fonction plusieurs fois on se rend compte que ça sera une clé composée de 8 chiffres, et que la grosse majorité du temps les deux premiers chiffres seront « 00 ». On va tenter de gagner du temps avec ça, et je génère un fichier .txt avec tous les nombres de « 00000000 » à « 00999999 ». On lance ensuite:
flask-unsign --unsign --cookie '.eJwNzLEOhCAMANB_6ewApS3qz1xoLcTE4ABOl_v3c3vT-8J1t-bH5-yw13INX6Df3Rx2yDWXQKyYdIuWtpo1kpAxJgnMBVddAxcRqaQoRvpSKcVsjkQHLDBmmc94r_b4mPD7AzlsIHM.ZeMjWA.WqN5xSSRJSoKlTuwclfz9fC3lAQ' --wordlist 6-digits.txt --no-literal-eval
Et effectivement, la fonction nous sort:
[*] Session decodes to: {'logged_in': False, 'nonce': '7f7a045b23b91c39f7b1464c5236055a28b805a666f4b26c4b666b4317ce244d', 'status': 'guest'}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 3712 attempts
b'00003323'
Je tente donc maintenant de crafter un nouveau token en remplaçant les données du token initial:
{'logged_in': True, 'nonce': '74209a57e7d864cc671edd1880985f85c9b1039a3f6c9cf6231a3fe567c1a56a', 'status': 'admin'}
Dans le code on voit qu’une fois connecté, on a accès à la route /panel. Tentons donc ?
Et non ! On accède bien à la page, mais quand on essaye d’ajouter un reminder l’app crash. Il doit nous manquer un truc.
Phase 2: Exploitation et injection
Rebelotte, on analyse le code ! Et effectivement, il nous manque une donnée primordiale:
session['logged_in'] = True
session['status'] = user.status
session['username'] = user.username
return {"message": "Login successful."}, 200
En regardant dans le fichier de la database, on voit:
db.session.add(User('admin', 'admin@compass.local', password, 'admin'))
On ajoute donc le username à notre token:
flask-unsign --sign --cookie "{'logged_in': True, 'nonce': '74209a57e7d864cc671edd1880985f85c9b1039a3f6c9cf6231a3fe567c1a56a', 'status': 'admin', 'username': 'admin'}" --secret "00003323"
Et on peut effectivement bien poster des reminders désormais. Cherchons donc ce qu’on va pouvoir faire. On tente une injection de template avec le payload suivant:
${{<%[%'"}}%\
Effectivement, le payload ressort différent, on va donc chercher de ce côté là.
On tente donc différentes choses et avec « {self.__init__.__globals__} » on obtient quelque chose d’intéressant. Je teste alors d’avoir accès aux __builtins__, à l’__import__, … Après avoir tenté tous les payloads que j’avais sous la main et quelques pistes imaginées, je repars à la pèche dans le code. Et là…
def __init__(self, coordinates: str):
# Only a wise administrator can retrieve the coordinates of the compass.
self.coordinates = coordinates
Petit facepalm, petit « {self.coordinates}« … Et là un petit GCC{} qui apparaît sous nos yeux aussi ébahis que déconfits d’avoir perdu du temps pour rien 😬
Conclusion
Une grosse piste d’amélioration: penser à lire le code quand il est fourni pour perdre moins de temps. Les réponses sont claires et écrites noires sur blanc, et j’ai tenté beaucoup de choses inutiles.
Le challenge était relativement simple mais assez drôle, je me suis bien amusé en tous cas. Je vous donne donc rendez-vous la semaine prochaine pour les write-ups du CTF de HTB ! Et promis, on parlera un peu plus de sécurité par ici à l’avenir !