MISC Writeup - 2026 Edition
Table des matières
Dans cet article, nous présenterons les différentes solutions aux challenges du CTF Jeanne d’Hack 2026 pour la catégorie MISC.
Mobile Odyssey
Catégorie : Misc — Difficulté : Facile
Description
Un nouveau jeu mobile, Mobile Odyssey, est en cours de développement.
On nous fournit uniquement l’APK mobile_odyssey_v0.0.1.apk. L’UI est bancale, il n’y a rien de vraiment jouable, mais la consigne laisse entendre qu’il pourrait y avoir des informations secrètes cachées dans l’application.
Format du flag :
JDHACK{this_is_a_flag}
Solution
Étape 1 — Ouvrir l’APK
Le challenge ne donne pas de backend à interroger, seulement l’APK Android.
On le charge donc dans un outil d’analyse statique comme :
- jadx-gui (
jadx mobile_odyssey_v0.0.1.apk), ou - MobSF (Mobile Security Framework).
L’objectif est de chercher des services tiers (Firebase, API, etc.) et des ressources embarquées.
Étape 2 — Identifier Firebase Remote Config
En parcourant l’arborescence dans jadx, on voit que l’application utilise Firebase (présence de packages com.google.firebase.*, fichiers google-services.json, etc.).
Un bon point d’entrée est la classe com.jeannedhackctf.mobileodyssey.GameActivity : on y voit l’initialisation de FirebaseRemoteConfig et la récupération de valeurs distantes (dont sEcre7vALu3).
Plus généralement, on cherche des références à Remote Config :
- dans le code Java/Kotlin (appels à
FirebaseRemoteConfig,getString(...)dansGameActivity.java).
Une recherche globale sur le mot‑clé sEcre7vALu3 permet de trouver rapidement où cette clé est utilisée.
Étape 3 — Localiser la clé sEcre7vALu3 et interroger Firebase
Dans GameActivity.java, on voit que l’application récupère une valeur de Remote Config avec la clé sEcre7vALu3. Le code initialise FirebaseRemoteConfig, définit éventuellement des valeurs par défaut, puis appelle getString("sEcre7vALu3") pour construire le message affiché dans l’application.
Les valeurs vraiment intéressantes ne sont pas stockées en dur dans l’APK : elles viennent du projet Firebase associé (via Remote Config). Il faut donc interroger directement Firebase pour récupérer la valeur de sEcre7vALu3.
Pour cela, on peut :
- soit lancer l’application dans un émulateur / téléphone et intercepter le trafic réseau (Burp, mitmproxy…) pour observer la requête Remote Config et la réponse JSON ;
- soit utiliser l’API REST Remote Config de Firebase (avec l’ID de projet et éventuellement la clé API trouvés dans
google-services.json) pour récupérer la configuration, puis lire le champ correspondant àsEcre7vALu3.
Dans la réponse Remote Config, la clé sEcre7vALu3 contient directement une valeur encodée en Base64 :
sEcre7vALu3 = SkRIQUNLe00wOGlsZV9ATmRfRjFyRWI0czNfIXNfZnVufQ
La décoder donne :
JDHACK{M08ile_@Nd_F1rEb4s3_!s_fun}

Cette valeur correspond au flag du challenge.
Flag : JDHACK{M08ile_@Nd_F1rEb4s3_!s_fun}
Goofy Fantasy
Les sources de ce challenge sont disponibles ici.
Vous êtes en vacances dans un pays lointain, mais vous avez oublié le mot de passe de votre chambre d’hôtel… Vous vous baladé alors en ville en espérant que cela finisse par vous revenir.
Le flag est dissimulé au sein des données du fichier goofy_fantasy.gif. Vous étudierez alors la structure des fichiers GIF afin de le retrouver.
Les fichiers GIF sont ordonnés de cette manière :

Suite à la Global Color Table, on retrouve les données associées à toutes les images incluses dans le fichier GIF. Chaque image correspond donc à une boucle qui comprend l’ensemble des composants situés entre la Global Color Table et le trailer.
Le trailer étant l’octet qui marque la fin du fichier GIF : 0x3B.
Parmi ces blocs, étudions la composition du Graphic Control Extension, ainsi que des Image Descriptor :
Graphic Control Extension :

Ce bloc optionnel contient un octet de Packed Fields. Les bits 5, 6 et 7 y sont marqués comme “Reserved” (réservés pour un usage futur) et sont normalement à zéro.
Image Descriptor :

