Writeup Reverse Engineering - Edition 2025
Dans cet article, nous vous présenterons les différentes solutions aux challenges du Jeanne d’Hack CTF 2025 pour la catégorie Reverse.
Rev My Snake I
Les sources du challenge sont disponibles ici.
Writeup by Tourte
Description
Ce challenge est le premier challenge de reverse de la série “Rev my snake”. Il est classé facile et n’a pas posé beaucoup de problèmes à la majorité des participants.
Outils nécessaires
- Un éditeur de texte classique
- Python (pour pouvoir exécuter le script)
Fichiers fournis
chall.py
: Le script python à analyser
Résolution
Si on regarde __init__
, qui représente le point d’entrée du script, on voit que celui-ci
exécute la fonction main
(ce qui est logiquement ce que font énormément de scripts Python).
Si vous avez lancé le script, vous avez dû voir qu’à un moment on vous demande de rentrer
un mot de passe. La majorité du temps dans ce genre de challenge, l’objectif est de trouver
le point d’entrée (là où le script va prendre ce que vous allez entrer) et de suivre toutes
les étapes pour comprendre comment le script va traiter votre entrée et à quoi elle va être comparée.
En Python, la prise d’entrée est gérée par la fonction input
. Nous allons donc chercher où
cette fonction est appelée dans le main
pour déterminer tous les inputs et trouver celui
qui nous intéresse (celui du mot de passe, hehe).
En cherchant un peu, on trouve une ligne vachement intéressante :
57| string = input('Enter the password: ')
Le mot de passe est donc stocké dans une variable string
. On peut alors suivre ce que le script fait de notre mot de passe en suivant cette variable. Ce qui n’est pas compliqué puisque la ligne qui suit montre que le script fait passer notre mot de passe dans une fonction check_password
qui renvoie un booléen (forcément, elle est dans un if
).
if check_password(string):
dialog('Anonymous', 'DAMN!')
pause(20)
dialog('Anonymous', "I let you win this time, but next time it won't be that easy!")
pause(20)
dialog('Anonymous', 'Here is your precious flag:\nJDHACK{%s}' % string)
dialog('System', 'Anonymous Left')
break
On comprend facilement que si check_password
renvoie True
, on aura le flag. On va donc gentiment
analyser cette fonction pour comprendre ce qu’elle fait.
def check_password(string):
# a = les 14 premiers caractères
# b = les 14 derniers caractères
a, b = (string[:14], string[14:])
# Nous envoie bouler si a n'est pas égal à b en taille
if len(a) != len(b):
return False
# Vérifie que b est égal à 'n0htyP_c3vA_35' en inversé donc
# '53_Av3c_Pyth0n'
if b[::-1] != 'n0htyP_c3vA_35':
return False
# Enlève 13 à chaque caractère de a et vérifie que a est la chaine
# bizarre
if ''.join([chr(ord(e) - 13) for e in a]) != '<a*e#R4hRE&i&e':
return False
return True
On comprend que notre chaîne est divisée en deux parties a
et b
.
a
représente les 14 premiers caractères de notre mot de passeb
représente les 14 derniers
On voit aussi qu’on nous renvoie False
si a
et b
n’ont pas la même taille, ce qui veut dire
que notre mot de passe doit faire 28 caractères.
Ensuite, on voit que b[::-1]
doit être égal à n0htyP_c3vA_35
. Donc, le renversé de b
doit être
égal à n0htyP_c3vA_35
. On peut alors facilement retrouver b
en faisant le renversé de n0htyP_c3vA_35
.
print('n0htyP_c3vA_35'[::-1])
# renvoie 53_Av3c_Pyth0n
Si on regarde a
, on voit que chaque caractère est décalé de 13 vers le bas et est comparé
à '<a*e#R4hRE&i&e'
. On comprend alors que a
doit être égal à '<a*e#R4hRE&i&e'
décalé de 13 vers le haut.
print(''.join([chr(ord(e) + 13) for e in '<a*e#R4hRE&i&e']))
# renvoie 'In7r0_Au_R3v3r`
Si on concatène a
et b
, on obtient le magnifique message In7r0_Au_R3v3r53_Av3c_Pyth0n
(intro au
reverse avec Python), qui est notre mot de passe et aussi notre flag au passage (JDHACK{In7r0_Au_R3v3r53_Av3c_Pyth0n}
).
Rev My Snake II
Les sources du challenge sont disponibles ici.
Writeup by Tourte
Description
Ce challenge est le deuxième challenge de reverse de la série “Rev my snake”. Il est classé facile comme le premier et a globalement plutôt été réussi par les participant du CTF.
Outils nécessaires
- Un éditeur de texte classique
- Python (pour pouvoir exécuter le script)
Fichiers fournis
chall.py
: Le script python à analysersecurelib.py
: Un fichier additionnel utilisé parchall.py
Résolution
Ici, le fichier chall.py
est quasiment identique à celui du challenge précédent. À la seule
différence que la fonction check_password
ne se trouve pas dans le script principal, mais
dans un autre fichier additionnel securelib.py
qui est importé.
try:
from securelib import check_password
except Exception as e:
print('O_o: oups... something went wrong')
exit(-42)
Nous allons devoir nous pencher sur le fichier securelib.py
pour comprendre comment
fonctionne la fonction check_password
, sauf que cette fois-ci (petite douche froide),
le fichier securelib.py
ne sera pas lisible en clair.
XR = lambda x, k: bytes([c ^ k for c in x]); HX = lambda e: bytes.fromhex(e)
exec(XR(HX((
"204e4f4c0a7549424f4941755a4b59595d45584e0a02656565651a65651a1a651a1a1a656565650a0"
"310200a0a0a0a6565651a65651a65651a1a1a1a1a651a1a0a17711b1b1a0a061c120a06131a0a061f"
"180a06131b0a06121b0a061b1b1b0a061b1a1c0a061b1a130a06121c0a061e190a06121e0a061b181"
"90a061b1a1a0a061c1c0a061c180a061d130a061b181d0a06191e0a061b1a190a061d1f0a061c1e0a"
"061b1a1b0a0612180a061d1b0a061d190a061c1d0a061e1e0a06121e0a0612180a061b1b180a77200"
"a0a0a0a434c0a464f440a026565651a65651a65651a1a1a1a1a651a1a0a030b17464f440a02656565"
"651a65651a1a651a1a1a656565650a0310200a0a0a0a0a0a0a0a584f5e5f58440a6c4b46594f200a0"
"a0a0a4c45580a651a1a651a1a1a1a651a1a1a1a656565650a43440a584b444d4f0a02464f440a0265"
"6565651a65651a1a651a1a1a656565650a030310200a0a0a0a0a0a0a0a434c0a45584e0a026565656"
"51a65651a1a651a1a1a656565650a71651a1a651a1a1a1a651a1a1a1a656565650a770374651a1a65"
"1a1a1a1a651a1a1a1a656565650a0b176565651a65651a65651a1a1a1a1a651a1a0a71651a1a651a1"
"a1a1a651a1a1a1a656565650a7710200a0a0a0a0a0a0a0a0a0a0a0a584f5e5f58440a6c4b46594f20"
"0a0a0a0a584f5e5f58440a7e585f4f2020")), 42)
)
check_password = lambda e: eval(XR(HX('526e65686e66527d6c7e7e7a627f69'), 13).decode()+"('{}')".format(e))
Si on plisse les yeux et qu’on décortique un peu le code, on arrive à comprendre ce qui se passe. Ici, deux lambda fonctions sont définies :
XR
, qui prendx
etk
en paramètre et qui effectue un XOR entre chaque caractère dex
etk
HX
, qui prende
en paramètre et qui renvoie la chaîne de caractères hexadécimalee
en bytes
Si on redéfinit ces deux fonctions pour qu’elles soient plus lisibles, on obtient :
def fun_xr(x, k):
return bytes([c ^ k for c in x])
def fun_hx(e):
return bytes.fromhex(e)
Ces fonctions sont appliquées d’une certaine manière sur la chaîne suivante :
"204e4f4c0a7549424f4941755a4b59595d45584e0a02656565651a65651a1a651a1a1a656565650a0"
"310200a0a0a0a6565651a65651a65651a1a1a1a1a651a1a0a17711b1b1a0a061c120a06131a0a061f"
"180a06131b0a06121b0a061b1b1b0a061b1a1c0a061b1a130a06121c0a061e190a06121e0a061b181"
"90a061b1a1a0a061c1c0a061c180a061d130a061b181d0a06191e0a061b1a190a061d1f0a061c1e0a"
"061b1a1b0a0612180a061d1b0a061d190a061c1d0a061e1e0a06121e0a0612180a061b1b180a77200"
"a0a0a0a434c0a464f440a026565651a65651a65651a1a1a1a1a651a1a0a030b17464f440a02656565"
"651a65651a1a651a1a1a656565650a0310200a0a0a0a0a0a0a0a584f5e5f58440a6c4b46594f200a0"
"a0a0a4c45580a651a1a651a1a1a1a651a1a1a1a656565650a43440a584b444d4f0a02464f440a0265"
"6565651a65651a1a651a1a1a656565650a030310200a0a0a0a0a0a0a0a434c0a45584e0a026565656"
"51a65651a1a651a1a1a656565650a71651a1a651a1a1a1a651a1a1a1a656565650a770374651a1a65"
"1a1a1a1a651a1a1a1a656565650a0b176565651a65651a65651a1a1a1a1a651a1a0a71651a1a651a1"
"a1a1a651a1a1a1a656565650a7710200a0a0a0a0a0a0a0a0a0a0a0a584f5e5f58440a6c4b46594f20"
"0a0a0a0a584f5e5f58440a7e585f4f2020"
On est bien d’accord que cette chaîne est ILLISIBLE en l’état. Mais si on la décode comme dans le programme avec les fonctions qu’on a redéfinies plus tôt, on obtient :
# code = la chaine illisible
print(fun_xr(fun_hx(code), 42).decode())
# ce qui renvoi
def _check_password (OOOO0OO00O000OOOO ):
OOO0OO0OO00000O00 = [
110, 68, 90, 52, 91, 81, 111, 106, 109, 86,
43, 84, 123, 100, 66, 62, 79, 127, 34, 103,
75, 64, 101, 82, 71, 73, 67, 44, 84, 82, 112
]
if len (OOO0OO0OO00000O00 )!=len (OOOO0OO00O000OOOO ):
return False
for O00O0000O0000OOOO in range (len (OOOO0OO00O000OOOO )):
if ord (OOOO0OO00O000OOOO [O00O0000O0000OOOO ])^O00O0000O0000OOOO !=OOO0OO0OO00000O00 [O00O0000O0000OOOO ]:
return False
return True
On comprend très vite que cette fonction est la fonction check_password
utilisée dans le code principal.
Encore une fois, on essaie de nous rendre la tâche difficile en utilisant des noms de variables
compliqués à lire, mais ce n’est pas grave, on va s’en sortir :).
def _check_password (s):
code = [
110, 68, 90, 52, 91, 81, 111, 106, 109, 86,
43, 84, 123, 100, 66, 62, 79, 127, 34, 103,
75, 64, 101, 82, 71, 73, 67, 44, 84, 82, 112
]
if len(code) != len(s):
return False
for i in range (len (s)):
if ord (s[i])^i != code[i]:
return False
return True
On arrive un peu plus à comprendre ce que fait la fonction maintenant. Elle vérifie que la chaîne
rentrée en paramètre soit égale à la chaîne code
après un XOR avec l’index de chaque caractère
de la chaîne. Heureusement pour nous, le XOR est réversible (c’est-à-dire que a ^ b = c
et
c ^ b = a
), donc on peut retrouver la chaîne code
en faisant un XOR entre chaque élément
de code
et l’index de chaque élément.
code = [
110, 68, 90, 52, 91, 81, 111, 106, 109, 86,
43, 84, 123, 100, 66, 62, 79, 127, 34, 103,
75, 64, 101, 82, 71, 73, 67, 44, 84, 82, 112
]
for i in range(len(code)):
code[i] = code[i] ^ i
print(''.join([chr(c) for c in code]))
# renovoie : nEX7_Time_!_wiL1_n0t_UsE_PY7HOn
On a enfin notre mot de passe (wouhou !). On peut maintenant lancer le script principal et
entrer le mot de passe pour obtenir le flag (JDHACK{nEX7_Time_!_wiL1_n0t_UsE_PY7HOn}
).
Rev My Snake III
Les sources du challenge sont disponibles ici.
Writeup by Tourte
Description
Ce challenge est le troisième et dernier challenge de reverse de la série “Rev my snake”. Il est classé moyen et a fait pleurer quelques larmes de sang à certains.
Outils nécessaires
- Un éditeur de texte classique
- Python (pour pouvoir exécuter le script)
- Ghidra (un logiciel de rétro-ingénierie)
Fichiers fournis
chall.py
: Le script python à analyserMaze.so
: Un fichier additionnel utilisé parchall.py
Résolution
Cette fois-ci, contrairement aux autres fois, ce n’est pas un mot de passe que chall.py
nous
demande, mais de réussir à résoudre un labyrinthe. En analysant le code, on s’aperçoit
que chall.py
importe le fameux fichier Maze.so
qui contient un objet Maze
inconnu.
import Maze
def enter_maze():
Maze.generate()
while not Maze.is_finished():
dialog('Maze', 'Where do you like to go?')
try:
choice = input('[Jeanne] ')
if len(choice) > 0:
if 'up'.startswith(choice.lower()):
Maze.up()
elif 'down'.startswith(choice.lower()):
Maze.down()
elif 'right'.startswith(choice.lower()):
Maze.right()
elif 'left'.startswith(choice.lower()):
Maze.left()
elif 'flag'.startswith(choice.lower()):
Maze.flag()
except (EOFError, KeyboardInterrupt):
show('\n')
break
C’est dans la fonction enter_maze
appelée dans la conversation plus bas du fichier qu’on
utilise l’objet Maze
. L’objet Maze
a l’air d’avoir plusieurs méthodes. Voici celles
qu’on arrive à identifier dans la fonction :
generate()
qui génère le labyrinthe d’une manière ou d’une autreup()
pour aller en hautdown()
pour aller en basleft()
pour aller à gaucheright()
pour aller à droiteflag()
sûrement pour récupérer le flag
On comprend assez bien que l’objectif de ce programme est de nous faire parcourir un labyrinthe jusqu’à un certain endroit où on pourra récupérer le flag. On pourrait s’amuser à tester toutes les possibilités à la main, mais on n’a aucune idée de si ce labyrinthe est vraiment faisable, alors ne nous aventurons pas dans ce genre de solutions.
À la place, ce que nous allons faire, c’est ouvrir le fichier Maze.so
dans Ghidra pour
tenter de comprendre ce qu’il fait vraiment.
Si on cherche dans le symbole tree, on s’aperçoit que toutes les méthodes énumérées plus tôt s’y trouvent. On va donc les analyser une à une pour les comprendre.
On remarque en analysant generate()
qu’elle alloue les ressources nécessaires pour charger
un objet de chiffrement issu de la bibliothèque OpenSSL.
Ensuite, si on analyse les 4 méthodes pour bouger (ici left), on obtient :
if (((player_x - 1U < 0x10) && ((uint)player_y < 0x10)) &&
(Maze[(int)(player_y * 0x10 + (player_x - 1U))] != 1)) {
dialog("Maze","Going left");
player_x = player_x + -1;
snprintf(buffer,3,"%x%x",(ulong)(uint)player_y);
EVP_DigestUpdate((EVP_MD_CTX *)mdctx,buffer,2);
}
else {
dialog("Maze","Cannot move left");
}
On comprend que les conditions pour bouger sont :
- De savoir si les coordonnées
x
ety
après mouvement sont inférieures à 0x10 (16 en gros) - De savoir si dans le tableau
Maze
à l’indice $x + y \times 16$ la valeur est différente de 1
Cette fonction nous apprend plusieurs choses, mais particulièrement qu’il existe un tableau Maze
rempli avec certaines valeurs.
Maintenant, prenons le temps d’analyser la méthode flag
:
if ((Maze[player_y * 16 + player_x] == 2) && (flag_is_taken == 0)) {
flag_is_taken = 1;
EVP_DigestFinal_ex((EVP_MD_CTX *)mdctx,md_value,&md_len);
EVP_MD_CTX_free(mdctx);
ctx_00 = EVP_CIPHER_CTX_new();
ctx = (EVP_CIPHER_CTX *)EVP_aes_128_cbc();
EVP_DecryptInit_ex(ctx_00,(EVP_CIPHER *)ctx,(ENGINE *)0x0,md_value,iv);
EVP_DecryptUpdate(ctx_00,buffer,&buffer_len,flag_enc,48);
iVar1 = buffer_len;
EVP_DecryptFinal_ex(ctx_00,buffer + buffer_len,&buffer_len);
buffer[iVar1 + buffer_len] = '\0';
if ((buffer._0_4_ == 0x4148444a) && (buffer._4_2_ == 0x4b43)) {
dialog("Maze","Here is your flag:");
dialog("Maze",(char *)buffer);
}
else {
dialog("Maze","Mmm, The flag doesn\'t seem correct, are you sure you took the right path?");
}
}
Cette méthode nous apprend que la condition pour récupérer le flag est d’obtenir
une position qui, dès que l’on fait le calcul \(i = y \times 16 + x\), doit avoir Maze[i] = 2
.
On s’aperçoit aussi que la clé est le résultat de plusieurs opérations cryptographiques sur le chemin que nous avons parcouru. Nous n’avons alors pas d’autre choix que de calculer nous-mêmes ce chemin à partir du labyrinthe en prenant en compte toutes les règles établies par le code. Pour la suite de ce writeup, je vais utiliser Python, mais n’importe quel langage de programmation pourrait théoriquement faire l’affaire.
Avant tout, nous allons récupérer la variable Maze
qui contient la “carte” de ce labyrinthe :
Maze = [
0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01,
0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01,
0x01, 0x00, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00,
0x01, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01,
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00,
0x01, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01,
0x01, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x00,
0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x01,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00,
0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x01,
0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01,
0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01,
0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x01, 0x01,
0x01, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00,
0x00, 0x00, 0x02, 0x01, 0x00, 0x00, 0x00, 0x01,
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00,
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x01,
0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00,
0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01,
0x01, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x00,
0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01,
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00,
0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01,
0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01
]
C’est facilement réalisable en faisant un double clic sur la variable dans la “preview C” de Ghidra, puis en sélectionnant la zone mémoire dans la fenêtre “listing” (qui montre le code assembleur), puis en faisant clic gauche > copy special puis en sélectionnant le format cible (ici, Python list).
Voici le code Python qui permettra de résoudre ce challenge.
import Maze
class MazeSolver:
def __init__(self):
self.Maze = [ ... ]
self.x = 0
self.y = 0
def solve_maze(self):
stack = []
visited = []
move_found = False
new_x = self.x
new_y = self.y
count = 0
while True:
move_found = False
for x, y, label in [(0, -1, Maze.up), (0, 1, Maze.down), (-1, 0, Maze.left), (1, 0, Maze.right)]:
new_x = self.x + x
new_y = self.y + y
if 0 <= new_x < 16 and 0 <= new_y < 16 and self.Maze[new_y * 16 + new_x] != 1 and (new_x, new_y) not in visited:
stack.append((self.x, self.y, label))
visited.append((self.x, self.y))
self.x = new_x
self.y = new_y
move_found = True
break
if not move_found:
if len(stack) == 0:
break
visited.append((self.x, self.y))
self.x, self.y, _ = stack.pop()
count += 1
if self.Maze[self.y * 16 + self.x] == 2:
print("FLAG FOUND")
break
for x, y, label in stack:
label()
return stack
Maze.generate()
solver = MazeSolver()
solver.solve_maze()
Maze.flag()
Ce code utilise un algorithme de backtracking pour parcourir tous les chemins possibles jusqu’à
arriver au flag. Une fois le chemin trouvé, il exécute les méthodes de Maze
du chemin dans
l’ordre et résout le challenge. L’exécution du code se fait en plusieurs dizaines de secondes.
Une autre manière de résoudre le challenge serait de reconstruire le flag en copiant les
mécanismes cryptographiques employés dans le programme, mais cela nous demanderait un
travail supplémentaire. Pour ceux qui aimeraient explorer cette solution, vous constaterez
que le flag se trouve dans le code sous le nom de flag_enc
et qu’il est chiffré avec
AES-128-CBC. Le IV est donné dans la méthode flag
(“1234567887654321”). La clé n’est
autre que les coordonnées y
en base 16 (ou y
et x
selon la direction) chiffrées
en MD5 durant votre parcours.
Vous devrez retrouver ceci :
JDHACK{My_N4m3_1s_P13rr3_C4uch0n}
ORnithorynque
Les sources du challenge sont disponibles ici.
La résistance vous a convié à une réunion top secrète ! Mais l’emplacement est gardé secret lui aussi… À vous de le découvrir.
Ce challenge est un challenge facile permettant de s’entraîner à reverse des programmes compilés
avant d’attaquer d’autres challenges plus complexes. On commence par ouvrir le programme dans
Ghidra, celui-ci se résume à deux fonctions : main
et flagxor
:
int main(void) {
printf("See you at this location : ");
flagxor();
return 0;
}
void flagxor(void) {
byte buffer [64];
int key [39];
int size;
int k;
int j;
int ptr;
int i;
key[0] = 0x4b;
key[1] = 0x45;
key[2] = 0x49;
key[3] = 0x42;
key[4] = 0x44;
key[5] = 0x4c;
key[6] = 0x7c;
key[7] = 0x55;
key[8] = 0x70;
key[9] = 0x4c;
key[10] = 0x5a;
key[0xb] = 0x70;
key[0xc] = 0x60;
key[0xd] = 0x54;
key[0xe] = 0x69;
key[0xf] = 0x4a;
key[0x10] = 0x43;
key[0x11] = 0x76;
key[0x12] = 0x5a;
key[0x13] = 0x62;
key[0x14] = 0x7e;
key[0x15] = 0x52;
key[0x16] = 0x52;
key[0x17] = 0x52;
key[0x18] = 0x52;
key[0x19] = 0x52;
key[0x1a] = 0x52;
key[0x1b] = 0x52;
key[0x1c] = 0x52;
key[0x1d] = 0x52;
key[0x1e] = 0x52;
key[0x1f] = 0x52;
key[0x20] = 0x52;
key[0x21] = 0x52;
key[0x22] = 0x52;
key[0x23] = 0x52;
key[0x24] = 0x52;
size = 0x25;
for (i = 0; i < size; i = i + 1) {
key[i] = key[i] + -1;
}
size = 0x15;
ptr = 0;
for (j = 0; j < size; j = j + 1) {
buffer[ptr] = (byte)key[j] ^ 3;
ptr = ptr + 1;
}
for (k = 0; k < size; k = k + 1) {
buffer[k] = buffer[k] ^ 6;
}
buffer[size] = 0;
puts((char *)buffer);
return;
}
Lorsque l’on exécute le programme, on obtient :
$ ./ORnithorynque
See you at this location : OAMDFN~QjN\jZVmLGp\dx
On comprend alors que le flag est “chiffré” à l’aide de la clé key. Afin de le retrouver, il faut effectuer les opérations dans le sens inverse. Pour cela, on utilise le script suivant :
def main():
encrypted_flag = "OAMDFN~QjN\jZVmLGp\dx"
decrypted_flag = ""
for c in encrypted_flag:
decrypted_char = chr((ord(c) ^ 6) ^ 3)
decrypted_flag += decrypted_char
print(decrypted_flag)
if __name__ == '__main__':
main()
En exécutant ce script, on obtient le flag : JDHACK{ToKYo_ShIBuYa}
.
Next Level
Les sources du challenge sont disponibles ici.
Alors que vous arpentez les rues de la ville, votre attention est attirée par un programme étrange qui semble se répéter sans fin. La lumière bleue d’un écran se projette dans l’obscurité et vos yeux sont surpris de constater que le contenu affiché n’a pas changé depuis des minutes. Le programme vous demande un mot de passe.
Le programme est un puzzle complexe qui semblent se dérouler sans fin dans une boucle. Vous devrez découvrir comment interagir avec le programme pour trouver la sortie du cycle et trouver le mot de passe.
Note : Ce challenge est grandement inspiré de l’excellent challenge du 404CTF de 2022 : introspection.
Le principe de ce challenge est celui des poupées russes, un programme imbriqué dans un programme.
Pour mieux comprendre, on commence par regarder la fonction main
:
int main(int argc, char **argv, char **environ) {
int iVar1;
ulong uVar2;
if (argc == 2) {
uVar2 = check_password(2,argv,environ);
iVar1 = (int)uVar2;
if (iVar1 != 0) {
puts("Wrong password");
iVar1 = -0x2a;
}
}
else {
printf("Usage: %s <password>\n",*argv);
iVar1 = -0x2a;
}
return iVar1;
}
Cette fonction ce contente d’appelé check_password
(renommer à partir des chaînes utilisé).
Cette fonction possède le code suivant:
ulong check_password(undefined8 param_1,char **argv,char **env) {
__pid_t _Var1;
int fd;
ulong uVar2;
_Var1 = fork();
if (_Var1 != -1) {
if (_Var1 != 0) {
// Parent
wait((void *)0x0);
return 0;
}
// Child
uVar2 = ptrace(PTRACE_TRACEME,0,0);
if ((int)uVar2 == 0) {
fd = memfd_create("",0);
if (fd < 1) {
return uVar2 & 0xffffffff;
}
write(fd,&DAT_001040a0,(ulong)DAT_00104080);
fexecve(fd,argv,env);
return uVar2 & 0xffffffff;
}
}
return 0xffffffd6;
}
On peut voir que la fonction crée un nouveau processus via l’appel à fork()
puis attend que le
processus fils se termine. Dans le fils, on utilise ptrace
afin d’essayer d’empêcher qu’un
debugger comme GDB soit attaché. Enfin, le programme appelle les fonctions suivantes :
memfd_create
: Permet de créer un fichier en RAM.write
: Écrit des données dans le fichier.fexecve
: Exécute le fichier en RAM en passant les arguments reçus en paramètre decheck_password
.
Si on examine les données qui sont écrites, on peut voir ceci :
DAT_001040a0 XREF[1]: check_password:00101250(*)
001040a0 7f ?? 7Fh
001040a1 45 ?? 45h E
001040a2 4c ?? 4Ch L
001040a3 46 ?? 46h F
...
On comprend qu’il s’agit d’un programme exécutable Linux grâce à la signature
\x7FELF
. Si on extrait ce programme et que l’on analyse ce dernier, on constate
une structure identique à la précédente. On comprend que l’analyse manuelle de ce
programme risque de prendre du temps et qu’il serait plus intéressant de scripter
la résolution. Pour cela, il existe plusieurs méthodes :
- Utiliser
LD_PRELOAD
afin de modifier le comportement du programme. - Utiliser une approche statique grâce à un script Ghidra ou à la bibliothèque LIEF.
Je vous propose la première solution car il s’agit de la plus rapide à mettre en place :
/*
* Compile with: gcc -shared -fPIC -o hook.so hook.c -ldl
*/
#define _GNU_SOURCE
#include <dlfcn.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
ssize_t write(int fd, const void *buf, size_t count) {
// Resolve bind if necessary
static ssize_t (*_write)(int, const void *, size_t) = NULL;
if (_write == NULL) {
_write = dlsym(RTLD_NEXT, "write");
}
// Generate a different filename for each level
char filename[1024] = { 0 };
snprintf(filename, 1024, "a.out.%d", getpid());
// Open the file to write the copied data
int copy = open(filename, O_WRONLY | O_CREAT, 0644);
if (copy == -1) {
return -1;
}
// Write the data to the copy file
ssize_t written = _write(copy, buf, count);
// Close the copy file
close(copy);
// Call the original write function
return _write(fd, buf, count);
}
On peut ensuite compiler et utiliser notre hook comme ceci :
$ gcc -shared -fPIC -o hook.so hook.c -ldl
LD_PRELOAD=./hook.so ./next_level aaaaaaaa
On obtient alors une centaine de programmes a.out.XXXX
. Il suffit de prendre le dernier
programme généré, c’est-à-dire celui avec le numéro de PID le plus grand. Une fois ouvert dans Ghidra, on obtient :
int check_password(char *param_1) {
long lVar1;
byte *pbVar2;
char cVar3;
int iVar4;
bool bVar5;
bool bVar6;
lVar1 = 0x19;
bVar5 = &stack0xfffffffffffffff0 < (undefined1 *)0x8;
bVar6 = &stack0x00000000 == (undefined1 *)0x18;
pbVar2 = (byte *)"y0u_r34ch_7h3_l457_l3v3l!";
do {
if (lVar1 == 0) break;
lVar1 = lVar1 + -1;
bVar5 = (byte)*param_1 < *pbVar2;
bVar6 = *param_1 == *pbVar2;
param_1 = (char *)((byte *)param_1 + 1);
pbVar2 = pbVar2 + 1;
} while (bVar6);
cVar3 = (!bVar5 && !bVar6) - bVar5;
iVar4 = (int)cVar3;
if (cVar3 == '\0') {
puts("\nCongratulations, you can validate with:");
printf("JDHACK{%s}\n","y0u_r34ch_7h3_l457_l3v3l!");
}
else {
iVar4 = -0x2a;
}
return iVar4;
}
On peut voir que le flag est directement écrit en clair dans ce programme (il était donc possible de
le récupérer directement avec strings
, oups). On obtient alors le flag : JDHACK{y0u_r34ch_7h3_l457_l3v3l!}
Kudo
Les sources du challenge sont disponibles ici.
Writeup by Tourte
Description
Dans ce challenge, nous sommes face à une application qui est une réimplémentation de sudo
.
L’objectif est de trouver le mot de passe de l’utilisateur Jeanne
en comprenant comment
l’exécutable kudo
fonctionne.
Outils nécessaires
- Un éditeur de texte classique
- Python (ou un autre langage de scripting pour générer la solution)
- Ghidra (ou un autre désassembleur comme IDA)
Fichiers fournis
kudo
: L’exécutable à analysershadow
: Le fichier contenant la liste des utilisateurs et leur mot de passe hashé
Résolution
Pour résoudre ce challenge, nous allons devoir comprendre comment fonctionne l’exécutable kudo
et comment
il vérifie le mot de passe de l’utilisateur Jeanne
.
Pour cela, nous allons commencer par lancer une fois l’exécutable pour voir comment il fonctionne.
Analyse de l’exécutable
Au lancement de l’exécutable, nous avons un très bel ASCII art représentant le titre de l’exécutable
ainsi qu’une demande d’input Enter your username:
. Nous devinons donc que c’est ici que nous allons
devoir entrer “Jeanne”. Une fois cela fait, l’exécutable nous demande de rentrer un mot de passe.
En entrant quelque chose au hasard, nous avons un message d’erreur composé d’une ligne DEBUG
et
de ce qui semble être une chaîne de caractères en base64. Sûrement le hash du mot de passe que
nous venons de rentrer. Cette information pourrait être utile par la suite pour trouver la
solution finale de ce challenge.
./kudo
__ __ __
| \ / \ | \
| $$ / $$__ __ ____| $$ ______
| $$/ $$| \ | \ / $$ / \
| $$ $$ | $$ | $$| $$$$$$$| $$$$$$\
| $$$$$\ | $$ | $$| $$ | $$| $$ | $$
| $$ \$$\| $$__/ $$| $$__| $$| $$__/ $$
| $$ \$$\\$$ $$ \$$ $$ \$$ $$
\$$ \$$ \$$$$$$ \$$$$$$$ \$$$$$$
Strong login prompt (better than sudo)
Enter your username: Jeanne
Enter your password: Jeanne
DEBUG: 7a6fYKrSp855RXHs//ziXw==
Authentification failure
Décompilation de l’exécutable
L’exécutable kudo
est un binaire, nous allons donc devoir le décompiler pour comprendre comment fonctionne le système de vérification de mot de passe.
En regardant la fonction main
de l’exécutable, on peut remarquer une fonction très intéressante login
.
On comprend son utilité rien qu’en regardant le nom. Cette fonction contient également une autre
fonction encore plus intéressante, compare_password
.
int compare_password(char *username,char *password,void *hash) {
int extraout_EAX;
int r;
size_t sVar1;
size_t l;
void *encoded;
char local_1048 [2048];
undefined1 local_848 [1024];
char buffer [1024];
ulong k;
size_t j;
ulong i;
sVar1 = strlen(password);
for (i = 0; j = sVar1, i < sVar1; i = i + 1) {
if (password[i] == '_') {
password[i] = '/';
}
else if (password[i] == '/') {
password[i] = '_';
}
else if (password[i] == '+') {
password[i] = '-';
}
else if (password[i] == '-') {
password[i] = '+';
}
else if (password[i] == '!') {
password[i] = '?';
}
else if (password[i] == '?') {
password[i] = '!';
}
else if (password[i] == '#') {
password[i] = '@';
}
else if (password[i] == '@') {
password[i] = '#';
}
}
for (; j != 0; j = j - 1) {
password[j] = password[j] ^ (byte)j;
}
hexencode(password,sVar1,buffer,0x400);
for (k = 0; k < 0x20; k = k + 1) {
l = strlen(username);
local_1048[k] = username[k % l];
}
encrypt(buffer,(int)(sVar1 << 1));
encoded = (void *)base64_encode(local_848,(long)extraout_EAX);
if (encoded == (void *)0x0) {
r = -1;
}
else {
fprintf(stderr,"DEBUG: %s\n",encoded);
r = memcmp(encoded,hash,0x18);
free(encoded);
if (r == 0) {
r = 0;
}
else {
r = -1;
}
}
return r;
}
Cette fonction prend en entrée trois arguments :
username
: le nom de l’utilisateurinput_password
: le mot de passe rentré par l’utilisateurhash
: le hash du mot de passe de l’utilisateur
Cette fonction modifie le mot de passe de plusieurs manières pour
générer un hash. Ce hash sera ensuite comparé avec celui présent dans
le fichier shadow
fourni avec l’exécutable.
Les différentes étapes de la modification du mot de passe sont les suivantes :
- On remplace certains caractères par d’autres.
- On effectue un XOR sur chaque caractère du mot de passe avec l’indice du caractère dans le mot de passe.
- On transforme le mot de passe en hexadécimal.
- On chiffre le mot de passe à l’aide d’une autre fonction
encrypt
que nous verrons plus tard. - On encode le mot de passe en base64.
Les caractères qui sont remplacés sont les suivants :
- ‘_’ par ‘/’ (et inversement)
- ‘+’ par ‘-’
- ‘!’ par ‘?’
- ‘#’ par ‘@’
La fonction encrypt
prend en paramètre le mot de passe transformé, une chaîne de caractères ‘key’ contenant le nom de l’utilisateur répété plusieurs fois jusqu’à ce qu’elle fasse 32 caractères (JeanneJeanneJeanneJeanneJe
) et la chaîne de caractères ‘58ee6d5465a345d1’.
encrypt
effectue simplement un chiffrement AES sur le mot de passe avec la clé et comme IV la chaîne mentionnée plus haut.
Solution
Pour trouver la solution, il suffit simplement de générer le mot de passe à partir du hash fourni dans le fichier shadow
en effectuant les opérations inverses.
On décode le hash en base64, ensuite on le déchiffre avec la clé et l’IV fournis. On obtient le mot de passe qu’on peut ensuite déchiffrer en hexadécimal, puis effectuer un XOR sur chaque caractère du mot de passe avec l’indice du caractère dans le mot de passe.
Voici un script Python qui permet de faire le XOR et de remplacer les caractères pour obtenir le mot de passe de l’utilisateur :
code = [0x37, 0x54,70,0x6d,0x77,0x2a,0x36,0x72,0x5c,0x26,0x59,0x7e,0x68,0x62,0x21,0x66,0x34,0x3e,0x50,0x56,0x40,0x41,0x25,0x65]
result = ""
for i in range(len(code) - 1, 0, -1):
result += chr(code[i] ^ i)
result = result[::-1]
final_string = ""
for l in result :
if l == '_' :
final_string += "/"
elif l == "/" :
final_string += "_"
elif l == "+" :
final_string += "-"
elif l == "-" :
final_string += "+"
elif l == "?" :
final_string += "!"
elif l == "!" :
final_string += "?"
elif l == "@" :
final_string += "#"
elif l == "#" :
final_string += "@"
else :
final_string += l
print(final_string)
Ce script, une fois exécuté, nous donne la chaîne : UDns_0uT_Sudo_i$_BETT3r
.
Ce challenge avait une petite erreur au niveau du hash, donc la solution
n’était pas totalement correcte. Il fallait entrer
7Urns_0uT_Sudo_i$_BETT3r
pour valider le challenge.
CustomFs
Les sources du challenge sont disponibles ici.
Dans ce défi, votre objectif est de comprendre et utiliser un système de fichier inconnu jusqu’à présent. Seul problème: Le programme que vous avez reçu ne peut pas extraire le format de système de fichier, mais uniquement en créer un. Votre mission consiste à déterminer la structure et le contenu du fichier
flag.txt
.Le flag est
JDHACK{$(sha256sum flag.txt | cut -d ' ' -f1)}
Ce challenge est le dernier de la catégorie reverse et est classé comme difficile. Pour ce dernier défi, nous recevons un programme
compilé sans symboles, ainsi qu’un fichier fs.packed
au format inconnu. Lorsque nous exécutons le programme
sans argument, voici ce que nous obtenons :
Usage: ./main CMD
Available commandes:
pack <directory> <file>
unpack <file> <directory>
On remarque qu’il est possible de “packager” un dossier en un fichier et vice versa. Cependant, si l’on tente d’utiliser
la commande unpack
sur fs.packed
, le programme renvoie une erreur :
Error: Not implemented
On dirait qu’on va devoir se débrouiller seuls… On commence par ouvrir le programme dans Ghidra et on se dirige vers l’entrypoint du programme :
void processEntry entry(undefined8 param_1,undefined8 param_2) {
undefined1 auStack_8 [8];
__libc_start_main(FUN_001025d7,param_2,&stack0x00000008,0,0,param_1,auStack_8);
do {
/* WARNING: Do nothing block with infinite loop */
} while( true );
}
On retrouve la fonction __libc_start_main
. Avec un peu d’expérience (ou une recherche sur Google), on sait que le premier
paramètre est un pointeur vers la fonction main
, que l’on peut renommer :
int main(int argc,char **argv) {
if (argc != 4) {
// Show help
goto LAB_00102820;
}
iVar2 = strcmp("pack",argv[1]);
if (iVar2 != 0) {
iVar2 = strcmp("unpack",argv[1]);
if (iVar2 == 0) {
fwrite("Error: Not implemented\n",1,0x17,stderr);
iVar2 = 1;
}
else {
fprintf(stderr,"Error: Unknown command \'%s\'\n",argv[1]);
iVar2 = 1;
}
goto LAB_00102820;
}
cVar1 = FUN_00102544(argv[2]);
if (cVar1 != '\x01') {
fprintf(stderr,"Error: \'%s\' is not a valid directory\n",argv[2]);
iVar2 = 1;
goto LAB_00102820;
}
local_18 = FUN_00101259(argv[2]);
if (local_18 != 0) {
iVar2 = FUN_00101dde(local_18,argv[2]);
if (iVar2 == 0) {
iVar2 = FUN_00101e25(local_18,argv[3]);
if (iVar2 != 0) goto LAB_00102769;
}
else {
LAB_00102769:
fprintf(stderr,"Fail to save filesystem to \'%s\'\n",argv[3]);
}
FUN_00101342(&local_18);
}
iVar2 = 0;
LAB_00102820:
return iVar2;
}
La fonction FUN_00102544
est relativement simple ; elle se contente de vérifier si le paramètre correspond à un dossier valide ou non.
Passons maintenant à la fonction FUN_00101259
:
void * FUN_00101259(char *param_1) {
size_t sVar1;
undefined8 uVar2;
undefined2 *local_10;
local_10 = (undefined2 *)malloc(0x38);
if (local_10 != (undefined2 *)0x0) {
*local_10 = 0x1337;
sVar1 = strlen(param_1);
if (sVar1 < 0x20) {
sVar1 = strlen(param_1);
}
else {
sVar1 = 0x20;
}
memcpy(local_10 + 1,param_1,sVar1);
uVar2 = FUN_00101eea();
*(undefined8 *)(local_10 + 0x14) = uVar2;
uVar2 = FUN_00101eea();
*(undefined8 *)(local_10 + 0x18) = uVar2;
if ((*(long *)(local_10 + 0x18) == 0) || (*(long *)(local_10 + 0x14) == 0)) {
if (*(long *)(local_10 + 0x18) != 0) {
FUN_001024ea(local_10 + 0x18);
}
if (*(long *)(local_10 + 0x14) != 0) {
FUN_001024ea(local_10 + 0x14);
}
local_10 = (undefined2 *)0x0;
}
}
return local_10;
}
On observe un appel à malloc
qui permet d’allouer une zone mémoire de 0x38 octets. Cette zone mémoire est ensuite
transmise aux différentes fonctions. On peut en déduire qu’il s’agit d’une fonction d’initialisation pour une structure.
Il est facile de créer cette structure en effectuant un clic droit sur local_10
, puis en sélectionnant
Auto Create Structure. Avec un peu de renommage et de typage, on obtient ceci :
Filesystem * create_filesystem(char *directory) {
OtherStruct *substruct;
size_t length;
Filesystem *fs;
fs = (Filesystem *)malloc(0x38);
if (fs != (Filesystem *)0x0) {
fs->magic = 0x1337;
length = strlen(directory);
if (length < 0x20) {
length = strlen(directory);
}
else {
length = 0x20;
}
memcpy(fs->directory,directory,length);
substruct = (OtherStruct *)create_other_struct();
fs->struct1 = substruct;
substruct = (OtherStruct *)create_other_struct();
fs->struct2 = substruct;
if ((fs->struct2 == (OtherStruct *)0x0) || (fs->struct1 == (OtherStruct *)0x0)) {
if (fs->struct2 != (OtherStruct *)0x0) {
destroy_other_struct(&fs->struct2);
}
if (fs->struct1 != (OtherStruct *)0x0) {
destroy_other_struct(&fs->struct1);
}
fs = (Filesystem *)0x0;
}
}
return fs;
}
La fonction create_other_struct
fait également appel à malloc
pour créer une structure de taille 0x10. La fonction
destroy_other_struct
permet de libérer les ressources associées à la structure en cas d’erreur. Elle possède le code suivant :
void destroy_other_struct_helper(OtherStruct *other,void *func,undefined8 param_3) {
if ((other != (OtherStruct *)0x0) && (func != (void *)0x0)) {
FUN_00102143(other->field1_0x8,func,param_3);
}
return;
}
void destroy_other_struct(OtherStruct **otherptr) {
if ((otherptr != (OtherStruct **)0x0) && (*otherptr != (OtherStruct *)0x0)) {
destroy_other_struct_helper(*otherptr,FUN_001024c2,0);
free(*otherptr);
*otherptr = (OtherStruct *)0x0;
}
return;
}
La fonction FUN_001024c2
se limite à appeler free
sur son paramètre. Quant à la fonction FUN_00102143
, elle possède le code suivant :
void FUN_00102143(long param_1,code *func,undefined8 param_3) {
if (param_1 != 0) {
FUN_00102143(*(undefined8 *)(param_1 + 0x10),func,param_3);
(*func)(param_1,param_3);
FUN_00102143(*(undefined8 *)(param_1 + 0x18),func,param_3);
}
return;
}
En examinant attentivement le code, on remarque que la fonction FUN_00102143
s’appelle récursivement sur deux
sous-éléments et, entre-temps, appelle la fonction de callback passée en paramètre sur l’élément actuel. Cette
structure correspond à un parcours en profondeur d’un arbre binaire. En cas de doute, il est possible de demander
à un LLM de notre choix :
> A ton avis, à quoi correspond cette fonction: <code de FUN_00102143>
La fonction FUN_00102143 que vous avez fournie semble être une implémentation d'un parcours récursif d'une
structure de données en forme d'arbre, probablement un arbre binaire.
On peut maintenant retyper les différentes structures et fonctions, on remplace “OtherStruct” par “Tree” :
void deep_first_walk(Node *node,code *func,undefined8 param_3) {
if (node != (Node *)0x0) {
deep_first_walk((Node *)node->left,func,param_3);
(*func)(node,param_3);
deep_first_walk((Node *)node->right,func,param_3);
}
}
void destroy_tree_helper(Tree *tree,void *func,undefined8 param_3) {
if ((tree != (Tree *)0x0) && (func != (void *)0x0)) {
deep_first_walk((Node *)tree->root,func,param_3);
}
}
void destroy_tree(Tree **otherptr) {
if ((otherptr != (Tree **)0x0) && (*otherptr != (Tree *)0x0)) {
destroy_tree_helper(*otherptr,FUN_001024c2,0);
free(*otherptr);
*otherptr = (Tree *)0x0;
}
}
Afin de mieux visualiser les choses, on peut réaliser un schéma des différentes structures :

