Yummy cookies across domains

Last Friday we announced and performed a migration of all GitHub Pages to their own github.io domain. This was a long-planned migration, with the specific goal of mitigating phishing attacks and cross-domain cookie vulnerabilities arising from hosting custom user content in a subdomain of our main website.

There’s been, however, some confusion regarding the implications and impact of these cross-domain cookie attacks. We hope this technical blog post will help clear things up.

When you log in on GitHub.com, we set a session cookie through the HTTP headers of the response. This cookie contains the session data that uniquely identifies you:

Set-Cookie: _session=THIS_IS_A_SESSION_TOKEN; path=/; expires=Sun, 01-Jan-2023 00:00:00 GMT; secure; HttpOnly

The session cookies that GitHub sends to web browsers are set on the default domain (github.com), which means they are not accessible from any subdomain at *.github.com. We also specify the HttpOnly attribute, which means they cannot be read through the document.cookie JavaScript API. Lastly, we specify the Secure attribute, which means that they will only be transferred through HTTPS.

Hence, it’s never been possible to read or “steal” session cookies from a GitHub Pages hosted site. Session cookies are simply not accessible from the user code running in GitHub Pages, but because of the way web browsers send cookies in HTTP requests, it was possible to “throw” cookies from a GitHub Pages site to the GitHub parent domain.

When the web browser performs an HTTP request, it sends the matching cookies for the URL in a single Cookie: header, as key-value pairs. Only the cookies that match the request URL will be sent. For example, when performing a request to github.com, a cookie set for the domain github.io will not be sent, but a cookie set for .github.com will.

GET / HTTP/1.1
Host: github.com
Cookie: logged_in=yes; _session=THIS_IS_A_SESSION_TOKEN;

Cookie tossing issues arise from the fact that the Cookie header only contains the name and value for each of the cookies, and none of the extra information with which the cookies were set, such as the Path or Domain.

The most straightforward cookie-tossing attack would have involved using the document.cookie JavaScript API to set a _session cookie on a GitHub Pages hosted website. Given that the website was hosted under *.github.com, this cookie would have been sent to all requests to the parent domain, despite the fact it was set in a subdomain.

/* set a cookie in the .github.com subdomain */
document.cookie = "_session=EVIL_SESSION_TOKEN; Path=/; Domain=.github.com"
GET / HTTP/1.1
Cookie: logged_in=yes; _session=EVIL_SESSION_TOKEN; _session=THIS_IS_A_SESSION_TOKEN;
Host: github.com

In this example, the cookie set through JavaScript in the subdomain is sent next to the legitimate cookie set in the parent domain, and there is no way to tell which one is coming from where given that the Domain, Path, Secure and HttpOnly attributes are not sent to the server.

This is a big issue for most web servers, because the ordering of the cookies set in a domain and in its subdomains is not specified by RFC 6265, and web browsers can choose to send them in any order they please.

In the case of Rack, the web server interface that powers Rails and Sinatra, amongst others, cookie parsing happens as follows:

def cookies
  hash = {}
  cookies = Utils.parse_query(cookie_header, ';,')
  cookies.each { |k,v| hash[k] = Array === v ? v.first : v }
  hash
end

If there is more than one cookie with the same name in the Cookie: header, the first one will be arbitrarily assumed to be the value of the cookie.

This is a very well-known attack: A couple weeks ago, security researcher Egor Homakov blogged about a proof-of-concept attack just like this one. The impact of the vulnerability was not critical (CSRF tokens get reset after each log-in, so they cannot be permanently fixated), but it’s a very practical example that people could easily reproduce to log out users and be generally annoying. This forced us to rush our migration of GitHub Pages to their own domain, but left us with a few weeks’ gap (until the migration was complete), during which we had to mitigate the disclosed attack vector.

Fortunately, the style of the disclosed attack was simple enough to mitigate on the server side. We anticipated, however, several other attacks that were either trickier to stop, or simply impossible. Let’s take a look at them.

The first step was mitigating the attack vector of simple cooking tossing. Again, this attack exploits the fact that web browsers will send two cookie tokens with the same name without letting us know the domain in which they were actually set.

We cannot see where each cookie is coming from, but if we skip the cookie parsing of Rack, we can see whether any given request has two duplicate _session cookies. The only possible cause for this is that somebody is attempting to throw cookies from a subdomain, so instead of trying to guess which cookie is legitimate and which cookie is being tossed, we simply instruct the web browser to drop the cookie set in the subdomain before proceeding.