Ce bloc contient également un octet de Packed Fields (situé à l’offset 9 du bloc). Ici, ce sont les bits 3 et 4 qui sont définis comme “Reserved”.
Ces deux composants possèdent des bits “inutilisés” où de la donnée peut être introduite discrètement sans altérer le rendu du fichier. Cependant, la documentation indique que le bloc Graphic Control Extension est optionnel. Un fichier GIF peut théoriquement n’en contenir aucun.
Parser un fichier GIF
Pour vérifier les valeurs de ces différents bits, il faut écrire un parser. Vous retrouverez à cette adresse un résumé schématisé de la norme GIF89a : https://giflib.sourceforge.net/whatsinagif/bits_and_bytes.html.
Il y a quelques subtilité à prendre en compte. La plus part des blocs ont une taille fixe, il suffit de sauter par dessus le bloc lorsque l’on repère sa signature.
D’autres ayant des tailles variables nécessitent un petit calcul, par exemple,

La Global Color Table est un bloc optionel qui permet de définir des teintes pour l’ensembles des images, sans qu’elles ne soient redéfinis à chaque fois. En d’autre terme c’est une macro constante de couleurs.
Pour sauter ce bloc il faut connaître la valeur de N. Celle si se trouve dans le bloc précédent.

Une fois parsé, on remarque que ces bits réservés ne fluctuent qu’à partir d’une image x et uniquement dans les Image Descriptors.
Ainsi, en récupérant l’ensemble des bits réservés de chaque Image Descriptor et en les mettant bout à bout, on récupère le flag au format ASCII.
Ce qui nous donnes ce script de résolution :
import math
def extract_secret(filename):
chunks = []
with open(filename, 'rb') as f:
f.read(13)
f.seek(10, 0)
packed = ord(f.read(1))
if packed & 0x80:
n = packed & 0x07
gct_size = 3 * int(math.pow(2, n + 1))
f.seek(13 + gct_size, 0)
else:
f.seek(13, 0)
while True:
byte = f.read(1)
if not byte: break
b = ord(byte)
if b == 0x3B:
break
elif b == 0x21:
f.read(1)
while True:
size = ord(f.read(1))
if size == 0: break
f.read(size)
elif b == 0x2C:
desc = f.read(9)
chunks.append((desc[8] >> 4) & 0x03) # xxxyyxxx become 000000yy
if desc[8] & 0x80:
lct_n = desc[8] & 0x07
lct_size = 3 * int(math.pow(2, lct_n + 1))
f.read(lct_size)
f.read(1)
while True:
size = ord(f.read(1))
if size == 0: break
f.read(size)
return chunks
def reconstruct_message(chunks):
bytes_list = []
i = 0
while i < len(chunks):
if i + 3 >= len(chunks):
break
#bytes are recrafted
bits_8_7 = chunks[i] << 6
bits_6_5 = chunks[i+1] << 4
bits_4_3 = chunks[i+2] << 2
bits_2_1 = chunks[i+3]
valeur = bits_8_7 | bits_6_5 | bits_4_3 | bits_2_1
bytes_list.append(valeur)
i += 4
data = bytearray(bytes_list).strip(b'\x00')
return data.decode('utf-8')
if __name__ == "__main__":
filename = "goofy_fantasy.gif"
chunks = extract_secret(filename)
secret = reconstruct_message(chunks)
print(f"SECRET: {secret}")
En executant ce script nous obtenons le flag : JDHACK{!T_wA5_Re5erveD_fOR_Y0u}.
Navi’s Mania
Les sources de ce challenge sont disponibles ici.
Link est piégé dans une boucle temporelle par le Fléau. Il ouvre ce coffre pour l’éternité mais celui-ci semble désespérément vide. À vous d’analyser ce songe pour en extraire le véritable contenu.
Le flag est présent dans un support mp4, vous étudierez la structure des fichiers mp4 afin de le retrouver.
Puisse la documentation vous aider : https://b.goeswhere.com/ISO_IEC_14496-12_2015.pdf
Strucutre des fichiers MP4
Les fichiers MP4 sont organisés en box. Chaque box contient d’autres boxes et celles-ci forment l’écosystème du fichier. Chacune d’entre elles possède une utilité différente, une taille et un type bien précis.
Un exemple de box importante est mdat : celle-ci contient l’ensemble des données brutes relatives à l’audio et aux images du fichier MP4.
Le header d’une box fait toujours 8 octets minimum : Les 4 premiers octets définissent la Taille totale de la box. Les 4 octets suivants définissent le Type (son nom en 4 caractères ASCII).
Ainsi pour se déplacer dans l’arborescence, il suffit de lire la taille, le nom de la box, si elle nous intéresse “entrer dedans” en lisant les 8 octets suivants, sinon se déplacer de la taille de toute la box.
Voici l’imbrication de ces boxes selon la norme ISO/IEC 14496-12 :

