En janvier 2020, en marge du FIC, était organisé le NorzhCTF par l’ENSIBS. Cette année je ne faisais pas partie des “créateurs de challenges” mais bien des participants avec mes amis Haax, Razaborg et L0n3w0lf.
Durant ce CTF, je me suis tout particulièrement occupé d’un scénario : Extranet Norzh Nuclea que vous pouvez retrouver sur le git du NorzhCTF.
Énoncé
On commence par lire l’énoncé
Vous avez trouvé l’extranet de l’entreprise.
Ce challenge se décompose en 4 parties :
- Obtenir un accés à l’interface administrateur
- Obtenir un shell sur la machine
- Obtenir un accès root sur la machine
- Obtenir le mot de passe de l’utilisateur
web-dev
Globalement, ça ressemble à de l’exploitation web puis une privesc système Linux… C’est tout à fait ce que je sais faire, donc on va se lancer.
Obtenir l’accès administrateur
Dans un premier temps on va un peu fouiller le site. On a 3 pages :
- Une page d’accueil
- Une page de blog
- Une page de login
La page d’accueil ne me semble pas utile, je vais directement voir la page de blog.
Ici, ça commence à devenir interéssant. Lorsque l’on regarde les liens des blogposts on obtient quelque chose comme ceci :
http://www.norzh.nuclea/blog/2f6772617068716c3f[...]6222297b5f6964207469746c6520636f6e74656e747d7d
Ça me paraît être quelque chose “encodé” en hexadécimal… On ouvre donc la console python, et on se fait une petite fonction pour décoder.
|
|
Et on obtient le résultat suivant :
/graphql?query=query {blogpost(_id:“5df269a2385e89885becb45b”){_id title content}}
C’est donc une requête GraphL, on peut le comprendre avec le /graphql et au contenu de query. La requête est sans doute interprétée par le serveur pour nous obtenir le blogpost voulu… On doit pouvoir faire une injection là dessus.
Trouver la table
Je me suis donc un peu renseigné sur le sujet n’en ayant jamais fait et je trouve rapidement ce qu’il faut faire.
Dans un premier temps, je me refait une petite fonction pour encoder toutes mes requêtes.
|
|
La première étape pour obtenir le contenu de la base de données, c’est d’avoir toutes les tables présentes. En GrapQL ça s’obtient avec la requête
|
|
On va encoder tout ça et le mettre dans notre URL.
Et là on a un blogpost vide…
Pas d’inquiétude à avoir, en fait l’affichage est traité en js et le retour de notre requête n’est pas celui attendu. Donc on a juste à ouvrir les sources de la page.
On voit bien la table BlogPost qui sert à la requête de base, mais nous on va s’intéresser à la ble User.
Nos requêtes sont les suivantes :
Trouver les colonnes
|
|
Ici on récupère en plus les types de chaques colonnes
|
|
En résumé, nous avons 3 colonnes:
- _id
- username
- password
On veut, pour avancer, récupérer ces deux dernières.
Récupérer les valeurs
On peut procéder de 2 manières :
- En requêtant toutes les valeurs de User une à une grâce à l’id
- En requêtant l’ensemble des valeurs de la table User.
Étant un partisan du moindre effort, je choisis la seconde option. Et pour ce faire, il “suffit” de rajouter un s à la fin du nom de table.
|
|
On obtient bien la liste de tous les couples username/password
|
|
On a donc notre premier flag, et de quoi se connecter avec le compte administrateur.
Obtenir un shell
Une fois connecté, on ne me laisse pas trop le choix… On a juste une page pour créer des blogpost.
Ils sont gentils, ils m’indiquent tout de suite où regarder : Une “Server Side Template Injection”.
Je teste comme ils disent le fameux {7*7} et en allant voir dans les blogpost, je vois bien mon post avec en contenu 49.
Pour être honnête, je ne connais que les injections de template via jinja, mon premier reflexe est donc de tenter une injection de code python.
|
|
Mais étrangement le bouton ajouter ne fonctionne pas…
Dans un premier temps j’ai pensé à un genre de WAF qui bloquerait des mots-clefs, puis j’ai décidé de regarder le code source de la page. On pouvait y voir un code javascript qui gère l’envoi de la requête et l’affichage de sa réponse.
|
|
Étrangement, on ne rentrait même pas dans le else du test après l’envoi de la requête. C’était donc une erreur non prévue par le serveur… L’injection de template n’était donc probablement pas en python.
On essaie d’injecter des mots clefs de langages, comme du node.js.
En envoyant un { this }, le blogpost se créé, on va donc voir son contenu.
Bingo, c’est du node.js !
Je sais qu’on peut exécuter des commandes système avec le code suivant.
|
|
Mais à chaque fois j’obtient le résultat [object Object].
J’arrête donc d’essayer d’afficher et je lance un reverse shell. Sur ma machine j’ouvre un listener.
|
|
Et je test une première payload sur le serveur.
|
|
Malheureusement, ça ne fonctionne pas. Il n’y a probablement pas la bonne version de netcat pour ça. Mais pas d’inquiétude, on utilise une autre payload qui fonctionne à (presque) tous les coups :
|
|
Et on obtient bien un accès sur notre listener.
Je fais ensuite en sorte d’avoir un shell totalement interactif.
|
|
Puis on va lire le flag
|
|
Parfait, passons à l’étape 3.
Obtenir un shell root
Déjà, le premier reflexe est de voir ce qu’on peut executer avec sudo.
Le sudo nous permet de lancer un serveur node.js en tant que root. En regardant les processus, on voit que ce serveur tourne déjà. On va dans un premier temps regarder le code de ce dernier.
|
|
Il s’agit d’un healthcheck sur le port 3000 avec authentification via basic-auth. On n’a pas les droits pour modifier ces fichiers donc la seule piste actuelle serait d’effectuer une injection via ce que fait le serveur.
En s’intéressant un peu plus à la fonction check_credentials, on voit qu’il appelle un script shell avec en argument le couple login/password avec lequel on veut s’authentifier. On va donc regarder ça de plus près.
|
|
On se retrouve donc face à un script plutôt… Sale. Mais intéréssant !
En effet, le script va :
- Récupérer notre couple username / password et les mettre dans des variables
- Extraire dans le fichier shadow le hash du mot de passe de l’utilisateur
- Lancer du python en ligne de commande qui va hasher notre mot de passe et comparer son hash avec celui récupéré
Les variables étant passées en ligne de commande pour le python, s’il y a des quotes elles seront interprétées. On a donc ici notre injection sur la variable $PASSWORD. Il faudra :
- Ouvrir une quote
- Compléter la fonction crypt.crypt() pour éviter une erreur
- Ajouter le code que l’on veut executer
- Commenter le reste du code python
On se trouve avec une payload comme suit :
|
|
Et pour faire ma requête, afin de ne pas m’embêter avec le basic-auth, je vais utiliser python-requests.
J’ouvre donc un listener :
|
|
|
|
Malheureusement, cette première requête n’a pas fonctionné… Je fais donc d’autre test en remplaçant le user root par web-dev
|
|
Et on obtient bien un shell root. On repète notre petite liste de commandes pour obtenir notre shell interactif puis on va lire le flag.
|
|
Et de 3 challs validés !
On peut d’ailleurs voir pourquoi la commande ne fonctionnait pas avec l’utilisateur root : On est dans un docker et le root n’a pas de mot de passe.
Le script s’arrêtait donc sûrement avant l’execution du python.
Trouver le mot de passe de web-dev
Ici, on se trouve face à un cas un peu spécifique… En général on trouve le mot de passe d’un utilisateur puis on s’en sert pour se connecter. Là le but final est de trouver ce mot de passe.
Le mot de passe étant le flag (probablement), casser le hash de /etc/shadow serait impossible puisqu’il ne sera pas dans une wordlist.
La première idée qui me vient serait donc de vérifier dans tous les fichiers de configuration si la mention ENSIBS apparaît quelque part. Pour cela, rien de plus simple qu’un grep.
|
|
Mais après discussion avec le créateur du challenge, c’est inutile. Le flag n’est pas simplement caché sur le serveur.
Je vois donc 2 possibilités :
- Le flag est chiffré quelque part
- Le flag est à l’exterieur du serveur
Au bout de 5min de réflexion intense, j’ai l’illumination : Le serveur sur le port 3000 est un serveur de “healthcheck” c’est à dire que les utilisateurs vont se connecter assez régulièrement vérifier qu’il est encore en vie et que tout fonctionne comme il faut.
Donc l’utilisateur web-dev fera sûrement des requêtes où il va s’authentifier, et son mot de passe apparaîtra dans les processus en argument de authent.sh ou dans la ligne de commande python.
Je récupère donc un outil magique : pspy, un executable qui affiche les processus en temps réel avec leurs arguments.
Je le lance et je vois très vite le saint graal.
Son mot de passe est donc le quatrième flag
ENSIBS{I_kn0w_Ur_passWord_Web_dev}
Retour sur le scénario
J’ai trouvé ce scénario très intéréssant, il m’a fait travailler sur des technos sur lesquelles je ne tape pas souvent (voire jamais) comme GraphQL ou node.js. Je me suis donc bien amusé et il a pu nous rapporter un total de 1000 points (250 par challenge), ce qui n’est pas negligeable.
Cependant les parties 2 & 3 étaient quelque chose de “custom” de façon un peu trop flagrante ce qui peut un peu enlever au réalisme voulu par le CTF.
Malgré tout, bravo aux organisateurs et particulièrement à Areizen pour ce scénario.