SameSite=Lax Is Not Enough: Ledgerline Email Change to ATO
Ledgerline is a small-business banking portal. The session cookie sid is issued at login with Secure, HttpOnly, SameSite=Lax, Path=/, a 30-minute sliding idle timeout, and the value is never rotated. Every money movement is POST /api/transfer and requires a header X-CSRF-Token whose value is bound to the session server-side; you have verified that omitting it, or sending another session's token, returns 403. But you also find GET /api/profile/set-email?email=x, which requires only a valid session cookie, applies the new address immediately, and then emails a notification to the old address. Password reset at /forgot sends the reset link to the address currently on file, with a 15-minute token. The client's threat model document says "CSRF is mitigated by SameSite=Lax and anti-CSRF tokens", and the finding is currently logged as accepted risk. You have a phishing pretext ready (a fake "unusual login" alert) but only one chance to make it work.
1. Why do the CSRF token and SameSite=Lax fail to protect the email-change endpoint? Be precise about which browser behaviours apply and which do not.
Read the worked answer
The CSRF token fails because it was never applied to this endpoint. The token is checked on POST /api/transfer only. /api/profile/set-email is a GET with no token parameter and no token check. The protection is per-endpoint, not per-session, and the threat model has generalised one correctly protected route to the whole application. That is the whole bug: a state-changing operation that (a) uses GET, (b) has no token, and (c) is exempted from the control the team believes is global.
SameSite=Lax fails because this is a top-level navigation, not a cross-site subresource request. Lax sends the cookie on cross-site requests that are top-level GET navigations - clicking a link, following a 302, submitting a form with method=GET and target=_top, or setting location from script. Lax blocks the cookie on cross-site subresource loads (<img>, <iframe>, <script>, fetch) and on cross-site POST, which is precisely why the transfer endpoint is genuinely safe and this one is not.
Two nuances worth being explicit about, because they decide whether the attack works on a given victim.
- Chrome's "Lax + POST" two-minute grace window applies only to cookies that have no SameSite attribute at all. Here
SameSite=Laxis set explicitly, so the grace does not apply. Do not plan around it. - SameSite enforcement is a browser-side control. It does nothing for clients that ignore it - an old browser, a webview, a native app using a cookie jar. That is a secondary, less reliable angle, but it is the one to cite if the client argues the fix is complete.
So: the session cookie is sent on a top-level GET to the target origin, the endpoint performs the change with no token, and no response body needs to be readable for the attack to succeed. The response being unreadable is irrelevant - you do not need to read the reply, only to have caused it.
2. Write out the end-to-end account-takeover chain, including exactly what the victim must do and what you must observe at each stage.
Read the worked answer
Stage 1 - delivery. The phishing page contains an auto-followed top-level navigation to the target: <script>location='https://ledgerline.example/api/profile/set-email?email=attacker%40evil.tld'</script>, or a <meta http-equiv="refresh">, or a GET form with target="_top" auto-submitted. The victim must be logged in (or be lured through the real login first) and must load the attacker page. Nothing else - no click on a specific button, no typing.
Stage 2 - execution and what you observe. The request arrives with sid attached because it is a top-level GET. The app sets the address to the attacker's mailbox and returns a redirect to the profile page. You cannot read that response, and you do not need to: you confirm success out-of-band because you control [email protected] and receive the notification mail that the app sends to the old address… except that one goes to the victim, not you. What you can observe is the change itself, later, when the reset mail arrives.
Stage 3 - claim. Trigger GET /[email protected], or simply POST whatever the reset form expects with the victim's address. The app looks up the address currently on file - now yours - and mails a 15-minute reset token to [email protected]. Open it, set a new password, log in as the victim. If the account has MFA, you now have the session but not the second factor; check whether email change or password reset invalidates MFA enrolment, and if not, pivot to whatever the victim's role can reach rather than assuming full takeover.
Stage 4 - the race you must win. The victim receives the security notification about the email change at their real address, so your window is minutes, not hours. Prepare everything (reset request, new password, destination account for funds) before you send the phishing link. Do not do the reset manually after the fact.
What can break the chain: MFA that survives a password reset, a re-authentication prompt on password reset, a delay between the email change and password-reset eligibility, or session invalidation on email change. Test for all four on your own account before touching the client's user.
3. Give the correct fix set, and identify which single control would have broken the chain on its own. Be careful about controls that look sufficient but are not.
Read the worked answer
Controls that break it on their own (any one of these ends the attack):
- Require the CSRF token on the email-change request and change the method to POST. The token is already implemented and session-bound; extending the existing check to every state-changing route - and enforcing that state changes are never GET - closes the hole. This is the smallest, most durable fix because it generalises: the real defect is that the control was applied per-route rather than per-operation.
- Re-authenticate: require the current password to change the account email. An attacker with only the session cookie cannot supply it. This also covers the case where the token control is later misapplied again.
- Set
SameSite=Strictonsid, or split a strict cookie for account settings. Belt-and-braces; do not rely on it alone, because it does not cover clients that ignore SameSite.
Controls that look sufficient but are not:
- "Confirm the new address before applying the change." This is the trap. The attacker owns
[email protected], so they click the confirmation link themselves. Confirmation protects against typos and against third parties changing your address, not against an attacker who initiated the change. It is a good control, but it does not break this chain. - Notifying the old address. Detection only, and it fires after the change is already applied. Move it to before the change, and make the notification contain a one-click revert link.
- Rotating the session on email change. Reduces the window but the attacker has already redirected the reset mail.
Related hardening while you are in the code: rotate sid on login and on any privilege-affecting change (the cookie is currently never rotated, which enables fixation), apply the __Host- prefix, and add rate limiting and a step-up challenge on /forgot. Finally, re-audit every GET route that writes state - this bug class rarely appears exactly once, and the tool that finds the rest is a grep for write operations in GET handlers, not a scanner.