~/

🇫🇷 AMSI CTF 2025 - Cavormice [reverse]

Difficulty : Medium

Team : Phreaks 2600

Source files

Description : La petite souris est perdue dans cette grotte labyrinthique ! Peux-tu l’aider à trouver le chemin vers le trésor dans la salle du haut ?

Le format du drapeau est AMSI{XXXXXXXX…} oĂą X est une direction parmi {U,D,L,R}, respectivement pour Up, Down, Left, Right.

Trouve le bon chemin.

Author : Hexwreaker

Topics :

TL;DR

  • Il y a un endroit en mĂ©moire qui stocke tous nos dĂ©placements.
  • Et chaque paire de dĂ©placement sera xorĂ©e et comparĂ©e avec un octet prĂ©determinĂ©, si on arrive Ă  valider ces valeurs, on valide le jeu et on peut gagner la partie.

Introduction

Ce challenge sort du lot car il s’agit de faire du reverse sur un jeu de gameboy. Le but est de mener notre souris vers le bon chemin pour dĂ©couvrir un trĂ©sor. Voici un quoi ressemble le jeu :

main menu

Prise en main

Pour Ă©muler le jeu j’ai utilisĂ© mgba-qt qui peut Ă©muler des jeu gameboy et gameboy advance et intĂ©gre une fonction pour voir la mĂ©moire et chercher des valeurs Ă  l’intĂ©rieur.

Je me baladais mais je trouvais rien de spĂ©cial, j’ai remarquĂ© que la salle du haut Ă©tait toujours la meme peut importe Ă  quel point on se baladait avec la souris, mĂŞme si on descendait puis on remontait, la salle est toujours lĂ ..

promenade de souris

Et au bout d’un moment après avoir assez jouĂ©, on finit par perdre, et notre souris rebrousse chemin sans un sou.

pauvre souris

Donc on comprend qu’il y a des conditions Ă  remplir pour pouvoir gagner.


Repérage dans la mémoire

J’ai dĂ©cidĂ© de jeter un oeil en mĂ©moire car il devrait y avoir des valeurs qui changent comme notamment la position de ma souris. Avec mgba-qt on peut afficher la mĂ©moire depuis outils > game state view > voir la mĂ©moire.

L’espace mĂ©moire est petit, de 0 Ă  0xFFFF donc 65535 octets. Et Ă  certain endroits il y a des valeurs qui bougent en permanence.

Donc j’ai pensĂ© chercher l’endroit ou sont Ă©crits les dĂ©placement de la souris. J’ai notĂ© mes dĂ©placements, (haut/haut/bas/droite -> UUDR) d’ailleurs un dĂ©placement correspond Ă  une salle visitĂ©e et non Ă  un pas de souris. Puis je les ai cherchĂ© en mĂ©moire avec un autre utilitaire de mgba-qt :

On dĂ©couvre qu’Ă  l’adresse 0xC808 se trouve l’historique des dĂ©placements, ainsi que deux octets qui gèrent la position de la souris (pas très important mais bon Ă  savoir).

Et lorsqu’on continue de jouer, le 33ème dĂ©placement met fin Ă  la partie.

Maintenant l’objectif est de savoir quelles sont les conditions de victoire.


Décompilation du jeu

Vu que je ne sais pas lire une ROM Gameboy de tĂŞte, j’ai cherchĂ© si c’Ă©tait faisable sur Ghidra et effectivement il y a une extension pour ça : https://github.com/Gekkio/GhidraBoy.

Il y a le mĂŞme nombre d’octet qui sont lus (0xFFFF, 65535) et Ă  des endroits on retrouve du code.

En regardant Ă  l’adresse 0xC808, celle oĂą les dĂ©placements sont stockĂ©s on voit qu’un espace de 32 octets est rĂ©servĂ©e.
Et qu’il y a 3 rĂ©fĂ©rences croisĂ©es vers cette addresse:

image cross references

références croisées

dont une est plutôt intéressante :

fun_0729

FUN_0729
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
undefined FUN_0729(void)

{
  char cVar1;
  int iVar2;
  byte bVar3;
  
  DAT_c807 = 0;
  if (DAT_c838 == ' ') {
    for (iVar2 = 0; bVar3 = (byte)((uint)iVar2 >> 8),
        (byte)(!(bool)(bVar3 >> 7) << 7 | bVar3 & 0x7f) < (byte)(((byte)iVar2 < 0x10) + 0x80U);
        iVar2 = iVar2 + 1) {
      cVar1 = (byte)iVar2 * '\x02';
      if ((&DAT_c828)[iVar2] != ((&DAT_c808)[(char)(cVar1 + '\x01')] ^ (&DAT_c808)[cVar1])) {
        DAT_c807 = 0;
        return 0;
      }
    }
    DAT_c807 = 1;
  }
  return DAT_c807;
}

