Cross-Site Request Forgery (CSRF)
Submitting data-altering requests blindly from your domain on the client-side. Cookies are automatically sent, often requiring CSRF tokens as protection
Description
Websites need to be able to access their own sensitive content, while malicious websites should not be able to access that same data from another site. To make this possible, browsers implement some same-origin and same-site policies. These either allow or deny an action based on the origins of the request. As you can read in the table below, same-site is generally more allowing than same-origin:
example.com
example.com
Yes
Yes
app.example.com
other.example.com
Yes
No: mismatched domain name
example.com
example.com:8080
Yes
No: mismatched port
https://example.com
http://example.com
No: mismatched scheme
No: mismatched scheme
Another term important to cookies is when requests are 'top-level' or not. One simple definition is if the address bar matches the request being made. Redirection or window.open()
, for example, are top-level navigations. A fetch()
or <iframe>
, however, are not, because the address bar shows a different address to the resource being requested.
Same-origin & CORS
One feature that uses the same-origin policy is Cross-Origin Resource Sharing (CORS). This prevents an attacker from requesting a page from a website on a user's behalf and being able to read the response content. If this were not the case, any website could steal secrets from any other website by simply requesting them. This policy ensures certain response headers are explicitly set to allow cross-origin resource sharing.
Access-Control-Allow-Origin
: <origin> | *
: If this header is missing, no other origins are allows to read the body. If it is a valid origin, the body may be read if the requesting origin is the same as that in this header. If the value is "*
" (wildcard), any origin may read the body.Access-Control-Allow-Credentials
: true
: If this header is missing, it is interpreted asfalse
. If it is instead explicitly set totrue
, the incoming request made byfetch()
may include cookies, only if...-Allow-Origin
is not*
during the preflight request. It must be a full origin. This is why some APIs simply reflect the incomingOrigin
header to allow any site to include cookies.
Fetch requests must explicitly ask to include cookies if they want to send cookies and read a response. This is done using the credentials:
option. If by the Same-site rules explained below your background request is allowed to include cookies, and the Access-Control
headers allow it, the following request will be authenticated and allow you to read the response cross-site:
Origin Check Bypasses
Some sites conditionally add an Access-Control-Allow-Origin:
header to the response if the request's Origin:
comes from a trusted domain. If the check of this origin is flawed, you may be able to fool it with a special domain.
Test this by making the cross-site request you want to make, and change the Origin:
header to some variations of a trusted domain. If the site trusts api.example.com
, for example, try some of the following registerable permutations (source):
Any domain
evil.com
Different TLDs
api.example.net
api.example.io
Subdomains (requires XSS or subdomain takeover)
xss.api.example.com
takeover.api.example.com
Pad domain from left
evilexample.com
api-example.com
Pad domain from right
api.example.com.evil.com
api.example.comevil.com
If you can successfully send a request from any of the above origins and read a response, you have bypassed CORS!
Origin: null
If the application responds with Access-Control-Allow-Origin: null
by default, or when you set Origin: null
, you are able to exploit this cross-site to read a response. Multiple ways allow you to send JavaScript requests from a null
origin, such as the <object>
tag or a sandboxed <iframe>
(source):
Same-site
A different feature that uses the same-site policy is Cookies. On many websites cookies are everything that authenticates the user. If a request includes the session cookie of a user, they are allowed to perform actions on their account. Simple as that.
To make sure malicious websites cannot simply recreate a <form>
and send it automatically to change a password, for example, these requests are checked to be same-site (see table above). If the origins are not same-site the cookies will not be sent.
In the early web days, this SameSite
did not exist for cookies. Nowadays it is an attribute on cookies that may be None
(no protections), Lax
(default, some protections) or Strict
(most protections).
This value is important to know as it decides what kind of cross-site requests will be authenticated. The table above shows that at least any subdomain on any port will bypass same-site protections because it is considered same-site. This means that any Cross-Site Scripting (XSS) vulnerability on such a website may lead to you being able to make authenticated requests!
All SameSite=
values have the following meanings:
SameSite=
None
: All cross-site requests to the cookie's origin will include cookies.SameSite=
Lax
: Only top-level GET requests will contain the cookie. Any other requests such as POST,<iframe>
's,fetch()
or other background requests will not include this cookie.SameSite=
Strict
: No cross-site requests will include cookies. In simple terms, this means that if the target site for the request does not match the site currently shown in the browser's address bar, it will not include the cookie. A redirect is not sufficient here, as the origin at the time of redirection is still yours instead of the target.SameSite
is missing: When the attribute is not explicitly set for a cookie, it gets a little complicated because the browser tries to be backward compatible. For Firefox, the value will be None by default, with no restrictions. For Chromium, however, the value will be Lax* by default. * This asterisk is saying that for the first 2 minutes of the cookie being set, it will be sent on cross-site top-level POST requests, in contrast to the normal Lax behaviour. After this 2-minute window, the behaviour mimics Lax completely, disallowing cross-site top-level POST requests again.
Third-party cookie protections
While the above rules covered everything for a long time, privacy and tracking concerns pushed browsers to limit cross-site cookies even more. These rules only restrict requests that are not top-level. When you make a fetch()
request, for example, the cookies will not be included, even if SameSite=None
! This rule adds to the regular same-site rules.
All browsers are implementing this in slightly different ways, check out the documentation for each:
Chromium: Privacy Sandbox Tracking Protection
Firefox: Enhanced Tracking Protection
Safari: Intelligent Tracking Prevention
Because this movement is still in progress, there are some 'Heuristics based exceptions' to these rules that make cookies behave like before. This is to prevent certain authentication flows from breaking and include the following bypasses because it is not a security feature:
Chromium Heuristics:
window.open()
the target site and receive an interaction on the popup, whitelisting your site for 30 days for to access the target's third-party cookies from your site that opened it.Firefox Heuristics:
window.open()
the target site once (no interaction required), whitelisting your site for 30 days
Attack Examples
To get a more practical idea of these protections, here are some examples of what is and isn't allowed in modern browsers. Firstly, some practical examples of how an attacker's site can send POST data to another site if it is misconfigured:
Here both methods can achieve the same requests, but notice that one is top-level, while the other is not. The <form>
method will work when the SameSite attribute is missing in Chromium-based browsers for the first 2 minutes of the cookie being set, as well as bypassing Third-party cookie protections automatically. The fetch()
method is more hidden but has more preconditions.
With the fetch()
method you can completely control the body data while using a <form>
this is done for you depending on the Content-Type
header (enctype=
in HTML).
This type can be changed to one of three values, which all have different formats. The text/plain
type may be interesting if a server expects the application/json
type which is normally impossible, but also accepts this as an alternative. Here are all three:
SameSite=
Strict
: bypassing using client-side redirect
SameSite=
Strict
: bypassing using client-side redirectAs mentioned earlier, the SameSite protection only prevents cross-site requests. If you can create a fake form or have javascript execution on a sibling domain or different port, this bypasses the restriction.
If this is not possible, there is another interesting method. It's impossible to send an authenticated request from your own site, so why not try to send a request from the site you are already targeting? Any requests like client-side redirects will be authenticated because you are on the same site. For this to work the target endpoint that you want to execute, such as /reset_password
, will need to allow GET requests with parameters. In a very flexible framework, this behavior might be common as query and body parameters are merged.
Take the following gadget, which allows an unauthenticated client-side redirect using a parameter:
Note that while this is in a GET response, an unauthenticated POST response might also have a gadget like this to abuse. We can send such a request using the <form>
technique from above.
This gadget can be abused because after redirecting to this location from our malicious site, the next redirect will be authenticated as it is coming from the same site. Using a directory traversal sequence in the ?postId=
query parameter we can make it redirect to the vulnerable state-changing GET endpoint that was our initial target, and it will be authenticated with Cookies:
SameSite=
Lax
: method override
SameSite=
Lax
: method overrideIf you find a state-changing GET request or can trick the server into thinking a GET request is a POST request, you may still find impact. With backends like PHP Symfony that have an extra ?_method=POST
parameter that can be set in a regular GET request to override the method internally:
SameSite=
None
: Background requests
SameSite=
None
: Background requestsWith this SameSite attribute, the cookie is treated as before SameSite was implemented. This means any techniques like the <form>
or fetch()
will work and send cookies using any request method. In such cases, you should check if any CSRF tokens are required; if not, there's a good chance you can make any victim send any state-changing request when they visit your site.
SameSite
is missing: None
or abusing the 2-minute window
SameSite
is missing: None
or abusing the 2-minute windowRemember that Firefox, a major browser, still defaults to SameSite=None
when a cookie misses this attribute. On Chromium browsers, it will still allow top-level POST requests for 2 minutes after the cookie is set, before fully committing to SameSite=Lax
.
This behaviour has a small chance of a victim just having logged in being exploitable. This is pretty unlikely, but a more powerful way to use this is if the site allows resetting the cookie. When it is set again by opening a new tab from your site, the timer is also reset and a CSRF is possible.
Other cookie attacks
If CSRF attacks are not possible due to protections like #csrf-tokens, but the SameSite attribute is still quite forgiving, there are more techniques involving the auto-sending behaviour of most cookies. Most involve references to a window of the target site being authenticated. This can either be a top-level context using window.open()
or redirection with location=
, or a third-party context using <iframe>
's.
Here are some examples of how to get window reference containing your target:
If the target page allows being put into an <iframe>
, your site above the iframe can put a barely transparent overlay over the frame to trick the user into clicking certain parts of the frame. This technique known as 'clickjacking' requires cookies in a third-party context, and thus SameSite=None
, but can be very effective if there is enough reason for the user to follow your instructions, like a game or a captcha.
Instead of clicks, this technique can go even further with overwriting clipboard/drag data to make the user unintentionally fill in forms, or carefully show parts of the iframe to make the user re-type what is on their screen back to you.
XS-Leaks are a more recently developed attack surface that can go very deep. The idea is to abuse your window reference or probe the requests to the target site in order to leak some information about the response. A common exploit for this is detecting if something exists, like a private project URL or query result. By repeating leaks for search functionality, you can find strings included in the response slowly to exfiltrate data from a response cross-site (called 'XS-Search').
This post and this writeup show examples of this technique. From a subdomain, it is possible to set cookies on any other subdomain or main domain. For example, from the xss.example.com
domain you could set a payload=...; domain=example.com
cookie to add a cookie to another domain. This can lead to all sorts of attacks like Self-XSS becoming exploitable, messing with flows, etc. because a developer may not expect the attacker to have control over the victim's cookies.
Using the path=/some/path
cookie attribute, you can even force the cookies to one specific path. The other cookies from the victim will stay active on other pages, potentially leading to complex attacks where different sessions are used (more info).
postMessage Exploitation
Protection: CSRF Tokens
However, the reality is slightly more complicated. Because these rules are so lax, most sites implement their own protection: CSRF Tokens. These are extra fields on a form that are randomly generated, but attached to the user's session. Whenever a form is submitted, the extra CSRF token field is validated to match the session and only then will it be considered authenticated. A malicious site won't know this randomly generated token and therefore cannot make a fake request that includes it. This is assuming however that:
This token is implemented;
This token is generated securely;
This token is unique per user.
Last updated