Si l’on revient maintenant à notre fonction main
, nous pouvons passer à l’analyse de la seconde fonction FUN_00101dde
:
int FUN_00101dde(Filesystem *fs,char *directory) {
int res = -1;
if ((fs != (Filesystem *)0x0) && (directory != (char *)0x0)) {
res = iter_dir(directory,FUN_00101a39,fs);
}
return res;
}
int iter_dir(char *directory, code *callback, Filesystem *fs) {
int result;
int v;
size_t len;
DIR *dir;
dirent *entry;
long in_FS_OFFSET;
stat st;
char filename [4104];
len = strlen(directory);
result = (int)len;
if (result < 0xfff) {
strcpy(filename,directory);
filename[result] = '/';
dir = opendir(directory);
if (dir == (DIR *)0x0) {
fprintf(stderr,"can\'t open %s\n",directory);
result = -1;
}
else {
while (entry = readdir(dir), entry != (dirent *)0x0) {
if (((entry->d_name[0] != '.') && (v = strcmp(entry->d_name,"."), v != 0)) &&
(v = strcmp(entry->d_name,".."), v != 0)) {
strncpy(filename + (result + 1),entry->d_name,(long)(0x1000 - (result + 1)));
v = lstat(filename,&st);
if (v == -1) {
fprintf(stderr,"can\'t stat %s\n",filename);
}
else {
if (((st.st_mode & 0xf000) == 0x4000) && (v = iter_dir(filename,callback,fs), v != 0)) {
result = -1;
goto LAB_00101dc8;
}
(*callback)(fs,filename);
}
}
}
if (dir != (DIR *)0x0) {
closedir(dir);
}
result = 0;
}
}
else {
result = -1;
}
LAB_00101dc8:
return result;
}
On peut voir que ces fonctions vont permettre d’itérer récursivement sur l’ensemble des fichiers présents
dans une arborescence (d’où le nom iter_dir
). Il est donc intéressant d’examiner la fonction de callback,
ici FUN_00101a39
, que l’on renomme en iter_dir_callback
:
int iter_dir_callback(Filesystem *fs,char *filename) {
int res;
ssize_t len;
stat st;
char name [4104];
res = lstat(filename,&st);
if (res == 0) {
if ((st.st_mode & 0xf000) == S_IFLNK) {
len = readlink(filename,name,0x1000);
if (len < 0) {
res = -1;
}
else {
name[len] = '\0';
res = FUN_00101855(fs,filename,name); /* Symbolic link */
}
}
else if ((st.st_mode & 0xf000) == S_IFDIR) {
res = FUN_001016f3(fs,filename); /* Directory */
}
else {
res = FUN_001013a4(fs,filename,st.st_size); /* File */
}
}
else {
res = -1;
}
return Res;
}
Cette fonction va vérifier le type de fichier traité à partir du nom transmit en paramètre. Elle appelle ensuite les
fonctions FUN_00101855
pour un lien symbolique, FUN_001016f3
pour un dossier et FUN_001013a4
pour les fichiers normaux.
On peut commencer par analyser le code traitant les fichiers classiques, les deux autres fonctions étant très similaires. Après un peu de renommage et de typage, on obtient la fonction suivante :
int FUN_001013a4(Filesystem *fs,char *filename,size_t size) {
int iVar1;
size_t len;
long i;
undefined8 *puVar2;
string *value1;
string *value2;
Node *node1;
void *file_content;
FILE *fp;
ulong hashval;
Node *node2;
string string;
undefined8 local_1008 [511];
string.size = 0x454c4946; /* -> b'FILE' */
string.str = (char *)0x0;
len = strlen(filename);
if (len < 0x1000) {
len = strlen(filename);
}
else {
len = 0x1000;
}
memcpy((void *)((long)&string.size + 4),filename,len);
value1 = (string *)alloc_string(&string);
if (value1 == (string *)0x0) {
iVar1 = -1;
}
else {
file_content = malloc(size + 1);
if (file_content == (void *)0x0) {
destroy_string(&value1);
iVar1 = -1;
}
else {
fp = fopen(filename,"r");
if (fp == (FILE *)0x0) {
destroy_string(&value1);
free(file_content);
iVar1 = -1;
}
else {
len = fread(file_content,size,1,fp);
if (len == 1) {
fclose(fp);
value2 = create_string(file_content,size);
if (value2 == (string *)0x0) {
destroy_string(&value1);
free(file_content);
iVar1 = -1;
}
else {
hashval = hash_string(value1);
node1 = create_node(hashval,value1);
if (node1 == (Node *)0x0) {
destroy_string(&value1);
destroy_string(&value2);
iVar1 = -1;
}
else {
node2 = create_node(hashval,value2);
if (node2 == (Node *)0x0) {
FUN_00102486(&node1);
destroy_string(&value1);
destroy_string(&value2);
iVar1 = -1;
}
else {
add_node(fs->t2,node1);
add_node(fs->t1,node2);
iVar1 = 0;
}
}
}
}
else {
destroy_string(&value1);
free(file_content);
fclose(fp);
iVar1 = -1;
}
}
}
}
return iVar1;
}
Le programme effectue les actions suivantes :
- Crée une structure
string
et y ajoute ‘FILE’ suivi du nom du fichier. - Lit le contenu du fichier et le stocke dans une autre structure
string
. - Calcule une valeur de hachage sur la première
string
contenant le nom du fichier et ajoute deux nœuds dans les deux arbres de la structureFilesystem
, le premier étant le nom du fichier et le second son contenu.
On peut donc renommer cette fonction en add_file_to_fs
et répéter les mêmes opérations pour les
deux autres fonctions vues précédemment. On obtient alors :
int iter_dir_callback(Filesystem *fs,char *filename) {
int res;
ssize_t len;
stat st;
char name [4104];
res = lstat(filename,&st);
if (res == 0) {
if ((st.st_mode & 0xf000) == S_IFLNK) {
len = readlink(filename,name,0x1000);
if (len < 0) {
res = -1;
}
else {
name[len] = '\0';
res = add_link_to_filesystem(fs,filename,name);
}
}
else if ((st.st_mode & 0xf000) == S_IFDIR) {
res = add_dir_to_filesystem(fs,filename);
}
else {
res = add_file_to_filesystem(fs,filename,st.st_size);
}
}
else {
res = -1;
}
return Res;
}
Il nous reste maintenant une dernière fonction à étudier dans main
:
fs = create_filesystem(argv[2]);
if (fs != (Filesystem *)0x0) {
r = add_filesystem(fs,argv[2]);
if (r == 0) {
r = FUN_00101e25(fs,argv[3]);
if (r != 0) goto LAB_00102769;
}
else {
LAB_00102769:
fprintf(stderr,"Fail to save filesystem to \'%s\'\n",argv[3]);
}
destroy_filesystem(&fs);
}
Vu le message d’erreur présent juste en dessous, on peut d’ores et déjà renommer FUN_00101e25
en save_filesystem
. Son
code est le suivant (une fois que l’on a effectué notre renommage habituel) :
int save_filesystem(Filesystem *fs,char *filename) {
FILE *fp;
undefined8 r;
fp = fopen(filename,"w");
if (fp == (FILE *)0x0) {
r = 0xffffffff;
}
else {
fwrite(fs,2,1,fp);
fwrite(fs->directory,0x20,1,fp);
save_tree(fs->t2,save_string,fp);
save_tree(fs->t1,save_string,fp);
fclose(fp);
r = 0;
}
return r;
}
void save_string(string *s,FILE *fp) {
if ((s != ) && (fp != (FILE *)0x0)) {
fwrite(s,8,1,fp);
fwrite(s->str,s->size,1,fp);
}
}
void save_tree(Tree *tree,void *callback,FILE *fp) {
if (((tree != (Tree *)0x0) && (callback != (void *)0x0)) && (fp != (FILE *)0x0)) {
fwrite(tree,8,1,fp);
save_node(tree->root,callback,fp);
}
}
void save_node(Node *node,void *callback,FILE *fp) {
uint64_t key;
if (node == (Node *)0x0) {
key = 0xffffffffffffffff;
fwrite(&key,8,1,fp);
}
else {
key = node->key;
fwrite(&key,8,1,fp);
(*(code *)callback)(node->value,fp);
save_node(node->left,callback,fp);
save_node(node->right,callback,fp);
}
}
On retrouve les structures habituelles avec les arbres, les nœuds et les strings. On comprend à présent que, pour
sauvegarder la structure Filesystem
sous forme de fichier, le programme va sérialiser le premier arbre contenant
les noms des fichiers, puis sérialiser celui avec leur contenu.
Toutefois, il existe un lien entre les deux entrées : la valeur de hachage qui est commune aux deux entrées. On peut donc commencer
par ouvrir notre fichier fs.packed
avec un éditeur hexadécimal. Je vous recommande l’excellent ImHex !

On commence donc par chercher le nom de notre fichier “flag.txt” dans l’éditeur.
Nous savons que la valeur de hachage est située juste avant le nom de notre fichier. Nous cherchons donc
maintenant F1 12 56 7C E7 25 03 D5
dans l’onglet Binary Pattern de la fenêtre Find. Nous obtenons
alors deux occurrences : le nom du fichier et son contenu.
On peut voir sur la capture que la taille du fichier (située après la valeur de hachage) est égale à \x00\x02\x00\x00\x00\x00\x00\x00
,
soit 512 en little endian. Nous pouvons extraire le fichier grâce à la commande dd
suivante :
dd if=fs.packed bs=1 skip=$((0x7771)) count=512 | sha256sum
On obtient alors le hash 8863d06f81bd19b9f540ef57fbd77c45d376b198b8f561192e3536ac0f82f963
et notre flag avec !
Pour ceux qui aimeraient examiner le système de fichiers par eux-mêmes, vous pouvez compiler le programme avec make debug
depuis le repository GitHub et utiliser la commande unpack
.