On voit que la valeur Ă  l’indice cVar1 de DAT_c808 est xorĂ©e avec l’a valeur Ă  l’indice cVar1+1 de DAT_c808 puis comparĂ©e Ă  la valeur Ă  l’indice iVar2 de &DAT_c828.

Si la valeur est diffĂ©rente, la fonction s’arrĂŞte grâce Ă  un return 0, sinon la boucle continue et cVar1 et iVar2 sont incrĂ©mentĂ©s.

On comprend qu’il faut Ă©viter que la fonction s’arrĂŞte prĂ©maturĂ©ment, mais aussi que nos dĂ©placements sont pris par paires, xorĂ©s et comparĂ©s avec une valeur Ă  l’adresse 0xC828.

Par ailleurs, cette valeur n’est pas Ă©crite en dur dans Ghidra mais on a l’espace d’adressage et il est sur 16 octets. Si on veut la valeur cependant il faudra Ă©muler le jeu.

La valeur est la suivante : 191908160700191108161119001E0711

Recomposition du bon chemin

Ainsi la chaine à obtenir est le resultat de 16 opérations de XOR entre différentes directions parmi : D,U,L,R.

Et selon la chaine, il y a 7 valeurs de XOR possible:

  • 19
  • 08
  • 16
  • 07
  • 00
  • 11
  • 1e

On va donc commencer par gĂ©nĂ©rer une tables qui fait correspondre les paires de direction avec le rĂ©sultat du XOR qu’elles donnent.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from itertools import product

to_get = [0x19, 0x08, 0x16, 0x07, 0x00, 0x11, 0x1e]

directions = {
  "U" : 0x55,
  "D" : 0x44,
  "R" : 0x52,
  "L" : 0x4C
}

xor_values = {}

for a,b in product(directions.values(), repeat=2):
	r = a^b 
	if r in to_get:
		pair = chr(a)+chr(b)
		xor_values[pair] = hex(r)

On obtient ces valeurs :

UU 0x0
UD 0x11
UR 0x7
UL 0x19
DU 0x11
DD 0x0
DR 0x16
DL 0x8
RU 0x7
RD 0x16
RR 0x0
RL 0x1e
LU 0x19
LD 0x8
LR 0x1e
LL 0x0

On peut mĂŞme recomposer Ă  la main le chemin satisfaisant.
Mais il faut garder Ă  l’esprit ces contraintes :

  • Le premier mouvement de la partie est toujours U, car c’est le seul mouvement possible au lancement du jeu.
  • une fois entrĂ©e dans la grotte, tout dĂ©placement de la souris vers le haut ne peut donner suite qu’Ă  un dĂ©placement vers le bas (on est bloquĂ© dans la caverne).

Par exemple, pour le dĂ©but on doit avoir 19 19 donc soit UL soit LU. Etant donnĂ© qu’au dĂ©but on va vers le haut pour rentrer dans la grotte, on ne peut commencer que par U donc UL.

Puis on a un deuxième 0x19. Soit UL soit LU, mais si on choisit UL, une fois montĂ© avec U, on sera bloquĂ© dans la grotte et on ne pourra faire qu’un mouvement vers le bas pour en sortir. Donc on lieu d’avoir UL on aurait un UD.

Donc le bon choix est LU. En continuant ainsi on peut commencer Ă  construire notre chemin.

Il y a parfois des choix avec plusieurs solutions valides comme RL et LR donnant lieu à une multitude de chemins menant chacun vers la victoire. Ces chemins étaient tout de même valide pour obtenir le flag après avoir demandé au chall maker.

Au final avec ces raisonnements on obtient par exemple ce chemin avec un script : ULLUDLDRRUDDLUDUDLDRUDLUDDLRRUDU

solve.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from itertools import product

to_get = [0x19, 0x08, 0x16, 0x07, 0x00, 0x11, 0x1e]

directions = {
  "U" : 0x55,
  "D" : 0x44,
  "R" : 0x52,
  "L" : 0x4C
}

xor_values = {}

for a,b in product(directions.values(), repeat=2):
    r = a^b
    if r in to_get:
        pair = chr(a)+chr(b)
        xor_values[pair] = hex(r)

xor_values.pop("UU")
xor_values.pop("UR")
xor_values.pop("UD")

password = bytes.fromhex("191908160700191108161119001E0711")

result = ""

for val in password:
    if result == "":
        result += "UL"
        xor_values.pop("UL")
        continue

    for i in xor_values.items():
        if int(i[1], 16) == val:
            result += i[0]
            break

print(result)

Qui nous permet de gagner :

Flag : AMSI{ULLUDLDRRUDDLUDUDLDRUDLUDDLRRUDU}

lets go