Fools Mate

Pour ce premier write-up je pars sur Fools Mate, un challenge facile de TryHackMe. La description tient en une question : Can you bypass the engine? On a un échiquier et un moteur qui refuse de nous laisser gagner. À nous de trouver où le coup est bloqué et comment le faire passer.
Le temps estimé est de 15 minutes. Pendant ma session la machine était sur 10.129.165.26.
Vérifier que la machine répond
Je commence par un ping de cinq paquets. Le -c5 sert à s'arrêter après le cinquième sinon la commande tourne à l'infini.
┌──(kali㉿kali)-[~]
└─$ ping -c5 10.129.165.26
PING 10.129.165.26 (10.129.165.26) 56(84) bytes of data.
64 bytes from 10.129.165.26: icmp_seq=1 ttl=62 time=21.9 ms
64 bytes from 10.129.165.26: icmp_seq=2 ttl=62 time=22.4 ms
64 bytes from 10.129.165.26: icmp_seq=3 ttl=62 time=21.0 ms
64 bytes from 10.129.165.26: icmp_seq=4 ttl=62 time=22.8 ms
64 bytes from 10.129.165.26: icmp_seq=5 ttl=62 time=17.7 ms
--- 10.129.165.26 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4014ms
rtt min/avg/max/mdev = 17.693/21.151/22.774/1.821 ms
Aucune perte, la machine est bien joignable.
Le ttl=62 donne un petit indice. Une valeur proche de 64 fait plutôt penser à Linux ou Unix, alors qu'autour de 128 on est souvent sous Windows. Comme le TTL baisse à chaque routeur traversé, on ne reçoit pas forcément la valeur de départ : c'est une piste, rien de plus. Les 64 bytes au début de chaque ligne, eux, correspondent à la taille de la réponse et n'ont aucun rapport avec le système d'exploitation.
Scanner les ports
Ensuite un scan Nmap sur tous les ports TCP.
┌──(kali㉿kali)-[~]
└─$ nmap -sV -sC -p- -T4 10.129.165.26
Starting Nmap 7.991 ( https://nmap.org ) at 2026-07-05 16:04 +0200
Nmap scan report for 10.129.165.26
Host is up (0.018s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 66:5f:9d:7c:71:5e:77:c6:cb:b6:b0:ea:e9:c0:0a:a3 (ECDSA)
|_ 256 c0:43:6a:09:c1:76:12:43:df:cf:e0:74:f5:a1:d5:63 (ED25519)
80/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 15.94 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.
Il n'y a que deux ports ouverts : SSH sur le 22 (OpenSSH 9.6p1 Ubuntu 3ubuntu13.16) et un serveur web sur le 80, en Node.js avec Express, dont la page s'appelle Endgame Trainer.
La bannière SSH parle d'Ubuntu, ce qui va dans le sens de Linux. Le p de 9.6p1 veut dire « portable » : c'est la version d'OpenSSH adaptée aux autres systèmes. Une bannière ne dit rien des correctifs réellement installés, je n'en tire donc aucune conclusion sur une éventuelle faille. De toute façon, je n'ai pas d'identifiants SSH et le service web colle avec le thème du challenge. Je continue donc par le port 80.
Explorer l'application web
En ouvrant http://10.129.165.26, on tombe sur un jeu d'échecs. Les blancs ont un mat en un coup : la tour passe de a1 à a8, attaque le roi noir sur la dernière rangée et il n'a aucune case pour s'échapper.

Je tente le coup et l'application refuse : un message d'avertissement s'affiche.

Une fois le message fermé, la tour revient là où elle était comme si rien ne s'était passé.

Le message en question :
I'll shut down your PC if you play that.
Une vérification intercepte donc mon coup avant qu'il soit joué. Pour comprendre laquelle, il faut aller voir le code qui gère le déplacement des pièces.
Avant ça je lance aussi Gobuster pour voir quels chemins existent sur le serveur.
gobuster dir -u http://10.129.165.26/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -t 50 2>/dev/null

Gobuster remonte /css/, /js/, /vendor/ et /pieces/. Tous répondent avec un code 301, c'est-à-dire une redirection vers la même adresse avec un slash à la fin. Autrement dit, ces chemins ne sont pas des pages qu'on peut exploiter directement : pas de listing de fichiers, pas de panneau d'administration. Le nom /js/ reste utile puisqu'il indique où se trouve le code qui fait tourner le jeu.
Comprendre le blocage dans le JavaScript
Dans l'inspecteur du navigateur on repère la balise qui charge js/app.js en tant que module :
<script type="module" src="js/app.js"></script>

Ce fichier est téléchargé par le navigateur pour faire marcher l'interface, donc rien ne m'empêche de le récupérer moi-même avec curl. Je cherche directement le texte du message pour tomber sur la condition qui le déclenche.
curl -s http://10.129.165.26/js/app.js | grep -Fn -C 10 "I'll shut down your PC if you play that."

On arrive sur la fonction preMoveCheck :
function preMoveCheck(from, to, promotion) {
const probe = new Chess(game.fen());
let result;
try {
result = probe.move({ from, to, promotion: promotion || undefined });
} catch (e) {
result = null;
}
if (result && probe.isCheckmate()) {
showSystemNotice("I'll shut down your PC if you play that.");
return false;
}
return true;
}
La première ligne crée une nouvelle partie à partir de game.fen(). Le FEN est un format qui décrit la position des pièces et le camp qui doit jouer, donc probe est une copie de la partie en cours sur laquelle le script teste mon coup sans toucher au vrai plateau.
Ensuite probe.move() essaie de jouer le déplacement. Si le coup n'est pas valide, ça lève une exception et result vaut null. Sinon le script regarde si la position obtenue est un échec et mat avec probe.isCheckmate().
C'est cette dernière condition qui me bloque. Dès qu'un coup légal donne mat, la fonction affiche le message et renvoie false. Les règles du jeu n'y sont pour rien : le coup est reconnu comme valide, c'est l'interface qui a décidé de ne pas laisser passer un mat.
Tout ça s'exécute dans mon navigateur. Reste à savoir si le serveur fait la même vérification quand il reçoit le coup.
Retrouver la requête envoyée au serveur
En continuant la lecture de app.js, je trouve sendMove, la fonction qui parle vraiment au serveur :
async function sendMove(from, to, promotion) {
locked = true;
let data;
try {
const res = await fetch('/api/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to, promotion: promotion || undefined })
});
data = await res.json();
} catch (e) {
locked = false;
renderFull();
return;
}
if (!data || !data.ok) {
locked = false;
renderFull();
return;
}
const pMove = game.move({ from, to, promotion: promotion || undefined });
animateMove(from, to);
recordMove(pMove ? pMove.san : from + to, 'w');
setLastMove(from, to);
if (data.botMove) {
const bf = data.botMove.slice(0, 2);
const bt = data.botMove.slice(2, 4);
const bp = data.botMove.slice(4);
setTimeout(() => {
const bMove = game.move({ from: bf, to: bt, promotion: bp || undefined });
animateMove(bf, bt);
recordMove(bMove ? bMove.san : bf + bt, 'b');
setLastMove(bf, bt);
if (game.fen() !== data.fen) { game.load(data.fen); renderFull(); }
finalize(data);
locked = game.isGameOver();
}, 220);
} else {
if (game.fen() !== data.fen) { game.load(data.fen); renderFull(); }
finalize(data);
locked = game.isGameOver();
}
}
L'essentiel se joue dans les premières lignes : sendMove envoie un POST vers /api/move avec un petit JSON contenant from, to et éventuellement promotion. from est la case de départ, to la case d'arrivée et promotion ne sert que pour transformer un pion, ce qui ne nous concerne pas avec une tour.
Deux choses me frappent dans cette fonction :
preMoveCheckn'est pas appelée ici. Le contrôle qui bloque le mat a lieu avant, côté navigateur.sendMovene revérifie rien : elle envoie les coordonnées et attend la réponse.- Le serveur a le dernier mot.
sendMovese contente de lire ce qu'il renvoie (data.ok,data.fen,data.botMove) et de mettre l'affichage à jour. Sidata.okest faux, rien ne bouge. Si le coup est accepté, c'est le serveur qui renvoie le résultat de la partie, flag compris.
J'aurais pu essayer d'appeler sendMove depuis la console du navigateur, mais ça ne marche pas : une fonction déclarée dans un module JavaScript n'est pas accessible globalement dans window. Je n'en ai de toute façon pas besoin, j'ai tout ce qu'il faut pour refaire la requête à la main : l'adresse de l'API, la méthode HTTP et le format du JSON.
Envoyer le coup gagnant directement à l'API
J'envoie donc le coup a1 vers a8 directement au serveur depuis le terminal.
┌──(kali㉿kali)-[~]
└─$ curl -X POST http://10.129.165.26/api/move \
-H "Content-Type: application/json" \
-d '{"from":"a1","to":"a8"}'
{"ok":true,"move":"a1a8","fen":"R5k1/5ppp/8/8/8/8/5PPP/6K1 b - - 1 1","status":"checkmate","turn":"b","winner":"white","flag":"THM{*********************}"}
-H indique que le corps de la requête est en JSON et -d fournit les coordonnées.
Là, le navigateur n'est plus dans la boucle : la requête part du terminal et arrive directement sur le serveur. preMoveCheck ne s'exécute jamais, il n'y a donc aucune vérification côté navigateur sur mon coup et le serveur ne refait pas le contrôle de son côté. Il reçoit un coup valide et l'accepte.
Dans la réponse, ok vaut true, le coup est a1a8 et le statut est checkmate avec les blancs gagnants. Le serveur renvoie aussi le flag :
THM{*********************}
La restriction qu'on voyait dans l'interface n'existe donc que dans l'interface. Pas besoin de modifier le JavaScript ni de chercher un accès SSH.
Ce qu'il faut retenir
Ce qui est intéressant ici, c'est l'écart entre ce que l'interface permet et ce que l'API accepte. Le navigateur bloque le coup gagnant, mais une requête écrite à la main passe sans problème.
Une vérification côté client sert à guider l'utilisateur ou à lui éviter des erreurs, elle ne protège de rien. Si une règle doit vraiment être respectée, le serveur doit la contrôler lui-même avant d'accepter l'action. Ici il a suffi de lire le JavaScript pour comprendre le blocage et retrouver la requête qui permettait de le contourner.