Catégories
ECW 2021 Reverse Write-ups

ECW 2021 – Go Game

Dans cet article nous allons faire le write-up d’un deuxième challenge de reverse engineering que j’ai pu résoudre durant l’ECW (European Cyber Week). Celui-ci était plus corsé que le premier (Chest) : avec 6 solves en deux semaines, c’est en quelque sorte le challenge facile des challenges de reverse difficiles de l’ECW 2021 🙂.

Énoncé du challenge

We received an email with a strange binary as well as a command to execute nc 10.4.0.12 12345. The only message left with this mail is that if we find out what this binary does and send a valid response to the server, a hell of a reward awaits us.

Connect through the VPN to access the challenge to tcp://10.4.0.12:12345

Fichier du challenge

Vous trouverez dans cette archive, en plus du binaire, mon idb (IDA database) construite avec IDA Free.

Version exacte d’IDA utilisée

Premier contact

Comme d’habitude, je commence par faire un file sur le binaire pour récupérer quelques informations.

$ file gogame
gogame: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=8492ac52b64d98d6d860c96fec2898123526ec6c, for GNU/Linux 3.2.0, stripped

On a donc affaire à un ELF x86_64. Point négatif, le programme est compilé en statique et strippé. On va donc devoir se repérer nous-mêmes dans les différentes fonctions de la libc (chose qui m’a fait perdre beaucoup de temps lors du CTF, j’ai beaucoup manqué d’intuition sur ce challenge…).

On peut alors le lancer en local pour observer un premier comportement du programme.

$ ./gogame 
aaaaaaaaaaa
User input is not correct

$ ./gogame 
1234
User input is not correct

A première vue rien de spécial, un input utilisateur et un message qui nous indique que notre input n’est pas correct.

Fonctions de la libc

Une bonne pratique à mon sens, lorsqu’on traite un binaire compilé statiquement et strippé, est de commencer par identifier les fonctions de la libc utilisées dans le programme et les renommer pour y voir plus clair (renommer le maximum de choses comprises est une bonne idée de manière générale).

PS : J’ai conscience qu’il n’est pas nécessaire de détailler tout cela mais comme c’est une partie importante quand on analyse un binaire de cette sorte (on retrouve par exemple beaucoup de malwares compilés de la sorte) j’ai décidé de décrire tout mon processus de recherche.

Dans la fonction main

Dans un premier temps, je commence par ouvrir le binaire dans IDA et je cherche le main.

Entry point du programme

On repère ici l’entry point du programme (fonction start) qui va appeler une fonction qui n’est autre que _libc_start_main. On sait donc que notre main se trouve en “sub_401F62” (premier argument passé à _libc_start_main). Je renomme alors ces deux fonctions et me penche sur le main.

malloc et free

Une fois le main ouvert, je vais utiliser le “cloud decompiler” fourni avec IDA Free pour accélérer l’analyse et je remarque en début de fonction des appels répétitifs à la fonction “sub_4288D0” à laquelle il est juste passé un paramètre de type int. Je pense alors à malloc() mais je vais creuser un peu pour en être certain.

  v16 = sub_4288D0(120LL);
  v15 = sub_4288D0(16LL);
  *(_QWORD *)(v15 + 8) = 0LL;
  for ( i = 0; i <= 14; ++i )
    *(_QWORD *)(8LL * i + v16) = sub_4288D0(240LL);
  v17 = sub_4288D0(32LL);

On regarde alors le code de celle-ci et on y trouve ceci :

sub_423BA0(
    "!victim || chunk_is_mmapped (mem2chunk (victim)) || &main_arena == arena_for_chunk (mem2chunk (victim))",
    "malloc.c",
    3059LL,
    "__libc_malloc"
);

Nous avons donc bien affaire à malloc. Je renomme la fonction et continue : une fonction “sub_423BA0” est appelée de multiples fois à la fin du programme avec comme unique paramètre un buffer, et cela pour les buffers alloués par malloc(). Je pense alors à la fonction free() et vais vérifier dans le code : je trouve dans cette fonction un appel à sub_423DD0 dont le code est le suivant.

