Le blog d’Antoine Planchot

AccueilÀ proposArchives


1er mai 2023

# Morceaux choisis du FCSC 2023

Logo du France Cybersecurity Challenge

J'ai pris part cette année au France CyberSecurity Challenge (FCSC dans le jargon), un challenge de cybersécurité organisé par l'ANSSI. Il consiste en plusieurs problèmes à résoudre, faisant appel à des connaissances dans des domaines variés comme la cryptographie, l'exploitation de binaires ou le web. Chaque problème rapporte un certain nombre de points, selon la difficulté.

Pour résoudre un problème, il faut trouver le flag (« sémaphore », en bon français) associé à ce problème, qui prend la forme d'une chaîne de caractère du type FCSC{br@v0_pour_1a_s0lu7ion}.

Je vais revenir ici quelques un des problèmes de cette édition que j'ai trouvé intéressant, ou à tout le moins que j'ai apprécié résoudre.

Le pré-challenge

Pour faire patienter le public avant l'ouverture des épreuves, un problème de teasing était caché sur le site web, rapportant un avantage considérable de 1 point et le privilège de pouvoir afficher un Emoji 🔥 à côté du pseudonyme de ceux qui en viennent à bout.

La porte d'entrée n'était pas très difficile à trouver, elle était indiquée dans un commentaire du code source de la page d'accueil.

<p>
  <!-- En attendant l'ouverture, un flag est à trouver sur ce site. Voir sur /teasing 🔥 -->
</p>