To accomplish this, we craft a very specific response: we instruct the web browser to redirect to the same URL that was just requested, but with a Set-Cookie header that drops the subdomain cookie.

GET /libgit2/libgit2 HTTP/1.1
Host: github.com
Cookie: logged_in=yes; _session=EVIL_SESSION_TOKEN; _session=THIS_IS_A_SESSION_TOKEN;
HTTP/1.1 302 Found
Location: /libgit2/libgit2
Content-Type: text/html
Set-Cookie: _session=; Expires=Thu, 01-Jan-1970 00:00:01 GMT; Path=/; Domain=.github.com;

We decided to implement this as a Rack middleware. This way the cookie check and consequent redirect could be performed before the application code gets to run.

When the Rack middleware triggers, the redirect will happen transparently without the user noticing, and the second request will contain only one _session cookie: the legitimate one.

This “hack” is enough to mitigate the straightforward cookie tossing attack that most people would attempt, but there are more complex attacks that we also need to consider.

If the malicious cookie is set for a specific path which is not the root (e.g. /notifications) the web browser will send that cookie when the user visits github.com/notifications, and when we try to clear it in the root path, our header will have no effect.

document.cookie = "_session=EVIL_SESSION_TOKEN; Path=/notifications; Domain=.github.com"
GET /notifications HTTP/1.1
Host: github.com
Cookie: logged_in=yes; _session=EVIL_SESSION_TOKEN; _session=THIS_IS_A_SESSION_TOKEN;
HTTP/1.1 302 Found
Location: /notifications
Content-Type: text/html
# This header has no effect; the _session cookie was set
# with `Path=/notifications` and won't be cleared by this,
# causing an infinite redirect loop
Set-Cookie: _session=; Expires=Thu, 01-Jan-1970 00:00:01 GMT; Path=/; Domain=.github.com;

The solution is pretty straightforward, albeit rather inelegant: for any given request URL, the web browser would only send a malicious JavaScript cookie if its Path matches partially the path of the request URL. Hence, we only need to attempt to drop the cookie once in each component of the path:

HTTP/1.1 302 Found
Location: /libgit2/libgit2/pull/1457
Content-Type: text/html
Set-Cookie: _session=; Expires=Thu, 01-Jan-1970 00:00:01 GMT; Path=/; Domain=.github.com;
Set-Cookie: _session=; Expires=Thu, 01-Jan-1970 00:00:01 GMT; Path=/libgit2; Domain=.github.com;
Set-Cookie: _session=; Expires=Thu, 01-Jan-1970 00:00:01 GMT; Path=/libgit2/libgit2; Domain=.github.com;
Set-Cookie: _session=; Expires=Thu, 01-Jan-1970 00:00:01 GMT; Path=/libgit2/libgit2/pull; Domain=.github.com;
Set-Cookie: _session=; Expires=Thu, 01-Jan-1970 00:00:01 GMT; Path=/libgit2/libgit2/pull/1457; Domain=.github.com;

Again, we’re blind on the server-side when it comes to cookies. Our only option is this brute-force approach to clearing the cookies, which despite its roughness, worked surprisingly well while we completed the github.io migration.

Let’s step up our game: Another attack can be performed by exploiting the fact that RFC 6265 doesn’t specify an escaping behavior for cookies. Most web servers/interfaces, including Rack, assume that cookie names can be URL-encoded (which is a rather sane assumption to make, if they contain non-ASCII characters), and hence will unescape them when generating the cookie list:

cookies = Utils.parse_query(string, ';,') { |s| Rack::Utils.unescape(s) rescue s }

This allows a malicious user to set a cookie that the web framework will interpret as _session despite the fact that its name in the web browser is not _session. The attack simply has to escape characters that don’t necessarily need to be escaped:

GET / HTTP/1.1
Host: github.com
Cookie: logged_in=yes; _session=chocolate-cookie; _%73ession=bad-cookie;
{
  "_session" : ["chocolate-cookie", "bad-cookie"]
}

If we try to drop the second cookie from the list of cookies that Rack generated, our header will have no effect. We’ve lost crucial information after Rack’s parsing: the fact that the name of the cookie was URL-encoded to a different value than the one our web framework received.

