DEV Community

Davi
Davi

Posted on Originally published at blog.mago.team

Server-Side Prototype Pollution: From JSON Body to RCE via Node.js Gadget Chains

Server-Side Prototype Pollution: From JSON Body to RCE via Node.js Gadget Chains

Report H1 #1631258 documents RCE in Rocket.Chat with CVSS 8.8. No XSS. No DOM. A field with __proto__ as the key, processed by a recursive merge on the server, corrupted Object.prototype across the entire Node.js process.

Prototype pollution guides teach the client-side vector: payload via URL parameter, DOM reflection, XSS output. Server-side prototype pollution (SSPP) is a distinct class. The payload arrives via JSON body. The contamination is process-wide and not limited to a single request. The path to RCE runs through gadget chains in the Node.js core. The detection problem is also different: no DOM, no client-side reflection, and a destructive payload takes down the entire production server.

Why JSON.parse Does Not Pollute: The Most Common Myth

JSON.parse('{"__proto__": {"admin": true}}') does not pollute Object.prototype. The result is a JavaScript object with a literal string property named "__proto__" as an own property. The prototype chain is not modified: behavior specified in ES2015+.

Pollution occurs in the code that consumes the parsed object. The downstream function that executes target[key] = source[key] without checking hasOwnProperty writes to Object.prototype when key is "__proto__". Object.assign() also does not pollute: it copies only own enumerable properties, treating __proto__ as an ordinary string key.

The classic case is lodash.merge prior to 4.17.11. The function iterates source keys with for...in, which includes "__proto__". When it executes target["__proto__"] = recurse(source["__proto__"]), it writes directly to Object.prototype via bracket-notation with no guard.

CVE-2024-21505 (web3-utils prior to 4.2.1, CVSS 7.5) demonstrates the pattern in a custom implementation: the mergeDeep function called Object.defineProperty directly on __proto__ without a hasOwnProperty check. CVE-2023-26136 (tough-cookie prior to 4.1.3, CVSS 9.8) is the most severe case: the internal initialization of CookieJar processed cookies with __proto__ as a key name via merge with prototype linkage. The vulnerable surface was initialization, not parsing.

The implication for testing is direct: JSON.parse is not a defense. The relevant question is whether the parsed object is consumed by a recursive merge downstream without a hasOwnProperty guard.

The Mechanism: Process-Wide Contamination, Not Per-Request

Object.prototype is shared across the entire Node.js process. A successful pollution persists until the process restarts. There is no isolation per request, per session, or per user.

{"__proto__": {"admin": true}}
Enter fullscreen mode Exit fullscreen mode

After this payload is processed by a vulnerable merge, ({}).admin === true at any point in the process, for any subsequent request from any user. The client-side scope is the context of a browser tab. The server-side scope is the entire process with all its concurrent users.

CVE-2023-23917 (Rocket.Chat prior to 5.2.0, CVSS 8.8) materialized this mechanism in production. Any registered user sent a JSON body with __proto__ to an endpoint with a vulnerable recursive merge. The process's Object.prototype was corrupted. Subsequent operations by any other user inherited the injected properties, enabling administrative access without additional authentication. Report H1 #1631258 confirmed impact on cloud infrastructure, with low complexity and no user interaction required per CVSS.

CVE-2023-30581 (Node.js 16, 18, and 20, CVSS 7.3) demonstrates that the problem reaches the runtime itself. __proto__ in process.mainModule bypasses Node.js's policy.json mechanism. The runtime's security boundary is broken via prototype pollution, not via application code vulnerability. When __proto__ is filtered server-side, constructor.prototype is the alternative path: {"constructor": {"prototype": {"admin": true}}} accesses the same Object.prototype via a different chain with identical effect.

Non-Destructive Probe: Three Properties That Produce an HTTP Signal

The constraint of testing in production eliminates the usual approaches. No DOM, no client-side reflection. A payload that crashes the process generates downtime and an immediate alert in the observability system. The goal is an observable HTTP side-effect without altering business state for concurrent users.

Gareth Heyes (PortSwigger Research, February 2023) identified three Express properties that satisfy this constraint.

Probe 1: json spaces

{"__proto__": {"json spaces": 9}}
Enter fullscreen mode Exit fullscreen mode