De plus, généralement l’ensemble des informations relatives à la vidéo se trouveront dans deux atomes trak, un pour les données audio, un pour les données vidéos. Lorsqu’un fichier mp4 contient plusieurs trak, quasiment tous les lecteurs proposent une option pour transiter entre les différentes pistes contenues au sein du même fichier mp4.
Lorsque l’on utilise un tool pour parser tout le fichier et avoir une vue de surface (https://www.onlinemp4parser.com/) nous obtenons :


Dans un fichier MP4, l’atome free (ou skip) est un espace réservé ou “vide” qui ne contient aucune donnée multimédia exploitable par le lecteur. Il sert principalement de zone tampon : les logiciels d’édition l’utilisent pour ajuster la taille du fichier ou insérer des métadonnées sans avoir à réécrire l’intégralité du contenu, ce qui rend le traitement beaucoup plus rapide.
On remarque deux choses très étranges : premièrement, le second atome free fait exactement la même taille que celui qui suit le premier trak.
Premièrement, un atome free de cette taille est tout à fait inhabituel. Comme expliqué précédemment, ces atomes servent normalement de zone tampon (padding) pour l’alignement des données et ne dépassent généralement pas quelques octets.
De plus, les trak sont placés les uns à côté des autres au sein de l’atome moov habituellement. Il faut aussi noter que les lecteurs multimédias, lorsqu’ils rencontrent l’atome free, sautent par-dessus directement sans chercher à savoir ce qu’il contient.
On en déduit alors que l’atome free qui suit le second trak était la piste qui contenait le flag. Seul le nom de l’atome a été renommé.
Alors, il suffit de le re-renommer en trak ? En fait non, on comprend assez vite que les données présentes y ont été randomisées. Il fallait alors copier toutes les données présentes dans le dernier atome free qui est en fait une copie du trak originel qui représentait la vidéo à retrouver.
De plus pour s’en assurer, en analysant le contenu du second atome free, on retrouvait la structure d’un atome track.
Il suffit ensuite de les insérer à la place du free qui suit le premier trak, et renommer l’atome en trak.
Ce qui nous donnes ce script de résolution :
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#define BUFFSIZE 1024
void unhide(FILE * file);
int main(int argc, char ** argv) {
if(argc != 2) {
printf("Usage : %s file\n", argv[0]);
return -1;
}
FILE * file = fopen(argv[1], "rb+");
if(file == NULL) {
fprintf(stderr, "Erreur d'ouverture\n");
return -1;
}
unhide(file);
fclose(file);
return 0;
}
void unhide(FILE * file){
int free_cnt = 0;
while(1) {
unsigned char header[8];
long current_pos = ftell(file);
if (fread(header, 1, 8, file) != 8) break;
size_t size = (header[0] << 24) | (header[1] << 16) | (header[2] << 8) | header[3];
if (strncmp((char*)&header[4], "moov", 4) == 0) {
continue;
}
if(strncmp((char*)&header[4], "free", 4) == 0) {
free_cnt++;
if (free_cnt == 2) {
unsigned char *buffer = malloc(size - 8);
fseek(file, -(long)size, SEEK_END);
fseek(file, 8, SEEK_CUR);
fread(buffer, 1, size - 8, file);
ftruncate(fileno(file), ftell(file) - size);
fseek(file, current_pos, SEEK_SET);
memcpy(&header[4], "trak", 4);
fwrite(header, 1, 8, file);
fwrite(buffer, 1, size - 8, file);
free(buffer);
break;
}
fseek(file, -(long)(size - 8), SEEK_CUR);
}
fseek(file, size - 8, SEEK_CUR);
}
}
En executant ce script nous obtenons le flag : JDHACK{l0st_tRaK$_!n_Th3_wO0d}.
Blind Distribution
Les sources de ce challenge sont disponibles ici.
Votre neveu était très content d’avoir réalisé sa première vidéo sur son jeu vidéo préféré. Malheureusement, il a renversé du café sur son ordinateur et sa vidéo ne s’affiche plus correctement… Aidez-le à retrouver la vidéo originale !
Vous obtiendrez le flag en visionnant la vidéo gameplay.mp4 une fois qu’elle sera redevenue à son état d’origine.
Nous avons vu dans la résolution de navi’s mania, la composition des fichiers mp4.
Mais quel est le lien entre cette écran noir, et ces différents atomes composants le fichier ?
Plusieurs raisons peuvent amener le lecteur d’un fichier MP4 à ne faire paraître aucune image (tout en gardant la bande son).
Ici il fallait s’intéressé à l’atome stco (Chunk Offset Box).
C’est l’un des atomes les plus importants, puisqu’il indique pour tel chunk de la vidéo, à quel offset de l’atome mdat aller afin d’avoir la donnée correspondante.
Un chunk est un ensemble d’une ou plusieurs frames (une image). Dans un fichier MP4, pour éviter que le lecteur doive faire des allers retours permanents entre la piste audio et la piste vidéo, les données sont découpées en ces petits blocs appelés chunks. On retrouve donc généralement un chunk de vidéo, puis un chunk d’audio, et ainsi de suite mélangé dans l’atome mdat.
Ainsi, l’atome stco contient une table qui liste l’emplacement exact de chaque chunk :
-
Chunk 1 -> Offset X
-
Chunk 2 -> Offset Y
-
…
Sans ces index, le lecteur ne peut pas savoir où commence et où s’arrête la donnée brute au sein de mdat, rendant la vidéo illisible.
Lorsqu’on analyse la box stco :

On remarque que les offsets associés aux différents chunks sont désordonnés. Or, dans un fichier MP4 standard, ces adresses au sein de l’atome stco doivent quasi systématiquement apparaître dans l’ordre croissant, car elles suivent la progression physique des données dans l’atome mdat.
En triant simplement cette liste pour remettre les adresses dans le bon ordre numérique, on réaligne la lecture avec la position réelle des données sur le disque, ce qui permet de retrouver la vidéo d’origine
Ce qui nous donnes ce script de résolution :
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#include <time.h>
#include <arpa/inet.h>
// Fonction de comparaison pour qsort
int compare_offsets(const void *a, const void *b);
int main(int argc, char ** argv) {
srand(time(NULL));
if(argc != 2) {
printf("Usage : %s file\n", argv[0]);
return -1;
}
FILE * file = fopen(argv[1], "rb+");
if(file == NULL) {
fprintf(stderr, "Erreur d'ouverture\n");
return -1;
}
while(1) {
unsigned char header[8];
if (fread(header, 1, 8, file) != 8) break;
size_t size = (header[0] << 24) | (header[1] << 16) | (header[2] << 8) | header[3];
char *type = (char*)&header[4];
// Route pour atteindre stco directement : b.goeswhere.com/ISO_IEC_14496-12_2015.pdf (brand isom)
if (strncmp(type, "moov", 4) == 0) continue;
if (strncmp(type, "trak", 4) == 0) continue;
if (strncmp(type, "mdia", 4) == 0) continue;
if (strncmp(type, "minf", 4) == 0) continue;
if (strncmp(type, "stbl", 4) == 0) continue;
if (strncmp(type, "stco", 4) == 0) {
unsigned char meta[8];
fread(meta, 1, 8, file);
uint32_t raw_count;
memcpy(&raw_count, &meta[4], 4);
size_t count = ntohl(raw_count);
// liste de tout les offset
uint32_t *offsets = malloc(count * sizeof(uint32_t));
fread(offsets, sizeof(uint32_t), count, file);
// trie de la liste d'offset melanger
qsort(offsets, count, sizeof(uint32_t), compare_offsets);
fseek(file, -(long)(count * sizeof(uint32_t)), SEEK_CUR);
fwrite(offsets, sizeof(uint32_t), count, file);
free(offsets);
break;
}
fseek(file, size - 8, SEEK_CUR);
}
fclose(file);
return 0;
}
int compare_offsets(const void *a, const void *b) {
unsigned int val_a = ntohl( *(unsigned int*)a );
unsigned int val_b = ntohl( *(unsigned int*)b );
if (val_a < val_b) return -1;
if (val_a > val_b) return 1;
return 0;
}
En executant ce script nous obtenons le flag : JDHACK{g0Od_oFF$eT_Go0D_CHUnK5}.