# This header has no effect: the cookie in
# the browser is actually named `_%73ession`
Set-Cookie: _session=; Expires=Thu, 01-Jan-1970 00:00:01 GMT; Path=/; Domain=.github.com;

To work around this, we had to skip Rack’s cookie parsing by disabling the unescaping and finding all the cookie names that would match our target after unescaping.

  cookie_pairs = Rack::Utils.parse_query(cookies, ';,') { |s| s }
  cookie_pairs.each do |k, v|
    if k == '_session' && Array === v
      bad_cookies << k
    elsif k != '_session' && Rack::Utils.unescape(k) == '_session'
      bad_cookies << k
    end
  end

This way we can actually drop the right cookie (be it either set as _session or as a escaped variation). With this kind of Middleware in place, we were able to tackle all the cookie tossing attacks that can be tackled on the server side. Unfortunately, we were aware of another vector which made middleware protection useless.

If you’re having cookie problems I feel bad for you, son. I’ve got 99 cookies and my domain’s ain’t one.

This is a slightly more advanced attack that exploits the hard limit that all web browsers have on the number of cookies that can be set per domain.

Firefox, for example, sets this hard limit to 150 cookies, while Chrome sets it to 180. The problem is that this limit is not defined per cookie Domain attribute, but by the actual domain where the cookie was set. A single HTTP request to any page on the main domain and subdomains will send a maximum number of cookies, and the rules for which ones are picked are, once again, undefined.

Chrome for instance doesn’t care about the cookies of the parent domain, the ones set through HTTP or the ones set as Secure: it’ll send the 180 newest ones. This makes it trivially easy to “knock out” every single cookie from the parent domain and replace them with fake cookies, all by running JavaScript on a subdomain:

for (i = 0; i < 180; i++) {
    document.cookie = "cookie" + i + "=chocolate-chips; Path=/; Domain=.github.com"
}

After setting these 180 cookies in the subdomain, all the cookies from the parent domain vanish. If now we expire the cookies we just set, also from JavaScript, the cookie list for both the subdomain and the parent domain becomes empty:

for (i = 0; i < 180; i++) {
    document.cookie = "cookie" + i + "=chocolate-chips; Path=/; Domain=.github.com; Expires=Thu, 01-Jan-1970 00:00:01 GMT;"
}

/* all cookies are gone now; plant the evil one */
document.cookie = "_session=EVIL_SESSION_TOKEN; Path=/; Domain=.github.com"

This allows us to perform a single request with just one _session cookie: the one we’ve crafted in JavaScript. The original Secure and HttpOnly _session cookie is now gone, and there is no way to detect in the web server that the cookie being sent is neither Secure, HttpOnly, nor set in the parent domain, but fully fabricated.

With only one _session cookie sent to the server, there is no way to know whether the cookie is being tossed at all. Even if we could detect an invalid cookie, the same attack can be used to simply annoy users by logging them out of GitHub.

Conclusion

As we’ve seen, by overflowing the cookie jar in the web browser, we can craft requests with evil cookies that cannot be blocked server-side. There’s nothing particularly new here: Both Egor’s original proof of concept and the variations exposed here have been known for a while.

As it stands right now, hosting custom user content under a subdomain is simply a security suicide, particularly accentuated by Chrome’s current implementation choices. While Firefox handles more gracefully the distinction between Parent Domain and Subdomain cookies (sending them in more consistent ordering, and separating their storage to prevent overflows from a subdomain), Chrome performs no such distinction and treats session cookies set through JavaScript the same way as Secure HttpOnly cookies set from the server, leading to a very enticing playground for tossing attacks.

Regardless, the behavior of cookie transmission through HTTP headers is so ill-defined and implementation-dependent that it’s just a matter of time until somebody comes up with yet another way of tossing cookies across domains, independent of the targeted web browser.

While cookie tossing attacks are not necessarily critical (i.e. it is not possible to hijack user sessions, or accomplish anything anything besides phishing/annoying the users), they are worringly straightforward to perform, and can be quite annoying.

We hope that this article will help raise awareness of the issue and the difficulties to protect against these attacks by means that don’t involve a full domain migration: a drastic, but ultimately necessary measure.

Have feedback on this post? Let @github know on Twitter.
Need help or found a bug? Contact us.

Changelog

Subscribe

Discover new ways to build better

Try Marketplace apps free for 14 days

Learn more