HackTheBox - Challenges Breaking Grad

01/11/2023 - 2 minutes

challenges hacking hackthebox inheritance javascript nodejs prototype_pollution web

Table of contents

Enumeration

Source Code

package.json

{
	"name": "breaking-grad",
	"version": "1.0.0",
	"description": "",
	"main": "index.js",
	"nodeVersion": "v12.18.1",
	"scripts": {
		"start": "node index.js",
		"dev": "nodemon .",
		"test": "echo \"Error: no test specified\" && exit 1"
	},
	"keywords": [],
	"authors": [
		"makelaris",
		"makelarisjr"
	],
	"dependencies": {
		"body-parser": "^1.19.0",
		"express": "^4.17.1",
		"randomatic": "^3.1.1"
	}
}

A file that particularly stands out is

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})`);
}

This means that something very specific is happening in node v12.18.1

Another thing that stands out is that the word flag is nowhere to be found in the code, meaning that we must gain RCE to solve this challenge

Endpoints routes/index.js

The app has the below endpoints

router.get('/', (req, res) => {
    return res.sendFile(path.resolve('views/index.html'));
});

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'
    });
});

ObjectHelper.js

But the cherry on top that gives it out is 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);
    }
}

This is a classic prototype pollution vulnerability with the added caveat that the proto key itself is disabled making exploitation a bit harder

Exploitation

Javascript is a prototype based language, meaning every object inherits some base classes that are a static, global, object. This means that if we cam modify the prototype element, we can modify all other objects using that prototype.

let student = ObjectHelper.clone(req.body);

This creates a clone of the object we send in our request using ObjectHelper.js where the vulnerability lies

Prototype Pollution

According to hacktricks ![[assets/image_1_33426e07.png]] The above means that if we manage to get a sucessful merge and set the constructor.prototype we can affect ALL js objects

Hmm what is our target then?

Well the debughelper is there waiting and asking to be abused, so lets have a look

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');
    }
}

We cant do anything with execSync as there isn't really an option to change the command being executed, because even if we write the command attribute then it will simply be overwritten by free -m at the time of execution

However for fork we can set execPath and execArgv to change the binaries being executed Meaning that we probably want execPath to be /bin/bash and execArgv to be a list of the rest of the arguments to read the flag

Final payload

And then once we visit /debug/version we receive the flag!