Sur la page indiquée (https://france-cybersecurity-challenge.fr/teasing donc), on trouve une image :

Cette image a visiblement été découpée et réagencée. Rien de mieux à faire dans l'immédiat que de la remettre dans l'ordre. Je me suis aidé d'un code Python pour recréer l'image, mais le puzzle a quand même été résolu à la main.

LSB = Least significant bit (« bit de poids faible »). La stéganographie, c'est le fait de dissimuler des choses dans un fichier, en particulier une image. « LSB Stegano » c'est donc une indication sur l'étape d'après. Dans une image, la couleur de chaque pixel est codée sur trois octets, chaque octet donnant la quantité de rouge, de vert et de bleu sur une échelle de 0 à 255.

Si on ajoute ou retire 1 à un octet, la différence est invisible à l'œil nu. Dès lors, on peut utiliser cette propriété pour dissimuler un message dans le dernier bit de chaque octet. Si on applique cette théorie à l'image que nous avons obtenue en prenant successivement le dernier bit de chaque octet dans chaque pixel, on obtient… une autre image.

Rebelotte :

Et cette fois-ci, si on prend encore le dernier bit de chaque octet de chaque pixel, on obtient un fichier binaire qui ne semble pas être une image cette fois ci.

$ file sortie.bin
sortie.bin: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=8633214f4900c48d504eb171c5013837f19a7d85, for GNU/Linux 3.2.0, stripped

On a donc un fichier exécutable. Dans le doute, on exécute.

$ ./sortie.bin

Il ne se passe rien. Le programme semble attendre quelque chose sur l'entrée standard. Et si on écrit quelque chose ?

$ ./sortie.bin
aaa
$

Le programme s'arrête. Bon. On suppose qu'il faut écrire quelque chose de précis pour obtenir un résultat intéressant. À ce stade j'étais un peu démuni parce que mes capacités dans cette discipline, la rétro-ingénierie, sont assez limitées. Après avoir un peu pataugé à essayé de lire du code assembleur, je me suis penché sur des outils de décompilation, qui visent à fournir un code en langage C équivalent à un exécutable donné. Après quelques soirées perdues à regarder des tutoriels sur YouTube et à me battre avec Ghidra, un logiciel de la NSA spécialisé dans ce type de tâches, j'ai fini par comprendre que le programme attendait en entrée le chemin pour sortir d'un labyrinthe.

j = 0;
i = 0;
ok = 1;
__isoc99_scanf("%188s",input);
raw_length = strlen(input);
length = (int)raw_length;
for (x = 0; x < length; x = x + 1) {
  if (input[x] == 'L') {
    i = i + -1;
  }
  else if (input[x] == 'R') {
    i = i + 1;
  }
  else if (input[x] == 'U') {
    j = j + -1;
  }
  else if (input[x] == 'D') {
    j = j + 1;
  }
  if (*(char *)((long)maze + (long)i + (long)j * 64) == '#') {
    ok = 0;
  }
  if (j < 0) {
    ok = 0;
  }
  if (i < 0) {
    ok = 0;
  }
  if (62 < j) {
    ok = 0;
  }
  if (62 < i) {
    ok = 0;
  }
}
if (((ok == 1) && (j == 62)) && (i == 62)) {
  puts("Congrats!! You can use the flag given by this command to validate the challenge:");
  printf("echo -n %s | sha256sum | awk \'{ print \"FCSC{\" $1 \"}\" }\'\n",input);
}

Ci-dessus, un extrait du code obtenu. Dites-vous bien que j'ai renommé les variables pour faciliter la lecture parce qu'avant on avait du « local_1008 », du « puVar2 » ou du « sVar1 ». Aussi, si j'avais perçu assez vite l'histoire du labyrinthe à résoudre (l'emploi des lettres L, R, U et D est assez parlant), c'est la disposition du labyrinthe qui m'a causé le plus de soucis. En effet, ce dernier était encodé sous la forme d'un tableau dont Ghidra n'a pas jugé utile de me donner les valeurs, me renvoyant à la lecture du code d'assemblage. En outre, le C est un langage exigeant et quand on a l'habitude du Python il n'est pas évident de distinguer ce qui relève de l'addition innocente de ce qui relève de l'accès à un index dans un tableau.

J'ai tout de même fini par extraire l'allure du labyrinthe, et dès lors coder un algorithme de résolution était comparativement assez facile. Le chemin final consiste en 188 mouvements, décrits par L, R, U et D (respectivement, gauche, droite, haut et bas). Finalement :

$ ./sortie.bin
RDDD … RRRD
Congrats!! You can use the flag given by this command to validate the challenge:
echo -n RDDD … RRRD | sha256sum | awk '{ print "FCSC{" $1 "}" }'
$ echo -n RDDD … RRRD | sha256sum | awk '{ print "FCSC{" $1 "}" }'
FCSC{5cf9940286533f76743984b95c8edede9dbfde6226de012b8fe84e15f2d35e83}

Des p'tits trous

Dans ce problème, on nous donne 79 images numérotées représentant chacune une carte perforée nous rappelant les premiers temps de l'informatique.

Une des images

L'énoncé du problème évoque « IBM029 » et précise « Sur les 79 cartes seules les 53 premières sont correctement numérotées, les dernières étaient dans une boîte qui a souffert du temps et a effacé les étiquettes collées dessus. ». Une recherche rapide permet de déterminer comment interpréter les cartes. Chaque carte comporte 12 lignes et 80 colonnes. Chaque colonne sur une carte correspond à un caractère, et les lignes sur lesquelles on trouve un trou renseignent sur le caractère encodé.

Source

Par exemple, s'il y a un trou uniquement sur la troisième ligne, alors c'est le caractère 0 (zéro). S'il y a un trou sur les deuxième, septième et onzième lignes, alors c'est le caractère * (astérisque). On en titre que l'image donnée en exemple doit être interprétée ainsi :

  INTEGER :: S1(256) = (/ 96,172,121,222,15,140,53,104,39,145,51,250,217,  &

Si on applique le processus à l'ensemble des images, on en tire un fichier complet, qui semble être du code en Fortran. La compilation n'est malheureusement pas très probante.

$ gfortran sortie.f90 -o a.out
sortie.f90:57:16:

   55 |   DO I = 0,29,1
      |                                                                                2
   56 | ! - - - - - -
   57 |   DO I = 0,29,1
      |                1
Erreur: La variable « i » à (1) ne peut pas être redéfinie à l'intérieur de la boucle commençant à (2)
sortie.f90:58:80:

   58 |   CHARACTER :: SSSS(0:255)
      |                                                                                1
Erreur: Instruction déclaration de données inattendue à (1)
sortie.f90:59:80:

   59 |   INTEGER :: SSS(0:255)
      |

... (une soixantaine de lignes d'erreur)

On se souvient de la précision dans l'énoncé (« sur les 79 cartes seules les 53 premières sont correctement numérotées »). Les 53 premières lignes correspondent en fait à des déclarations de variables, les 26 lignes restantes sont des opérations sur ces variables avec quelques boucles « for ». Après quelques essais, on arrive au résultat :

$ gfortran edit.f90 -o a.out
$ ./a.out
FCSC{#!F0RTR4N_1337_FOR3V3R!}
STOP 0

Au Boolot

L'énoncé indique qu'on va nous présenter un circuit logique constitué uniquement de portes OU EXCLUSIF (XOR), avec 128 entrées et 256 sorties. On nous autorise à demander la sortie de 130 entrées différentes, puis c'est à nous de deviner quelle sera la sortie d'une entrée donnée. Si on y arrive, à nous le flag.

$ nc challenges.france-cybersecurity-challenge.fr 2302
===== Welcome to auBOOLot: Beat the Circuit! ======
[+] Please wait a bit during circuit generation ...
[+] Secret circuit generated! (26356 XOR gates)
Please provide a 128-bit binary input to evaluate:
>>> 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
Your output is 1001000110110100110000111111110101101110111100101000101111000001101010001010000011001101000111011010111010010011100001101110011100100000110001000110000111101110100010101111001010101001100010110111011010000011001111110111011011000001000011101100001000010000

Ici, on peut s'appuyer sur deux propriétés de l'opération XOR, notée ⊕. Dans la suite, A, B et C sont des variables booléennes valant indifféremment VRAI ou FAUX :

Dès lors, on peut schématiser le circuit logique en indiquant pour chaque sortie si oui ou non chacune des entrées intervient dans son calcul. Le circuit peut donc être modélisé par une matrice binaire 128×256, chaque ligne représentant une entrée et chaque colonne représentant une sortie.

Dernier détail, on remarque dans l'exemple plus haut que même lorsque toutes les entrées sont à 0 (FAUX), certaines sorties sont à 1 (VRAI). Or, on ne peut pas construire VRAI avec uniquement FAUX (car FAUXFAUX = FAUX) (ce n'est pas de la philosophie, n'en tirez aucune leçon de vie). Cela signifie qu'il faut prendre en compte une sorte de 129e entrée valant tout le temps VRAI et intervenant dans le calcul de certaines sorties. Et ces sorties, se sont justement celles qui valent VRAI quand toutes les entrées sont à FAUX (vous me suivez ?).

On a donc notre première questions sur les 130 qui nous sont accordées. Pour celles qui suivent, on va « allumer » chaque entrée les unes après les autres, en gardant les autres « éteintes » :

Si vous voulez vous la péter, la énième entrée est égale à 1 << (128-n), où << est l'opération « décalage à gauche ».

Pour chaque sortie obtenue, on regarde si chacun des bits diffère de la sortie obtenue lorsqu'on a fourni une série de zéros. Si le bit est différent, alors le bit d'entrée valant VRAI intervient dans le calcul de ce bit de sortie.

À la fin, il nous reste une question qu'on peut se permettre de griller. Pour répondre au problème final, on a plus qu'à reprendre notre matrice patiemment constituée et calculer chaque bit de sortie. On prend comme valeur de départ la valeur du bit obtenu avec l'entrée nulle (000…000), puis on balaie la colonne de la matrice correspondant au bit de sortie qu'on souhaite calculer. Si la ligne vaut 0, alors on ne fait rien. Si la ligne vaut 1, on calcule OU EXCLUSIF avec le bit d'entrée correspondant.

La machine virtuelle (Comparaison, Fibonacci, RSA Secure Dev)

Plusieurs problèmes de cette année prenaient pour appui un langage d'assemblage créé pour l'occasion (bien documenté, même si un peu abscons à première vue) et fonctionnant dans une machine virtuelle fournie. Pour chaque problème il fallait écrire un programme dans ce langage, ce qui n'était pas très compliqué mais exigeait un peu de concentration dès lors que le langage était très bas niveau.

Le premier problème, « Comparaison », est un problème d'introduction qui permet de prendre en main la machine virtuelle en effectuant une comparaison simple.

Le second problème, « Fibonacci », demande à calculer un certain terme de la suite de Fibonacci. Il faut jongler avec les différents registres pour stocker notamment le terme en train d'être calculé, le terme précédent et l'indice du terme. À la fin, on a un code qui ressemble à ça :

MOV R0, #0
MOV R1, #1
MOV R2, #0
MOV R3, #1
CMP R5, R2
JZR +5
ADD R1, R0, R1
SUB R0, R1, R0
SUB R5, R5, R3
JR -5
MOV R1, #0
STP

Si on compile le code et qu'on le fournit à la machine virtuelle :

$ python3
Python 3.10.9 (main, Dec 15 2022, 19:49:41) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import assembly
>>> code = open("code.txt").read().strip().split("\n")
>>> assembly.assembly(code)
'800000008001000180020000800300010625c7054a414c084cedcffb800100001400'
>>> exit()
$ nc challenges.france-cybersecurity-challenge.fr 2301
Enter your bytecode in hexadecimal:
>>> 800000008001000180020000800300010625c7054a414c084cedcffb800100001400
[+] Congrats! Here is the flag: FCSC{770ac04f9f113284eeee2da655eba34af09a12dba789c19020f5fd4eff1b1907}

Une autre série de problèmes dans cette machine virtuelle appelle à reproduire l'algorithme RSA, un message et une clef étant fournis. La clef se constitue de plusieurs paramètres propres à RSA : les nombres premiers p et q, un exposant e, et d'autres variables issues d'opérations arithmétiques avec pleins de modulos :

e = 2 ** 16 + 1
dp = gmpy2.invert(e, p - 1)
dq = gmpy2.invert(e, q - 1)
iq = gmpy2.invert(q, p)
d  = gmpy2.invert(e, (p - 1) * (q - 1))

Ce code utilise la bibliothèque gmpy2.

Si on applique l'algorithme rigoureusement, il faut calculer (m**d) % (p*q). Cela donne un résultat correct, mais ce n'est pas assez rapide pour résoudre le problème. Si on va voir à la source des spéficiations de RSA, dans la RFC 3447, section 5.2.1, on trouve la solution, moins directe, mais néanmoins bien documentée. Celle-ci se convertit assez facilement dans notre langage :

MOV R3, R8
MOV RC, RA
MOV RD, R7
POW R2, R5
MOV RC, R9
MOV RD, R6
POW R1, R5
SUB R4, R1, R2
MUL R4, R4, R3
MOD R4, R4
MOV R0, R7
MUL R0, R0, R4
ADD R0, R0, R2
STP

Ce qui permet d'obtenir le résultat :

$ python3
Python 3.10.9 (main, Dec 15 2022, 19:49:41) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import assembly
>>> code = open("code.txt").read().strip().split("\n")
>>> assembly.assembly(code)
'008300AC007D0352009C006D03514c8c4ee4024400704f004a801400'
>>> exit()
$ nc challenges.france-cybersecurity-challenge.fr 2352
Enter your bytecode in hexadecimal:
>>> 008300AC007D0352009C006D03514c8c4ee4024400704f004a801400
Which flag do you want to grab?
  0. Quit.
  1. Easy flag   - check for code correctness and performances.
  2. Medium flag - check resistance against several fault attacks, d not given.
  3. Hard flag   - check resistance against more fault attacks, not e and not d given.
>>> 1
[+] Testing correctness...
[+] Correct!
[+] Testing performances against the reference solution...
[*] Reference performances: 8972767.24 ns
[*] User performance:       6993844.04 ns
[*] Ratio:                     0.78
[+] Congrats! Here is the easy flag: FCSC{06de1084d295f2a016f79485f2a47744bfb9ed3e30e9312121665559df9447de}

Voilà pour les problèmes de cette année ! Ils ne sont pas forcément bien représentatifs de la réalité de la cybersécurité, mais ont le mérite de faire travailler pleins de sujets techniques différents. J'ai le sentiment d'avoir plutôt appris des choses, notamment en rétro-ingénierie, ce qui est positif. J'ai aussi constaté la différence que cela faisait au moment d'aborder un problème quand on a déjà une culture technique du sujet, même minime. J'étais par exemple déjà familiarisé par avance sur le fonctionnement des cartes perforées, ce qui m'a permis de démarrer assez vite. Sur un autre problème, non expliqué ici et qui avait trait à la cryptographie sur courbes elliptiques, mes connaissances se limitaient à quelques vidéos YouTube, mais cela m'a quand même permis d'avoir une intuition du sujet et d'en tirer des résultats.

Sur ceux, merci de m'avoir lu. Je ne vous dis pas à l'année prochaine, j'espère qu'on se reverra d'ici là :^)