WHY2025 CTF: Gamerboy
Écrit par l’équipe HackGyver – Août 2025
1: Premières informations
$ file chall.gb
chall.gb: Game Boy ROM image (Rev.01) [ROM ONLY], ROM: 256Kbit
On se retrouve face à une ROM pour Game Boy. On va essayer de la lancer avec un émulateur.
mGBA au hasard. bgb existe aussi et contient un debugger.
> Press START
On appuie donc sur la touche START (`Entrée` sur clavier) :
> NOW THE CHEAT PATTERN!!
Ensuite, le programme semble retranscrire les touches sur lesquelles nous appuyons.
Quand on appuie sur deux touches en même temps, on obtient MULTI.
2: Analyse statique
Prérequis
- Ghidra (version 11.4.1 à l'écriture de cet article)
- GhidraBoy en suivant les instructions d'installation, (veillez à bien installer la version correspondante à la version de Ghidra)
Par défaut, Ghidra ne gère pas les ROMs de Game Boy.
GhidraBoy permet de correctement mapper les zones mémoires de la ROMs ou encore, de prendre en charge les instructions du CPU SM83.
Chaînes de caractères
Généralement, j'aime bien regarder les chaînes de caractères, cela permet de s'aiguiller dans le programme.
> Window > Defined Strings![]()
On retrouve bien les deux premières lignes de texte ainsi que les touches.
On remarque qu'il nous manque UP, DOWN, LEFT, A et B.
Parfois, les textes courts ont du mal a être détectés, on va les ajouter à la main.
On va double-cliquer sur RIGHT et regarder autour.
029f 55 ?? 55h U
02a0 50 ?? 50h P
02a1 00 ?? 00h
02a2 44 ?? 44h D
02a3 4f ?? 4Fh O
02a4 57 ?? 57h W
02a5 4e ?? 4Eh N
02a6 00 ?? 00h
02a7 4c ?? 4Ch L
02a8 45 ?? 45h E
02a9 46 ?? 46h F
02aa 54 ?? 54h T
02ab 00 ?? 00h
02ac 52 49 47 ds "RIGHT"
48 54 00
02b2 41 ?? 41h A
02b3 00 ?? 00h
02b4 42 ?? 42h B
02b5 00 ?? 00h
02b6 53 45 4c ds "SELECT"
45 43 54
00
02bd 53 54 41 ds "START"
52 54 00
02c3 4d 55 4c ds "MULTI"
54 49 00On retrouve bien les autres touches comme DOWN à l'adresse 0x02a2.
Afin d'ajouter ces chaînes, on va faire un clic droit sur le début de chaque chaîne et les définir
comme des chaînes qui terminent par un 0x00 (*null-terminated string* ou *C string*) : Data > TerminatedCString.
ℹ️ Vous pouvez utiliser le raccourci `Y` pour répéter le dernier type défini.
029f 55 50 00 ds "UP"
02a2 44 4f 57 ds "DOWN"
4e 00
02a7 4c 45 46 ds "LEFT"
54 00
02ac 52 49 47 ds "RIGHT"
48 54 00
02b2 41 00 ds "A"
02b4 42 00 ds "B"
02b6 53 45 4c ds "SELECT"
45 43 54
00
02bd 53 54 41 ds "START"
52 54 00
02c3 4d 55 4c ds "MULTI"
54 49 00Cela nous sera utile un peu plus tard.
3: Point d'entrée
À partir d'ici, on aimerait savoir où sont utilisées ces chaînes de caractères, en particulier celle qui est affichée au début du programme.
Il est normalement possible de faire clic droit puis References > Show References To Address
pour afficher tous les endroits qui utilisent une donnée, mais les références ne fonctionne actuellement pas.
Le programme étant assez petit, il est très facile de trouver ces références.
Commençons par regarder ce que le decompilateur nous donne :
void FUN_036b(void)
{
char cStack_10;
undefined2 uStack_f;
undefined1 auStack_d [8];
char local_5;
byte local_4;
undefined1 local_1;
local_1 = 0;
uStack_f = 0xb;
FUN_04fa(&UNK_023c,auStack_d);
FUN_0200(&uStack_f);
FUN_05e1("Press START");
FUN_05a6(0x80);
FUN_05e1("NOW THE CHEAT PATTERN!!\n");
do {
local_5 = FUN_057e();
if (local_5 != '\0') {
FUN_0247();
}
if (local_5 != '\0') {
if (local_5 == (&cStack_10)[local_4]) {
local_4 = local_4 + 1;
if (local_4 == 0xb) {
FUN_02c9(&cStack_10);
}
}
else {
local_4 = 0;
}
}
FUN_0606(0x50);
} while( true );
}On retrouve bien nos deux chaînes de caractères affichées au début du programme.
On peut déjà se dire qu'il s'agit de la fonction principale du programme.
Renommons donc celle-ci en main par exemple.
Pour ceci, clic droit sur le nom de la fonction dans la vue de décompilation, Rename Function ou par son raccourci, la touche L (pour Label).
On peut également dire que la fonction FUN_05e1 sert à afficher une chaîne de caractères. On va l'appeler print_string.
Voici la boucle principale du programme :
do {
local_5 = FUN_057e();
if (local_5 != '\0') {
FUN_0247();
}
if (local_5 != '\0') {
if (local_5 == (&cStack_10)[local_4]) {
local_4 = local_4 + 1;
if (local_4 == 0xb) {
FUN_02c9(&cStack_10);
}
}
else {
local_4 = 0;
}
}
FUN_0606(0x50);
} while( true );4: Lecture des boutons
local_5 = FUN_057e();
byte FUN_057e(void)
{
byte bVar1;
byte bVar2;
P1 = 0x20;
bVar1 = P1;
bVar1 = P1;
P1 = 0x10;
bVar2 = P1;
bVar2 = P1;
bVar2 = P1;
bVar2 = P1;
bVar2 = P1;
bVar2 = P1;
P1 = 0x30;
return ~(bVar2 << 4 | bVar1 & 0xf);
}On voit l'utilisation du registre P1, utilisé pour lire les entrées du joueur, comprendre les boutons pressés.
On remarque que le registre est lu plusieurs fois à la suite.
La documentation nous indique que ceci sert à "stabiliser" la prise d'information : Most programs read from this port several times in a row (the first reads are used as a short delay, allowing the inputs to stabilize, and only the value from the last read is actually used).
Toujours en lisant la documentation, on comprend qu'il est possible d'écrire dans les bits 5 et 4 de ce registre de 8 bits,
puis de le lire les 4 premiers bits pour récupérer les boutons pressées :
- 0x20 ou 0b0010 0000 : récupère les boutons du D-pad (DOWN, UP, LEFT et RIGHT);
- 0x10 ou 0b0001 0000 : récupère les autres boutons (START, SELECT, A et B);
- 0x30 ou 0b0011 0000 : les 4 premiers bits de P1 seront à 1, soit 0xf ou 0b1111.
À noter qu'un bit à 0 indique que le bouton est actuellement enfoncé.bVar1 contient ainsi les boutons DOWN, UP, LEFT et RIGHT et bVar2 les boutons START, SELECT, A et B.
Renommons les variables bVar1 et bVar2 respectivement en dpads et buttons en faisant clic droit puis Rename Variable (ou comme pour une fonction, le raccourci L).
return ~(buttons << 4 | dpads & 0xf);
La fonction retourne un octet (soit 8 bits) dont les 4 premiers bits correspondent à la valeur de buttons et les 4 derniers, ceux de dpads.
Cette valeur est ensuite inversé grâce à l'opérateur ~ (NOT), ce qui comme décrit plus haut, permet d'obtenir les boutons enfoncés comme des 1 plutôt que des 0.
On peut donc renommer cette fonction en quelque chose comme read_pressed_buttons.
byte read_pressed_buttons(void)
{
byte buttons;
byte dpads;
P1 = 0x20;
dpads = P1;
dpads = P1;
P1 = 0x10;
buttons = P1;
buttons = P1;
buttons = P1;
buttons = P1;
buttons = P1;
buttons = P1;
P1 = 0x30;
return ~(buttons << 4 | dpads & 0xf);
}Voici un tableau qui donne les valeurs en fonction des touches appuyés :
| Touche | Décimal | Hexadécimal | Binaire |
|---|---|---|---|
| RIGHT | 1 | 0x1 | 0000 0001 |
| LEFT | 2 | 0x2 | 0000 0010 |
| UP | 4 | 0x4 | 0000 0100 |
| DOWN | 8 | 0x8 | 0000 1000 |
| A | 16 | 0x10 | 0001 0000 |
| B | 32 | 0x20 | 0010 0000 |
| SELECT | 64 | 0x40 | 0100 0000 |
| START | 128 | 0x80 | 1000 0000 |
Dans la fonction main, on peut maintenant renommer la variable local_5 pour lui donner un nom plus descriptif comme pressed_buttons.
5: Affichage des boutons
if (pressed_buttons != '\0') {
FUN_0247();
}Dès qu'un bouton est appuyé, la fonction FUN_0247 est appelée :
void FUN_0247(char param_1)
{
if (param_1 == '\x01') {
print_string("RIGHT");
return;
}
if (param_1 == '\x02') {
print_string("LEFT");
return;
}
if (param_1 == '\x04') {
print_string("UP");
return;
}
if (param_1 == '\b') {
print_string("DOWN");
return;
}
if (param_1 == '\x10') {
print_string("A");
return;
}
if (param_1 == ' ') {
print_string("B");
return;
}
if (param_1 == '@') {
print_string("SELECT");
return;
}
if ((char)(param_1 + -0x80) != '\0') {
print_string(param_1 + -0x80,"MULTI");
return;
}
print_string(0,"START");
return;
}On y retrouve des références aux boutons comme A ou encore DOWN qu'on a défini plus tôt.
Sans cela, nous aurions plutôt eu quelque chose comme :
if (param_1 == '\b') {
print_string(&UNK_02a2);
return;
}ℹ️ Il est possible de changer la manière dont sont affichées les constantes.
Par exemple, on aimerait afficher 8 plutôt que \b.
Pour cela, clic droit sur la valeur et choisir Decimal: 8.
Répétez sur toutes le constantes pour un affichage plus cohérent.
On comprend rapidement que c'est la fonction en charge d'afficher les boutons appuyés.
Changeons le nom de la fonction en display_pressed_buttons et le nom du paramètre en button.
Pour cela, clic droit sur le nom de la fonction dans la vue de décompilation, puis Edit Function Signature.
Lors de l'appel de la fonction FUN_0247 depuis main, aucun paramètre n'est passé dans la décompilation.
Le fait de cocher la case Commit all return/parameter details propage les changements, ce qui répare le problème.
6: Suite de touches
if (pressed_buttons != '\0') {
if (pressed_buttons == (&cStack_10)[local_4]) {
local_4 = local_4 + 1;
if (local_4 == 0xb) {
FUN_02c9(&cStack_10);
}
} else {
local_4 = 0;
}
}cStack_10 semble être un tableau de char.
Si la valeur à l'indice local_4 correspond au bouton appuyé, on incrémente local_4.
Sinon on met local_4 à 0. Dès que local_4 est égal à 11 (0xb), la fonction FUN_02c9 est appelée.
On dirait une suite de touches à pressés dans un ordre précis.
On va renommer cStack_10 en expected_buttons et local_4 en `i`.
char expected_buttons; undefined2 uStack_f; undefined1 auStack_d [8];
On remarque que expected_buttons est défini comme char.
Cependant, on sait qu'il s'agit d'un tableau de 11 élements.undefined2 correspond à 2 octets et undefined1 à 1 octet.
Ces trois variables réunis font bien 11 octets, il s'agit d'une erreur de décompilation.
La décompilation fait au mieux avec les informations qu'elle arrive à récupérer, mais il peut arriver parfois qu'elle fasse des choix discutables.
Le code reste correct car la mémoire est sensée être contigüe, mais il serait mieux d'avoir une seule variable de type char[11].
Pour changer le type d'une variable, clic droit puis Retype Variable (ou CTRL+L).
void main(void)
{
char pressed_buttons;
char expected_buttons [11];
byte i;
expected_buttons[1] = 11;
expected_buttons[2] = 0;
FUN_04fa(&UNK_023c,expected_buttons + 3);
FUN_0200(expected_buttons + 1);
print_string("Press START");
FUN_05a6(0x80);
print_string("NOW THE CHEAT PATTERN!!\n");
do {
pressed_buttons = read_pressed_buttons();
if (pressed_buttons != 0) {
display_pressed_buttons(pressed_buttons);
if (pressed_buttons == expected_buttons[i]) {
i = i + 1;
if (i == 11) {
FUN_02c9(expected_buttons);
}
}
else {
i = 0;
}
}
FUN_0606(0x50);
} while( true );
}Voici le code actuel, plus lisible n'est-ce pas ?
7: Résoudre le challenge
Maintenant, l'objectif semble clair : appeler la fonction FUN_02c9.
Deux méthodes :
1. Patch la ROM pour appeler la fonction ;
2. Trouver ce qui se trouve dans expected_buttons pour reconstituer la suite à saisir.
8: Patch de la ROM
La manière la plus simple serait de sauter directement à 0x03d2.
03d2 3e 0b LD pressed_buttons, 0xb 03d4 21 00 00 LD HL, 0x0 03d7 39 ADD HL, SP 03d8 5d LD E, L 03d9 54 LD D, H 03da cd c9 02 CALL FUN_02c9
On peut sauter directement dès qu'une touche est enfoncée :
03ac f8 0b LD HL, SP+0xb 03ae 7e LD pressed_buttons, (HL => local_5) 03af b7 OR pressed_buttons 03b0 28 31 JR Z, LAB_03e3 ; changer ce saut
03b0 28 20 JR Z, LAB_03d2
9: Trouver la suite
10: Lecture de la mémoire
⚠️ TODO.
0x08 DOWN 0x04 UP 0x04 UP 0x08 DOWN 0x01 RIGHT 0x01 RIGHT 0x02 LEFT 0x01 RIGHT 0x20 B 0x10 A 0x10 A
Le crackme gameboy du CTF est disponible ici:
📥 gamerboy.tgz