__int64 __fastcall sub_423DD0(_QWORD *a1)
{
  unsigned __int64 v1; // r8
  unsigned __int64 v2; // rsi

  if ( (a1[1] & 2) == 0 )
LABEL_5:
    sub_423BA0("chunk_is_mmapped (p)", "malloc.c", 2813LL, "munmap_chunk");
  v1 = (unsigned __int64)a1 - *a1;
  v2 = (a1[1] & 0xFFFFFFFFFFFFFFF8LL) + *a1;
  if ( (((qword_4C3118 - 1) & (unsigned __int64)(a1 + 2)) - 1) & (qword_4C3118 - 1) & (unsigned __int64)(a1 + 2) | (v2 | v1) & (qword_4C3118 - 1) )
  {
    sub_423C00("munmap_chunk(): invalid pointer");
    goto LABEL_5;
  }
  _InterlockedDecrement(&dword_4C2728);
  _InterlockedAdd64(&qword_4C2738, -(__int64)v2);
  return sub_454740(v1);
}

On a donc ici visiblement un appel à munmap_chunk(), ce qui nous confirme que le fonction est bien free(). Je la renomme et passe à la suite.

En cas de doute, il peut être utile de regarder le code source des fonctions malloc et free.

printf et puts

Je continue alors mon exploration du main et je tombe sur ce bloc intéressant.

  for ( j = 0; j < v13; j += 2 )
  {
    sub_4025DE(v16, v17, v15);

    if ( (unsigned int)sub_4034BA(v16, (unsigned int)v19[j], (unsigned int)v19[j + 1]) )
      sub_41AFD0("Are you trying to cheat? You lose a round!");
    else
      sub_402A4A(v16, (unsigned int)v19[j], (unsigned int)v19[j + 1], 2LL, v15);

    v14 = sub_403878(v15);

    if ( v14 == 2 )
    {
      sub_41AFD0("You lost, try again!");
      break;
    }

    if ( v14 == 1 )
    {
      v3 = sub_411C00("FLAG");
      sub_412FD0((unsigned int)"You won: %s\n", v3, v4, v5, v6, v7, j);
      break;
    }
  }

On remarque alors 2 fonctions utilisées pour afficher du texte : sub_41AFD0 et sub_412FD0. Les chaînes de caractères passées en paramètre à sub_41AFD0 ne contenant pas de retour à la ligne (\n) en fin de chaîne, il s’agit probablement de la fonction puts() de la libc qui se chargera de print un retour à la ligne en plus en fin de chaîne.

Pour ce qui est de la fonction sub_412FD0, celle-ci va utiliser un formatage %s pour afficher une variable dans la chaîne de caractères : c’est la fonction printf (vous remarquerez ici qu’IDA gère assez mal le nombre de paramètres passés à cette fonction).

getenv

Dans le bloc de code précédent, je remarque également une fonction très intéressante :

flag = sub_411C00("FLAG");
printf((unsigned int)"You won: %s\n", flag, v4, v5, v6, v7, j);

La fonction sub_411C00(“FLAG”) va ainsi retourner le flag qui sera affiché. Cette fonction fait partie des fonctions de la libc m’ayant fait perdre du temps car je l’ai reverse entièrement avant de comprendre ce que c’était en réalité…

On ne va pas perdre de temps à l’étudier ici, sachez juste que cette fonction va retourner un char* contenant la variable d’environnement dont le nom est passé en paramètre (ce sera donc le contenu de la variable d’environnement “FLAG” dans notre cas). Vous pouvez trouver le code source de getenv ici.

fgets

Nous avons vu qu’un input est demandé à l’utilisateur : on va donc se mettre en quête de cette fonction afin de l’identifier et d’identifier le buffer qui contiendra notre input. Pour cela, je lance le debug du main avec IDA en single step jusqu’à ce que le programme attende une entrée utilisateur.

Debug du début de la fonction main