Express reads json spaces from Object.prototype when serializing via res.json(). The response changes from compact JSON to JSON indented with 9 spaces. Compare the response body bytes before and after the payload: no business state change, no cascade to other users.

Probe 2: exposedHeaders

{"__proto__": {"exposedHeaders": ["X-PP-Confirmed"]}}
Enter fullscreen mode Exit fullscreen mode

The cors module reads exposedHeaders from Object.prototype when building the CORS response. Send the request with an Origin header. The value X-PP-Confirmed appears in Access-Control-Expose-Headers on any subsequent response from any CORS endpoint in the process.

Probe 3: status

{"__proto__": {"status": 510}}
Enter fullscreen mode Exit fullscreen mode

The http-errors module reads status from Object.prototype. Any error path that does not explicitly define a status returns HTTP 510 instead of 500. Trigger a known error path in the application and compare the response code.

All three properties operate at the framework layer, not in business logic. The side-effects are infrastructural, observable via HTTP, and reversed when the process restarts. Pollution confirmed by any one of the three is sufficient to proceed to gadget enumeration.

Gadget Chains: From Object.prototype to RCE in the Node.js Core

Silent Spring (USENIX Security 2023, Shcherbakov, Balliu, and Staicu) identified 11 universal gadgets in Node.js core APIs. RCE was demonstrated in the NPM CLI, Parse Server, and Rocket.Chat without lodash and without any additional third-party library as a gadget requirement.

EJS outputFunctionName gadget (most reliable in production):

Object.prototype.outputFunctionName = "x; require('child_process').execSync('id'); x"
Enter fullscreen mode Exit fullscreen mode

EJS concatenates outputFunctionName into the compiled template string before eval(). The injected code executes in the context of the Node.js process with the process's permissions. Any Express app using EJS as the view engine is vulnerable, and EJS is the default in projects generated by express-generator.

child_process.fork + execArgv gadget:

Object.prototype.execArgv = ["--eval=require('child_process').execSync('id').toString()"]
Enter fullscreen mode Exit fullscreen mode

child_process.fork() inherits execArgv from Object.prototype when not explicitly specified at the call site. Pure Node.js core, zero external dependencies, active in any process that uses fork for workers or subprocesses.

lodash.template + sourceURL gadget (H1 #852613, Kibana):

Object.prototype.sourceURL = "}\nconsole.log(require('child_process').execSync('id'))//"
Enter fullscreen mode Exit fullscreen mode

lodash.template concatenates sourceURL into the template string before eval(). The } closes the template wrapper, injecting arbitrary code. The pollution surface and the RCE gadget are in distinct code paths: the typical pattern in real applications where the vulnerable merge and the template engine are separate components of the same codebase.

The KTH-LangSec Research Group catalogs over 60 npm packages with documented gadget chains. forever-monitor, workerpool, cross-spawn, and chrome-launcher are on the list alongside the major template engines. The presence of these packages as transitive dependencies extends the surface beyond the project's direct dependencies.

Prioritization: Express + body-parser as the Default Target

The standard Express stack combines the entry vector, the probe surface, and the RCE gadget in the same set of dependencies installed by default. body-parser with extended: true uses qs.parse, which accepts __proto__ in query strings in addition to JSON bodies. The json spaces probe works via GET: /?__proto__[json+spaces]=9.

The test sequence for a Node.js/Express target follows increasing invasiveness:

  1. Identify endpoints accepting JSON body via Content-Type: application/json
  2. Send the json spaces probe and compare response body indentation
  3. If positive: confirm with the exposedHeaders probe via CORS request
  4. Confirm the gadget: send outputFunctionName with an id payload if EJS is present, or execArgv if fork is called in the process

Stack identification precedes testing. The X-Powered-By: Express header confirms Express. Route fingerprints and response body patterns confirm EJS versus other view engines. The tech_detector tool (MAGO team tool) identifies the Node.js/Express stack and template engine from headers and response patterns before the first probe.

Prototype pollution is not a bug in a specific library. It is a property of the language that any recursive merge without a hasOwnProperty guard can activate. The gadgets that turn pollution into RCE are already in the Node.js core. The three properties that confirm the vulnerability without taking down the process are documented and work today.

Top comments (0)