July 4, 2026
Fool’s Mate: Beating a Chess Engine That Cheats — A TryHackMe Walkthrough
https://tryhackme.com/room/foolsmate

By ghosteye
5 min read
Introduction
Not every hack requires a fancy exploit chain. Sometimes the vulnerability is hiding in plain sight — in this case, inside a chess engine that argues with you about what checkmate is.
Fool's Mate on TryHackMe presents a simple premise: you're given a browser-based chess app showing a position labeled "Mate in one — White to move." There's an obvious mating move sitting right there on the board. But every time you try to play it, the app throws up a fake system warning and refuses to let the move go through.
This writeup covers how I found the flaw, read through the app's source, and got the "impossible" move to register.
The starting position — White to move, mate in one available.
Step 1: Recon — Poking at the App
The room spins up a simple web app called Endgame Trainer. It's a single-page chess board built with vanilla JS, backed by a small API for validating and applying moves.
The first thing I did was try to play the obvious mating move directly on the board.
Instead of registering the win, the app popped up a scare-tactic modal:
A classic misdirection — bluffing the player away from the correct move.
Clearly, this wasn't a real system warning. It was app logic actively trying to stop me from making a legal move. That told me the bug almost certainly lived in the client-side JavaScript, not in the actual chess rules.
Step 2: Reading the Source
I opened dev tools and pulled up the page source, then went looking for the JS files powering the board.
The app was built on two files:
vendor/chess.js— the well-known, open-sourcechess.jslibrary. This handles the actual rules of chess: legal move generation, check detection, checkmate detection, etc. A quick skim confirmed it was unmodified — a legitimate, trustworthy rules engine.js/app.js— the app's own custom wrapper around that library. This is where the UI logic, move submission, and (as it turned out) the actual vulnerability lived.
Digging through app.js, one function stood out immediately: 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;
}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;
}And here's how it was used, in the doMove() function that runs whenever you try to make a move on the board:
function doMove(from, to) {
if (!isLegalTarget(from, to)) return false;
const promotion = needsPromotion(from, to) ? 'q' : undefined;
if (!preMoveCheck(from, to, promotion)) {
setElPos(els[from], from, true);
return true;
}
toast(SMUG[Math.floor(Math.random() * SMUG.length)]);
sendMove(from, to, promotion);
return true;
}function doMove(from, to) {
if (!isLegalTarget(from, to)) return false;
const promotion = needsPromotion(from, to) ? 'q' : undefined;
if (!preMoveCheck(from, to, promotion)) {
setElPos(els[from], from, true);
return true;
}
toast(SMUG[Math.floor(Math.random() * SMUG.length)]);
sendMove(from, to, promotion);
return true;
}Step 3: Spotting the Vulnerability
Reading this closely, the logic broke down like this:
- Whenever you try to make a move, the app first simulates it locally, on a throwaway copy of the game (
probe), using the legitimatechess.jslibrary. - If that simulated move results in checkmate, the app shows the fake warning and stops — it never even attempts to send the move to the server.
- Only if the move is not checkmate does the app proceed to
sendMove(), which fires a realfetch()request to a/api/moveendpoint on the server.
In other words: the checkmate block is a purely cosmetic, client-side gate. The server-side /api/move endpoint has no idea this check exists — it just accepts whatever { from, to, promotion } payload gets POSTed to it. The client was essentially lying to me about my own move, then refusing to press "send" on my behalf.
Since I control the browser, I don't have to use the client's own "send" button. I can just talk to the server directly.
The client-side gate that blocks the winning move — but only on the client.
Step 4: Exploiting It
The plan was simple: skip the UI entirely and POST the mating move straight to the server via the browser console.
First, I reset the board to make sure I was working from the known starting FEN:
6k1/5ppp/8/8/8/8/5PPP/R5K1 w - - 0 16k1/5ppp/8/8/8/8/5PPP/R5K1 w - - 0 1This position has Black's king boxed in on g8 by its own pawns (f7, g7, h7), with White's rook sitting on a1. The mate-in-one is straightforward: Ra1–a8#, delivering check along the back rank with no legal escape square for the king.
I opened the browser console (F12 → Console tab) and ran:
fetch('/api/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'a1', to: 'a8' })
}).then(r => r.json()).then(console.log)fetch('/api/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'a1', to: 'a8' })
}).then(r => r.json()).then(console.log)A couple of small mistakes along the way (worth flagging for anyone following along):
- Case sensitivity matters. My first attempt used
Headersinstead ofheaders—fetch()silently ignores unrecognized option keys, so the request body never got sent as JSON, and the server correctly rejected it with"from and to are required". - State can drift. After some earlier experimentation, the server's game state had moved on from the starting position (confirmed by the
fenfield in the error response), so mya1 → a8request came back as"illegal move"— the rook wasn't on a1 anymore. Resetting the position via the "Reset position" button and immediately firing the request afterward solved this.
Once the request matched the server's actual state, it went through cleanly.
Bypassing the client-side gate — straight to the server.
Step 5: Capturing the Flag
The server's JSON response confirmed the move was accepted and returned the flag:
THM{cl13nt_s1d3_ch3ckm4t3}THM{cl13nt_s1d3_ch3ckm4t3}Key Takeaways
This room is a neat, compact illustration of a principle that shows up constantly in real-world web app security:
Never trust the client. Validate everything server-side.
The app's developer built a perfectly legitimate chess engine (chess.js) and then undermined it by putting a critical business-logic check — "is this the winning move?" — entirely in client-side JavaScript. Anyone with browser dev tools can:
- Read that logic
- Understand exactly what it's blocking and why
- Bypass the UI entirely and talk directly to the API
The lesson generalizes far beyond chess: purchase price validation, permission checks, rate limits, "you can't do that" business rules — any of these implemented only in client-side JS are trivially bypassable. Client-side checks are fine for user experience (instant feedback, disabling buttons, etc.), but they must never be the only enforcement layer. The server has to independently re-validate every rule that actually matters.
Tools Used
- Browser Developer Tools (Console, Sources, Network tabs)
- Manual JavaScript source review
fetch()API for direct HTTP requests
Thanks for reading! If you're working through TryHackMe's easier web rooms, Fool's Mate is a great quick one for practicing source review and understanding why client-side validation alone is never enough.