On remarque alors que notre input est attendu par la fonction “sub_41A900”. Cette fonction prend en premier paramètre le buffer [rbp+var_80] (que je renommerai en USER_INPUT par la suite), 100 (0x64, pour la taille maximale d’input) et un pointeur vers la valeur 0xFBAD2088, contenu dans cs:off_4C26D8 (je n’ai pas trouvé d’explication à ce sujet pour le moment, je ne veux pas dire de bêtises). Ça ressemble ainsi à un fgets(char* USER_INPUT, 100, stdin), et même si ce n’est pas le cas après essais/erreurs on se rend compte que c’est juste une fonction qui va prendre un input de max 100 caractères : je renomme donc cette fonction fgets.

Dans check_user_input

Nous avons vu quelque chose d’intéressant lors de notre premier contact avec le binaire. Laissez-moi vous rafraîchir la mémoire :

$ ./gogame 
aaaaaaaaaaa
User input is not correct

$ ./gogame 
1234
User input is not correct

On voit que la chaîne “User is not correct” apparait, mais nous n’avons pas vu passer cette chaîne de caractères dans le main. On va donc regarder les strings présentes dans le binaire et ensuite trouver via les cross-references dans quelle fonction cette chaîne est utilisée.

Cross-references de la chaîne “User input is not correct”

On va donc aller regarder de plus près la fonction sub_401DA9, que je vais renommer check_user_input par la suite.

strlen et exit

Le code de la fonction commence de la manière suivante :

stack_coockie = __readfsqword(0x28u);
virgule = ',';
v5 = 0;
_USER_INPUT = (_BYTE *)USER_INPUT;

if ( (unsigned __int64)sub_401180((_BYTE *)USER_INPUT) > 101 ) {
   puts((__int64)"You can do better!");
   sub_411FA0(1LL);
}

Si le résultat de sub_401180(USER_INPUT) est supérieur à 101 alors le message “You can do better!” s’affiche et enfin est appelée la fonction sub_411FA0(1).

Après une analyse dynamique et quelques essais, on se rend compte que sub_401180(USER_INPUT) retourne en fait la taille de notre input (en comptant le \n à la fin), c’est donc strlen(), et sub_411FA0(1) quitte le programme avec 1 en code d’erreur : c’est donc exit().

strtok (+ wrapper)

