siunam's Website

My personal website

Home Writeups Blog Projects About E-Portfolio

metaverse

Overview

Background

Author: aplet123

Metaenter the metaverse and metapost about metathings. All you have to metado is metaregister for a metaaccount and you’re good to metago.

metaverse.lac.tf

You can metause our fancy new metaadmin metabot to get the admin to metaview your metapost!

Enumeration

In this challenge, we can download a file:

┌[siunam♥earth]-(~/ctf/LA-CTF-2023/Web/metaverse)-[2023.02.11|12:21:45(HKT)]
└> file index.js
index.js: JavaScript source, ASCII text

But before we look at that JavaScript source code, let’s check out the home page:

Oof, it hurts my eyes.

Anyway, when we go to /, it redirects us to /login.

In here, we can login to meta, and register a meta account.

Let’s try to register an account:

As you can see, we can add a friend to metafriend, and create a new metapost.

We can try to add a friend:

In here, we see the username and display name is being reflected to the page.

Then, we can try to create a new post:

When we created a new post, it’ll open a new tab to /post/<uuid>, and append our new post to metapost list.

Now, we can look at the source code.

In line 1 - 5, we see this:

const express = require("express");
const path = require("path");
const fs = require("fs");
const cookieParser = require("cookie-parser");
const { v4: uuid } = require("uuid");

The web application is using Express as the back-end.

In line 7 - 9, we see this:

const flag = process.env.FLAG;
const port = parseInt(process.env.PORT) || 8080;
const adminpw = process.env.ADMINPW || "placeholder";

In here, we see the flag is in the machine’s environment variable. Then, the adminpw is from the environment variable, or “placeholder”.

In line 11 - 17, we see this:

const accounts = new Map();
accounts.set("admin", {
    password: adminpw,
    displayName: flag,
    posts: [],
    friends: [],
});

So, the displayname is the flag!

Armed with above information, our final goal is to get admin’s display name!

In line 24 - 38, we see this:

setInterval(() => {
    const now = Date.now();
    let i = cleanup.findIndex((x) => now < x[1]);
    if (i === -1) {
        i = cleanup.length;
    }
    for (let j = 0; j < i; j++) {
        const account = accounts.get(cleanup[i][0]);
        for (const post of account.posts) {
            posts.delete(post);
        }
        accounts.delete(cleanup[i][0]);
    }
    cleanup = cleanup.slice(i);
}, 1000 * 60);

Hmm… Every minute, it’ll cleanup account’s post?

In line 61 - 69, we see something weird:

// templating engines are for losers!
const postTemplate = fs.readFileSync(path.join(__dirname, "post.html"), "utf8");
app.get("/post/:id", (req, res) => {
    if (posts.has(req.params.id)) {
        res.type("text/html").send(postTemplate.replace("$CONTENT", () => posts.get(req.params.id)));
    } else {
        res.status(400).type("text/html").send(postTemplate.replace("$CONTENT", "post not found :("));
    }
});

The first line will read file from post.html, which is a post template file.

Then, in route (path) /post/<id>, if the request’s GET parameter id is supplied, replace the post content to the template one.

Hmm… I can smell some SSTI (Server-Side Template Injection) vulnerabilities!

In line 81 - 101, we see this:

app.post("/register", (req, res) => {
    if (typeof req.body.username !== "string" || typeof req.body.password !== "string" || typeof req.body.displayName !== "string") {
        res.redirect("/login#" + encodeURIComponent("Please metafill out all the metafields."));
        return;
    }
    const username = req.body.username.trim();
    const password = req.body.password.trim();
    const displayName = req.body.displayName.trim();
    if (!/^[\w]{3,32}$/.test(username) || !/^[-\w !@#$%^&*()+]{3,32}$/.test(password) || !/^[-\w ]{3,64}/.test(displayName)) {
        res.redirect("/login#" + encodeURIComponent("Invalid metavalues provided for metafields."));
        return;
    }
    if (accounts.has(username)) {
        res.redirect("/login#" + encodeURIComponent("Metaaccount already metaexists."));
        return;
    }
    accounts.set(username, { password, displayName, posts: [], friends: [] });
    cleanup.push([username, Date.now() + 1000 * 60 * 60 * 12]);
    res.cookie("login", `${username}:${password}`, { httpOnly: true });
    res.redirect("/");
});

In the /register route, when a POST request is sent, it’ll check the username, password, or displayName is a string or not.

Then, it checks the username has 3 - 32 character set of A-Za-z0-9_. password and displayName are similar to username.

After that, set a new cookie called login, with value username:password, and HttpOnly attribute is set to true.

In line 134 - 146, we see this:

app.post("/post", needsAuth, (req, res) => {
    res.type("text/plain");
    const id = uuid();
    const content = req.body.content;
    if (typeof content !== "string" || content.length > 1000 || content.length === 0) {
        res.status(400).send("Invalid metacontent");
    } else {
        const user = accounts.get(res.locals.user);
        posts.set(id, content);
        user.posts.push(id);
        res.send(id);
    }
});

In the /post route, when a POST request is sent, it’ll check the body content is a string data type, content length is greater than 1000, and equals to 0.

If not, then create a new post with a UUID value.

Armed with above information, we can try to get the flag!

Exploitation

In this challenge’s description, we can send a URL to the admin bot to view our metapost:

So, we need to find some client-side vulnerability, like XSS (Cross-Site Scripting), CSRF (Cross-Site Request Forgery). This would enable us to read the bot’s cookie.

However, remeber the cookie’s HttpOnly attribute is set to true? That being said, we can’t use the JavaScript document.cookie API to access the admin bot’s cookie:

Luckly, the flag is the admin bot’s display name, if we somehow leak it, we can get the flag!!

But how??

In the metaposts function, we don’t see any CSRF token and other CSRF protection, so theoretically we can craft an evil HTML page that’ll send the admin bot’s username upon visit.

Now, let’s try to inject some HTML code that’ll execute JavaScript code, which will pop up an alert box:

It worked in the /post/<uuid>!

Alright, let’s take a step back.

In /, we can add a friend to metafriends. However, when we add someone to metafriend, we won’t see him/her username and display name.

To see that, he/she MUST add a friend to us!

Armed with above information, we can craft a payload that when the admin bot visit, it’ll send a POST request to /friend, with parameter username=<your_username>. That way, we can see it’s display name!! This can happen is because it doesn’t have any CSRF protection!

Payload:

<script>
    // Wait the window is fully loaded
    window.onload = function (){
        var username = 'siunam';

        // Construct the require POST parameters
        var data = 'username=' + encodeURIComponent(username);

        // Add our username to friend list upon visit
        fetch('https://metaverse.lac.tf/friend',
            {
                method: 'POST',
                mode: 'no-cors',
                body: data,
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded",
                }
            }
        )
    };
</script>

Let’s copy and paste that to the metapost, and create it!

Then, copy the URL, and send to the admin bot:

Finally, we should see the admin bot added to our friend list!

Nice!! We got the flag!

Conclusion

What we’ve learned:

  1. Leveraging Stored XSS To Perform CSRF attack