Introduction
When you see a callback control in a JSONP endpoint doesn't that make you want to execute XSS? But, there's always this "text/javascript" (or similar) content-type that stands in the way.
During BlackHat 2014 I presented Same Origin Method Execution (SOME) attack. This talk explains how to abuse callback endpoints to execute javascript methods in a vulnerable domain.
Nowadays, finding vulnerable callback endpoints got harder as passive content-types dominate the web. While the vulnerable, active content-types like "text/html", "Adobe Flash" or "ActiveX plugins" are less common. In the second SOME talk during HackInTheBox 2017, I shared the hint of my ongoing research:
"JSONP is NOT vulnerable without a chain"
(slide 16, under "What are the vulnerable endpoints?")
As I was always "too busy", on Jun 7, 2020, @kinugawamasato released the following tweet:
I created a new XSS challenge! Can you solve it? https://t.co/reTuO3oxjE
— Masato Kinugawa (@kinugawamasato) June 7, 2020
His callback challenge was similar and inspired me to share my research in the form of a my own XSS challenge.
The Challenge
The challenge goal was: chaining HPP to SOME, then bypass a strict CSP policy and get arbitrary cross-site scripting.
For almost 2 months (Jun 18, 2020 - Aug 14, 2020) only 4 players solved it. Even though, on July 1th, Masato released the solution to his challenge.
The following is a the original tweet:
XSS Challenge is out! Try to trigger alert(𝒏𝒐𝒏𝒄𝒆) in https://t.co/R2ntR1YVHV
— Ben Hayak (@BenHayak) June 18, 2020
Good luck! https://t.co/5CKzARqkZj
Please DM me if you find any real bugs. Enjoy!
Challenge Structure
- index.html - Static Page
- connect.php - JSONP endpoint
- purify.js - Cure53's DOMPurify
Entry points
- "client_id" - URL parameter
- client_id - URL parameter - Limited control of up to 38 characters (<= 38)
- callback - URL parameter:
- Limited to classic callback characters:
[a-z0-9.]+/i
- Blocked the string "write" to avoid using "document.write"
CSP Policy
The script-src directive only allow scripts with a valid nonce (I did not use a random nonce but players were instructed to assume it's dynamic for every page load).
The Challenge Workflow
- Calls "init();" to insert items from the rules array which is defined on the global scope.
- Calls "connect(client_id)" to trigger the JSONP endpoint at "connect.php" while appending client_id as a parameter.
- Listen to JSONP which calls back with "callback(response, status)".
- Render the response as HTML safely using Purify's sanitize function.
Unintended Solution
Slightly after I uploaded the challenge it was possible to skip the harder steps of the challenge as I forgot to include "base-uri 'none'" - that was quickly fixed.
The Solution
To solve the challenge one had to complete several steps:
- Control the method execution using a callback (SOME attack).
- Abuse the lack of X-Frame-Options to obtain multiple method execution.
- Inject arbitrary HTML limited to 38 characters.
- Bypass CSP to execute XSS while abusing "strict-dynamic".
- Navigating the DOM tree to overcome character limits.
During my talk in 2017, I already published the instructions for solving steps 1-2 and step 5 (slide 34-43) as part of SOME white paper and slides.
Before we dig into the details the following is the challenge solution:
Here is a simplified version of the solution:
top.frame.rules.push("Error: '<iframe srcdoc='<script></script>'>'","406: Not Acceptable")
top.frame.init("Error: 'x'","406: Not Acceptable")
top.frame.itemsList.lastElementChild.previousSibling.previousSibling.previousSibling.firstElementChild.contentWindow.document.head.firstElementChild.append("Error: '';parent.x=parent.document.scripts//'","406: Not Acceptable")
top.frame.itemsList.lastElementChild.previousSibling.lastElementChild.contentWindow.document.head.firstElementChild.append("Error: '';alert(parent.x[0].nonce)//'","406: Not Acceptable")
Solvers🎉
Thankfully 4 of the players were above all others and solved this challenge relatively fast! All with the intended solution, though some used shorter and cleaner code to solve.
The first to solve was @kinugawamasato the great, followed by 3 other fantastic players: Roman
Shafigullin (@shafigullin), terjanq (@terjanq) and Luan Herrera (@lbherrera_)
Deep Dive
Method Execution via HTTP Parameter Pollution
☑️ Method execution can quite simply be controlled by abusing HPP.
Reviewing the javascript code of the main challange page (Specifically the 'DOMContentLoaded' event listener) reveals that it accepts the search parameter: 'client_id'. This parameter is later used by the 'connect' function to append a script to the page.
Injecting "%26callback=alert" to this 'client_id' parameter allows us to overwrite the appended script url ("connect.php" endpoint) callback parameter and therefore get control over the method to execute.
This however will only execute an alert, which will not allow stealing the CSP nonce or executing arbitrary XSS.
Multiple Method Execution
Multiple Method Execution is all about constructing a gadget and reusing existing code.
- Setting up 5-6 windows (iframes) based on the amount of methods we want to execute on the challenge page.
- Abusing the JSONP endpoint with designated callback parameter by navigating each window context.
- Controlling the execution order.
I've described the impact of executing multiple methods using SOME and how it can be as bad or nearly as bad as XSS throughout the SOME white-paper.
Pushing 38 Bytes of Arbitrary HTML
The first real step of the challenge was to "plant" HTML code into a globally defined array named "rules". This could be achieved using the native Array.push function:
top.frame.rules.push("Error: '<iframe srcdoc='<script></script>'>'","406: Not Acceptable")
Once we have arbitrary HTML in "rules", it is possible to use the JSONP endpoint again, this time to execute the "init()" method defined in a whitelisted script. The code at "init" uses innerHTML to inject the rules array items along with the smuggled 38 bytes of HTML payload!
Injecting the HTML:top.frame.init("Error: 'x'","406: Not Acceptable")
We now finally have arbitrary HTML injection!
☑️ HTML injection (of 38 bytes)
Bypassing Content Security Policy
The server was setup to block anything but allowed scripts using the following CSP directives:
Reading the policy, one can spot a bold hint I left there, that is the strict-dynamic directive.
This step made quite some players struggle, as it was about abusing strict-dynamic to make a non-"parser-inserted" script element execute javascript code.
What is strict-dynamic?
A simple explanation can be found in content-security-policy.com:
The key super power of strict-dynamic is that it will allow whitelisted scripts to load additional scripts via non-"parser-inserted" script elements.
So how do you create a non-"parser-inserted" script element? Here's an example:
var s = document.createElement('script'); s.src = "https://cdn.example.com/some-script-you-need.min.js"; document.body.appendChild(s);
That is a great, but at this point in the challenge executing such code is impossible.
Yet, is that the only way we can create non-parser inserted scripts?Empty Script Nodes
If we carefully read the HTML5 spec we can notice the following:
"A script element has a parser document, which is either null or a Document. Initially, its value must be null. It is set by the HTML parser and the XML parser on script elements they insert, and affects the processing of those elements. script elements with non-null parser documents are known as "parser-inserted"."
So if the script tag has content, it’s already considered as “parser inserted”.
Creating Scripts with null parser documents
The key point players had to figure out is that script tags's parser documents are initially null ("Initially, its value must be null") and therefore, an empty script has a null parser document and will not be executed by the html parser.
Here how to abuse this for solving the challenge:
<iframe srcdoc='<script></script>'></iframe>
Notice this payload is 44 bytes (>38) and is blocked by "connect.php" length limit. Luckily, we can drop the "</iframe>" and the browser will "guess" it needs to close the tag for us. Once we have a non-parser inserted script it is now finally possible to abuse strict-dynamic.
☑️ non-parser inserted scripts
XSS via Node.append
The final part in the challenge can be broken down into 2 main steps
- Navigate the DOM to obtain a reference to the empty script, and
- Find a method that allows adding content into the empty non-"parser inserted" script tag.
All there's left to do is to construct a valid javascript code and append it to the script node:
top.frame.itemsList.lastElementChild.previousSibling.previousSibling.previousSibling.firstElementChild.contentWindow.document.head.firstElementChild
// Returns a refernce to the injected <script> Node
scriptNode.append("Error: '';alert(1337)//'","...")
// Adds content to the script tag and executes
☑️ Bypassing CSP for arbitrary XSS
Run the SolutionThis writeup took longer than expected but I hope you enjoy it as much as I enjoyed writing it.
Thanks you all who played!