La suite de la fonction est une boucle qui commence de la manière suivante :

  while ( 1 )
  {
    v10 = sub_42BFB0(_USER_INPUT, (__int64)";", &_USER_INPUT);
    if ( !v10 )
      break;

    v11 = sub_42BFA0(v10, (__int64)&virgule);

Après quelques essais en utilisant l’analyse dynamique du programme, on se rend compte que la fonction sub_42BFB0 va en fait remplacer la première occurrence d’un point virgule dans notre input par un zéro (une sorte de split en soit). Celle-ci va ensuite créer un nouveau pointeur vers la partie splitée (avant le premier point virgule trouvé) qui sera passé en paramètre de la fonction sub_42BFA0 qui n’est autre que l’appel de la même fonction, avec une virgule à la place du point virgule.

sub_42BFA0 est un wrapper de sub_42BFB0

Le comportement de la fonction sub_42BFB0 est celui de la fonction strtok ! On passe à la suite.

atoi (wrapper de strtol)

Une fois les variables renommées, on continue notre lecture de la boucle.

    buffer = wrap_strtok(parsed_point_virgule, (__int64)&virgule);

    if ( !buffer || (*(_WORD *)(*(_QWORD *)sub_404D80() + 2LL * *buffer) & 0x800) == 0 )
    {
      puts((__int64)"User input is not correct");
      exit(1LL);
    }

    v7 = sub_411380(buffer);

Une fois notre input split par “;” puis par “,”, le résultat est passé dans la fonction sub_411380 (on détaillera la condition de test avec sub_404D80() plus tard). Il m’a fallu un peu (trop) de temps pour comprendre cette fonction qui me retournait un int en prenant un char* en paramètre : il s’agit de la fonction atoi qui va par exemple convertir la chaîne “14” vers le nombre 14.

Cette fonction atoi est en fait un wrapper de la fonction strtol : c’est celle-ci qui m’a fait perdre beaucoup de temps et d’énergie car je l’ai reverse plutôt que de deviner son fonctionnement en observant les entrées et sorties de la fonction. Je vous épargne le code mais je vous laisse profiter du magnifique flowgraph qui vous donnera une idée de ce dans quoi je me suis embarqué…

Flowgraph de la fonction strtol

On peut alors voir que la fonction atoi est un wrapper de strtol(char* buffer, NULL, 10) comme on peut le vérifier avec le code source de la fonction atoi :

/* Convert a string to an int.  */
int atoi (const char *nptr) {
  return (int) strtol (nptr, (char **) NULL, 10);
}
libc_hidden_def (atoi)

check_user_input

Vérification d’un input numérique

Comme nous l’avons vu précédemment, une condition intrigante est à remplir avant que l’input parsé soit donné à la fonction atoi, sans quoi le message que nous avons eu à l’exécution sera affiché suivi d’un exit(1).

    buffer = wrap_strtok(parsed_point_virgule, (__int64)&virgule);

    if ( !buffer || (*(_WORD *)(*(_QWORD *)sub_404D80() + 2LL * *buffer) & 0x800) == 0 )
    {
      puts((__int64)"User input is not correct");
      exit(1LL);
    }

    v7 = atoi(buffer);

Pour examiner cette condition il nous faut donc d’abord savoir ce que va retourner la fonction sub_404D80.

Code de la fonction sub_404D80

J’ai encore une fois utilisé l’analyse dynamique pour vérifier ce que retournait cette fonction : il s’agit un buffer contenu en 0x49EA60 dans la section .rodata (read-only data) que j’ai renommé “buffer_2020”.

“Buffer_2020” dans la section .rodata

Comme on le voit dans la suite de la fonction, le premier caractère de notre input splitté va être multiplié par 2 et le résultat va servir d’index pour piocher un WORD (2 octets) dans le buffer_2020.

Code désassemblé de la condition étrange

J’ai alors établi une liste des différentes valeurs possibles trouvables dans le buffer avec les adresses correspondantes, en admettant que celui-ci est au maximum de taille 2 * 0xFF == 510 octets : je fais donc la liste des valeurs possibles entre 0x49EA60 et (0x49EA60 + 510) = 0x49EC5E.

***** Value 0 *****
0x49EB60 --> 0x49EC5E

***** Value 2 *****
0x49EA60 --> 0x49EA70
0x49EA7C --> 0x49EA9E
0x49EB5E

***** Value 0x2002 *****
0x49EA74 --> 0x49EA7A

***** Value 0x2003 *****
0x49EA72

***** Value 0x6001 *****
0x49EAA0

***** Value 0xC004 *****
0x49EAA2 --> 0x49EABE
0x49EAD4 --> 0x49EAE0
0x49EB16 --> 0x49EB20
0x49EB56 --> 0x49EB5C

***** Value 0xC508 *****
0x49EAEE --> 0x49EB14

***** Value 0xC608 *****
0x49EB2E --> 0x49EB54

***** Value 0xD508 *****
0x49EAE2 --> 0x49EAEC

***** Value 0xD608 *****
0x49EB22 --> 0x49EB2C

***** Value 0xD808 *****
0x49EAC0 --> 0x49EAD2

Nous avons donc la liste des différentes valeurs. Ensuite, il faut garder en tête que le résultat de “valeur & 0x800” sera comparé à 0 : je code donc un rapide programme en C qui me permettra de dire quelles valeurs donnent un résultat != 0 avec cette opération.

#include <stdio.h>
#include <stdlib.h>

int main(void){

    int values[11] = {0, 2, 0x2002, 0x2003, 0x6001, 0xC004, 0xC508, 0xC608, 0xD508, 0xD608, 0xD808};

    for(int i=0; i < 11; i++){

        int and_value = values[i] & 0x800;
        printf("0x%x & 0x800 = 0x%x (%d)\n", values[i], and_value, and_value);
    }
    return 0;
}

Je compile alors le code et observe le résultat de l’exécution.

$ gcc -o v2_and_0x800 v2_and_0x800.c 
$ ./v2_and_0x800 
0x0 & 0x800 = 0x0 (0)
0x2 & 0x800 = 0x0 (0)
0x2002 & 0x800 = 0x0 (0)
0x2003 & 0x800 = 0x0 (0)
0x6001 & 0x800 = 0x0 (0)
0xc004 & 0x800 = 0x0 (0)
0xc508 & 0x800 = 0x0 (0)
0xc608 & 0x800 = 0x0 (0)
0xd508 & 0x800 = 0x0 (0)
0xd608 & 0x800 = 0x0 (0)
0xd808 & 0x800 = 0x800 (2048)

On trouve alors une seule valeur qui remplit la condition : 0xD808. Il va donc falloir faire en sorte de tomber dans le range 0x49EAC0 – 0x49EAD2 du buffer_2020. Calculons ce que cela représente comme caractères.

>>> buffer_adr = 0x49EA60
>>> chr((0x49EAC0 - buffer_adr) // 2)
'0'
>>> chr((0x49EAD2 - buffer_adr) // 2)
'9'

Bon… Eh bien cette condition vérifiait juste que notre input parsé (du moins le premier caractère) était bien une valeur numérique, comprise entre le caractère “0” et le caractère “9”… Un peu de temps perdu ici encore mais ce n’est pas grave, on avance 😁 !

Conclusion sur la fonction

La conversion en int de notre partie input sera effectuée sur les deux parties de l’output de la fonction qui va split par “,” et tout ce gros bloc sera effectué pour toutes les parties de notre input une fois split par “;”. Les int seront alors stockés dans une structure, que l’on verra plus tard, passée en 2ème paramètre à la fonction check_user_input.

En d’autres termes, la fonction check_user_input va vérifier que notre input est de la forme suivante :

<int>,<int>;<int>,<int>;<int>,<int>[...]

Par ailleurs, comme nous l’avons vu plus tôt, la fonction va vérifier que la taille totale de l’input n’est pas supérieure à 101. Cependant, nous avons vu que le fgets récupère un input de maximum 100 caractères et que donc cette condition ne sera donc jamais vraie ! Cette fonction va ensuite retourner le nombre de couples de la forme <int>,<int> entrés par l’utilisateur.

Remarque : on se rend alors compte que notre input est en fait un série de couples de valeurs décimales, il faudra garder cet élément en tête pour la suite.

Retour au main

Une fois les fonctions de la libc et le format d’input identifiés, on peut alors retourner observer le main depuis le début et inspecter les différentes fonctions sur lesquelles nous ne sommes pas encore passé. La fonction main commence comme ceci :

  stack_coockie = __readfsqword(0x28u);

  fgets(USER_INPUT, 100LL, stdin);

  v12 = malloc(0x78LL);
  v11 = (__int64 *)malloc(0x10LL);
  v11[1] = 0LL;

  for ( i = 0; i <= 14; ++i )
    *(_QWORD *)(8LL * i + v12) = malloc(0xF0LL);

  v13 = malloc(0x20LL);
  v8 = sub_401D95();
  sub_4022F7(v12);

Je renomme v11 en “structure_1” et v13 en “structure_2” (je n’ai pas suivit exactement le fonctionnement ni l’utilisation de ces structures par flemme mais ce n’est pas nécessaire pour résoudre le challenge). Pour ce qui est de la fonction sub_401D95(), celle-ci est très simple car elle va juste retourner la valeur 0xEFC8B8.

Code de la fonction sub_401D95

Je renomme alors cette fonction “ret_0xEFC8B8” et la variable v8 deviendra “val_0xEFC8B8”.

Un tableau à double entrée

Dans les différentes allocations, on retrouve une variable nommée v12 qui sera un pointeur vers un buffer de 0x78 octets qui sera, suite à la boucle for, un pointeur de pointeurs vers des buffers de 0xF0 octets. On peut alors se représenter cette structure comme un tableau à 2 entrées.

Ce pointeur est ensuite passé en paramètre à la fonction sub_4022F7. Voici le code décompilé de celle-ci :

__int64 __fastcall sub_4022F7(__int64 a1)
{
  int i; // [rsp+10h] [rbp-8h]
  int j; // [rsp+14h] [rbp-4h]

  for ( i = 0; i <= 14; ++i )
  {
    for ( j = 0; j <= 14; ++j )
    {
      if ( j != 14 && i != 14 && j && i )
        *(_DWORD *)(16LL * j + *(_QWORD *)(8LL * i + a1)) = 0;
      else
        *(_DWORD *)(16LL * j + *(_QWORD *)(8LL * i + a1)) = -1;
    }
  }
  return 1LL;
}

Cette fonction va donc visiblement itérer sur la zone allouée comme sur un tableau à double entrée en mettant des 0 dans une case sur deux (d’où le 16 * j, une case correspondant visiblement à un QWORD, soit 8 ocets) de chaque ligne du tableau. On remarque également que la première et la dernière lignes seront initialisées avec des -1 (soit 0xFFFFFFFF) tout comme les premières et dernières cases de chaque lignes.

Beaucoup d’indices : que cache ce programme ?

On a alors beaucoup d’indices sur le challenge :
– un tableau à double entrée
– un input composé de couples de int
– le titre du challenge “go game”

En effet, il s’agit ici d’une implémentation du jeu de go ! C’est un jeu de société d’origine chinoise où deux adversaires s’affrontent en plaçant des pierres sur un plateau. Le joueur B a les pierres blanches et le joueur A avec les pierres noires commence à jouer. Le but est de poser des pierres sur les intersections des lignes du goban (la grille) et d’encercler les pierres de l’adversaire. Vous trouverez plus de précisions sur les règle ici. On note au passage que “lors d’une partie typique, les joueurs se placent d’abord proches des coins”.

NOTE : Dans la suite de cet article je vais probablement inverser à certains endroit x et y, mais ce n’est fondamentalement pas un problème vu que le goban est un carré 😇

On renomme alors la variable v12 en “goban” et la fonction sub_4022F7 en “initialize_goban”. On remarque également que le goban est composé de 15 lignes avec la première et la dernière initialisées à -1 comme pour délimiter le bord (voir la fonction initialize_goban). On peut alors en déduire après lecture des spécificités du goban que celui est d’une taille de 13×13 (une taille réduite “pour jouer des parties rapides ou pour faciliter l’apprentissage des règles du jeu”). On peut alors lancer une analyse dynamique et observer notre goban initialisé.

Visualisation du goban en mémoire

Voici donc à quoi ressemble une ligne au hasard, en identifiant la place des différentes cases.

Ligne n°8 du goban, une fois celui-ci initialisé

De plus, on peut alors se rendre compte que la fonction check_user_input va en fait vérifier que l’input correspond bien à des couples de coordonnées correspondant chacun à un tour de jeu de notre part. Ainsi, entrer “1,1;2,2” placera deux de nos pions sur le goban : le premier en x=1 et y=1 et le deuxième en x=2 et y=2. La fonction va ensuite retourner le nombre de coups que l’utilisateur jouera au total.

Si on revient sur cette fonction rapidement, on remarque alors que ces coordonnées seront stockées dans une structure passée en paramètre que je renommerai par la suite “struct_coord”.

Stockage des coordonnées calculées dans struct_coord

Ceci est facilement vérifiable avec de l’analyse dynamique : avec l’input “1,1;2,2;3,3;4,4;5,5” on se retrouve avec ceci en mémoire.

struct_coord en mémoire

On observe alors une dernière fonction non identifiée appelée avant la boucle principale du programme :

  initialize_goban(goban);

  *(_QWORD *)(structure_2 + 8) = sub_4023A5(goban, val_0xEFC8B8, structure_1);
  *(_DWORD *)(structure_2 + 16) = *(_DWORD *)(*(_QWORD *)(structure_2 + 8) + 4LL);
  *(_DWORD *)(structure_2 + 20) = *(_DWORD *)(*(_QWORD *)(structure_2 + 8) + 8LL);
  *(_DWORD *)(structure_2 + 24) = 5;
  *(_DWORD *)structure_2 = 1;

Cette fonction étant assez longue pour pas grand chose d’intéressant, je vais juste l’exécuter pas à pas et observer ses actions. Ce qui est le plus notable à mon sens est une modification de la case goban[12][1] (je suppose que le goban va de goban[1][1] à goban[13][13] en accord avec ce que j’ai pu trouver précédemment).

Case 1 du goban modifiée

On repère le dernier octet du QWORD de la case 1 : c’est la valeur 1. On a donc goban[12][1] = 1 et on conjecture alors qu’une case prise par l’adversaire aura la valeur 1 (pierres noires) et qu’une case prise par l’utilisateur aura la valeur 2 (pierres blanches). Je renomme cette fonction en “initial_move”.

Boucle principale

La boucle principale du main correspond donc pour l’instant à ceci.

  nb_coups = check_user_input((__int64)USER_INPUT, (__int64)struct_coord);

  for ( j = 0; j < nb_coups; j += 2 )
  {
    sub_4025DE(goban, structure_2, structure_1);

    if ( (unsigned int)sub_4034BA(goban, struct_coord[j], struct_coord[j + 1]) )
      puts((__int64)"Are you trying to cheat? You lose a round!");
    else
      sub_402A4A(goban, struct_coord[j], struct_coord[j + 1], 2, (__int64)structure_1);

    check_result = check_winner(structure_1);

    if ( check_result == 2 )
    {
      puts((__int64)"You lost, try again!");
      break;
    }

    if ( check_result == 1 )
    {
      flag = (const char *)getenv("FLAG");
      printf((__int64)"You won: %s\n", flag);
      break;
    }
  }

J’ai ici déjà renommé la fonction “check_winner” car sa valeur de retour est devinable en lisant le code : elle va retourner 1 si on gagne, 2 si on perd et 0 si la partie n’est pas terminée ou en cas d’égalité (on peut alors quitter le programme sans message nous indiquant que l’on a gagné ou perdu).

get_rock_from_goban

La première fonction que l’on peut analyser rapidement est la fonction sub_4034BA. Si sa valeur de retour est différente de zéro, alors le programme va nous afficher le message “Are you trying to cheat ? You loose a round!”.

__int64 __fastcall get_rock_from_goban(__int64 goban, int x, int y)
{
  if ( y <= 0 || y > 14 || x <= 0 || x > 14 )
    return 0xFFFFFFFFLL;
  else
    return *(unsigned int *)(16LL * y + *(_QWORD *)(8LL * x + goban));
}

Cette fonction va alors retourner -1 si on entre des coordonnées x et y en dehors du goban, sinon elle va nous retourner la valeur contenue à la case goban[x][y]. Cette valeur devra être zéro pour pouvoir y placer une pierre, sinon cela veut dire que la case est déjà occupée. Je renomme donc cette fonction “get_rock_from_goban”.

apply_move

Si la fonction get_rock_from_goban retourne bien zéro alors la fonction sub_402A4A est appelée avec comme paramètres le goban, la position x, la position y, la valeur 2 qui sera mise dans la case (la valeur 1 est passée en paramètre lorsque c’est l’adversaire qui joue comme nous l’avons vu plus tôt) ainsi que la structure_1.

Encore une fois il faut savoir prendre un peu de recul car cette fonction fait beaucoup d’opération mais on peut la résumer au titre “apply_move” : elle va mettre la valeur 1 ou 2 passée en paramètres dans la case goban[x][y], comme nous le verrons avec de l’analyse dynamique plus tard.

adversary_move

La première fonction appelée dans la boucle principale de la fonction main va utiliser les fonctions que nous avons déjà analysées. Voici un extrait de cette fonction :

  if ( v7 != 5 )
  {
    if ( v7 == 7 )
    {
      if ( v6 >= *(_DWORD *)structure_2 + v9 )
      {
        *(_DWORD *)(structure_2 + 24) = 6;
        *(_DWORD *)(structure_2 + 16) = v5 + 1;
        if ( (unsigned int)get_rock_from_goban(goban, v5 + 1, v6) )
          return adversary_move(goban, structure_2, structure_1);
        return apply_move(goban, *(_DWORD *)(structure_2 + 16), v6, 1, structure_1);
      }
      *(_DWORD *)(structure_2 + 24) = 7;
      *(_DWORD *)(structure_2 + 20) = v6 + 1;
      if ( (unsigned int)get_rock_from_goban(goban, v5, v6 + 1) )
        return adversary_move(goban, structure_2, structure_1);
    }
    else
    {
      if ( v7 != 6 )
      {
        if ( v6 <= v9 - *(_DWORD *)structure_2 )
        {
          *(_DWORD *)(structure_2 + 24) = 5;
          *(_DWORD *)(structure_2 + 16) = v5 - 1;
          ++*(_DWORD *)structure_2;
          if ( !(unsigned int)get_rock_from_goban(goban, v5 - 1, v6) )
            return apply_move(goban, *(_DWORD *)(structure_2 + 16), v6, 1, structure_1);

En y repensant, il nous manque une brique essentielle dans ce jeu de go… Un adversaire ! C’est en effet cette fonction récursive qui va se charger de simuler un adversaire et de jouer contre nous : je la renomme donc “adversary_move”.

Analyse dynamique – Stratégie de l’adversaire

Je décide alors, une fois arrivé à ce stade du challenge, de privilégier l’analyse dynamique plutôt que l’analyse statique du programme par manque de temps, surtout par rapport à la fonction adversary_move. Mon but va être simple : jouer des coups qui n’interfèrent pas avec ceux de l’aversaire et observer les siens.

Je me souviens alors que le premier coup joué lors de la fonction “initial_move” est joué en goban[12][1]. Selon la logique de la page Wikipédia du jeu de go, l’adversaire va visiblement commencer par sécuriser le coin en bas à gauche : j’entre alors un input qui va remplir goban[1] et goban[2] (en faisant attention que mon input ne fasse pas plus de 100 caractères) pour me laisser le temps d’observer tour par tour les coups de l’adversaire.

1,1;1,2;1,3;1,4;1,5;1,6;1,7;1,8;1,9;1,10;1,11;1,12;1,13;2,1;2,2;2,3;2,4;2,5;2,6;2,7;2,8;2,9;2,10

Je décide de noter l’ordre et la position des différents coups de l’adversaire dans un tableau.

Coups de l’adversaire numérotés dans l’ordre d’exécution

Notre adversaire va donc dessiner un carré en bas à gauche du goban.

Solution

Après quelques essais, je remarque que le programme arrête visiblement de jouer s’il est trop perturbé dans la construction de son carré. Ainsi, je vais essayer de l’encercler le plus tôt possible pour gagner !

Planification des coups pour gagner (cases vertes)

Ceci correspond alors à l’input suivant :

10,1;10,2;11,3;12,3;13,3

Je définis une variable d’environnement FLAG en local et je teste mon input.

$ export FLAG="FAKE{N1c3_J0b_Try_Th1s_1n_R3m0t3}"
$ ./gogame 
10,1;10,2;11,3;12,3;13,3
You won: FAKE{N1c3_J0b_Try_Th1s_1n_R3m0t3}

Cet input permet bien de valider le challenge ! Je le teste alors en remote et je récupère le flag 😁.

$ nc 10.4.0.12 12345
10,1;10,2;11,3;12,3;13,3
You won: ECW{2bc36071b3510444444e002a0b01014307337dc15d20b22a80600c3a22af2bda}

Le challenge terminé, c’était bien sympa ! Bravo à vous si vous êtes arrivé au bout de cet article, un peu long je vous l’accorde 🙃, et merci à l’auteur de ce challenge !