Summary
Endgame Trainer is a small web app that presents a "mate-in-one" chess puzzle. The board is legit (built on the chess.js library), but the app has a gimmick: if you actually play the winning move, a fake system dialog pops up threatening to "shut down your PC" instead of letting you win. That block, however, only exists in the front-end JavaScript. The move itself is validated and executed by a backend API endpoint (/api/move) that has no idea the UI is supposed to be stopping you. Sending the winning move (a1a8, delivering checkmate) straight to the API with curl skips the client-side gate entirely and the server hands back the flag in its JSON response. Along the way, a relative-path request also pulled the raw chess.js source out from a directory that wasn't meant to be reachable, confirming exactly how the client-side move validation worked and that it could be bypassed server-side.
Box IP referred to below as machine-ip.
Recon
Nmap
nmap -A -Pn machine-ip -o nmap
Nmap output
Starting Nmap 7.98 ( https://nmap.org ) at 2026-07-21 11:56 -0400
Nmap scan report for machine-ip
Host is up (0.038s latency).
Not shown: 998 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 a8:1e:0e:a0:5f:74:77:25:d5:0f:11:98:56:9e:b0:ed (ECDSA)
|_ 256 dd:84:d1:e8:ee:c2:69:30:cb:0c:1a:43:58:d4:45:d1 (ED25519)
80/tcp open http Node.js Express framework
|_http-title: "Endgame Trainer"
No exact OS matches for host (If you know what OS is running on it, see https://nmap.org/submit/ ).
Network Distance: 3 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
TRACEROUTE (using port 21/tcp)
HOP RTT ADDRESS
1 37.15 ms 192.168.128.1
2 ...
3 38.04 ms machine-ip
Nmap done: 1 IP address (1 host up) scanned in 24.49 seconds
Two open ports: SSH (22, not the focus here) and an Express-based HTTP service on port 80 titled "Endgame Trainer."
Checking out the web app
curl http://machine-ip/
Page source
<title>Endgame Trainer</title>
<span>♜</span>
<span>Endgame<span>Trainer</span></span>
Mate-in-one · White to move
<span id="turnDot"></span>
<span id="statusText">White to move</span>
...
<span id="winTitle">/usr/lib32</span>
<span><span>×</span></span>
I'll shut down your PC if you play that.
OK
The page is a chessboard UI starting from a mate-in-one position (6k1/5ppp/8/8/8/8/5PPP/R5K1 w - - 0 1, i.e. White rook on a1, Black king boxed in on g8). It's framed as a joke: try to deliver the actual mate and a fake "system" dialog threatens to shut down your PC instead of showing a win. That's the hint that the "you can't play that" logic is happening somewhere it shouldn't be trusted - the client.
Reading the client logic
curl http://machine-ip/js/app.js
Key excerpt from app.js
import { Chess } from '../vendor/chess.js';
const START_FEN = '6k1/5ppp/8/8/8/8/5PPP/R5K1 w - - 0 1';
...
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 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;
}
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) { ... }
...
}
This is the whole game right here. preMoveCheck() runs a local, throwaway copy of the chess engine (probe) against the move the user is trying to make. If that probe results in checkmate, the front end just refuses to call sendMove() and shows the fake "shut down your PC" popup instead. It never touches the server. The actual move submission happens over fetch('/api/move', ...), a plain POST with from/to/promotion in JSON - nothing here that couldn't be replayed directly with curl.
Confirming the library and a stray path traversal
curl http://machine-ip/../vendor/chess.js
Excerpt from chess.js
/**
* @license
* Copyright (c) 2025, Jeff Hlywa (jhlywa@gmail.com)
* ...
*/
export const WHITE = 'w'