Step 192. Advanced XSS: CSP Bypass, DOM Deep Dive — Attacking Where Defenses Exist
Level 3 — CTF in the Field & Advanced Attack Skills | Difficulty ★★★★☆ | Estimated time: 4 hours
Prerequisites: you’ve finished the XSS basics of Steps 138–139 (Reflected/Stored, cookie theft, filter bypass).
- What you need: Python 3 + Flask (for local CSP experiments), browser developer tools, (optional) a PortSwigger Academy account + Burp Suite.
- ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime. PortSwigger Web Security Academy is a legal learning platform built for attack practice.
- Caution: the presence of the CSP header and the response structure are measured live on a local server. The scene of the browser blocking scripts upon receiving the header, and PortSwigger lab screens, cannot be reproduced in this environment, so they’re marked as Screen examples.
Steps 138–139’s XSS lived in a world with "no defenses at all." A well-built real-world site has a defensive membrane called CSP (Content-Security-Policy) — the server declares via a header "the origins from which scripts on this page may execute," and the browser enforces that rule. But this membrane gets pierced when the policy is loose. Advanced XSS is not a payload contest but a policy-reading contest. Today we cover how to read a CSP and find its gaps, and an advanced look at DOM-based XSS, which completes entirely inside the browser without passing through the server.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Read a CSP header’s directives (
script-src,'self', etc.) and interpret their meaning - Confirm with a local server the response difference between CSP present and absent
- Enumerate representative paths for bypassing a loose CSP (broad whitelists, JSONP, vulnerable CDNs)
- Trace a DOM XSS data flow from source to sink
- Explain the order of approaching PortSwigger’s CSP and DOM XSS labs
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 + Flask (two servers, with/without CSP), requests (header inspection), browser developer tools (Console, Sources), DOM Invader in Burp’s built-in browser (example) |
| Today’s payloads/headers | Content-Security-Policy: default-src 'self'; script-src 'self', <img src=x onerror=alert(1)>, location.search, innerHTML |
| Concepts needed | CSP directives, source→sink flow, JSONP, DOM Invader’s automatic detection |
| Today’s deliverables | lab192_nocsp.py / lab192_csp.py + CSP policy analysis records + a "conditions under which CSP still gets pierced" document |
2-1. CSP — A Rule the Server Declares and the Browser Enforces
CSP is a response header.
Content-Security-Policy: default-src 'self'; script-src 'self'
How to read it: "by default (default-src), load all resources only from the same origin ('self'); scripts (script-src), likewise, only from the same origin." With this header present, the browser does not execute inline <script> or handlers like onerror — because an attacker-planted script has the origin "inline," a rule violation.
One important fact: CSP does not change the server’s response body. The vulnerable page still sends the payload as-is. The blocking is done by the browser, which reads the header. This fact is the key observation of today’s local measurements.
2-2. CSP’s Gaps — Four Shapes of a Loose Policy
Representative conditions under which CSP still gets pierced.
- A broad whitelist in
script-src: an external domain is allowed, likescript-src https://cdn.example.com— if that CDN hosts content an attacker can upload (old library versions, user uploads), you load scripts via that domain. - An allowed JSONP endpoint: if an allowed domain has JSONP (an API whose response changes with a
callback=parameter), putting JS into the callback name becomes a classic bypass. 'unsafe-inline'allowed: the moment inline scripts are permitted, CSP’s XSS defense value nearly vanishes.- Missing directives: a policy with holes, like having only
default-srcand noscript-src.
The attacker’s procedure is fixed: copy the whole header → write down the allowlist → find the exploitable one among them. Since every policy has a different answer, what you learn is not "the right payload" but "the reading procedure."
2-3. DOM-Based XSS — An Attack That Doesn’t Even Reach Server Logs
XSS so far had the payload embedded in the server’s response HTML. DOM XSS is different — the server is irrelevant; the JavaScript inside the page processes user input in a dangerous way and it executes.
Two key terms:
- Source: an input point the attacker controls. Representative ones:
location.search(after the URL’s?),location.hash(after#),document.referrer. - Sink: the point where input "becomes code."
innerHTML,document.write,eval, assignment tolocation.href, etc.
When a value from a source flows into a sink unsanitized, that’s DOM XSS. Since everything after the URL’s # is not sent to the server, DOM XSS using the hash has the trait of leaving no trace in server logs.
2-4. DOM Invader — A Tool That Automatically Tracks Sources and Sinks
An extension built into Burp Suite’s embedded browser that auto-detects a page’s source→sink flows. It saves you from hunting "which input flows to which sink" through code one by one. Today we learn only its existence and role, leaving the principle lesson to manual tracing (developer tools) — to understand what the tool shows, you must experience manual tracing first.
3. Follow Along
3-1. A Server Without CSP and One With — Same Body, Different Header
Make two servers. Both are the same vulnerable search page, but only one attaches the CSP header.
lab192_nocsp.py:
from flask import Flask, request
app = Flask(__name__)
@app.route("/search")
def search():
q = request.args.get("q", "")
return f"<html><body>query: {q}<script>window.xss_ran=true;</script></body></html>"
if __name__ == "__main__":
app.run(port=5192)
lab192_csp.py (only the header changed):
from flask import Flask, request
app = Flask(__name__)
@app.route("/search")
def search():
q = request.args.get("q", "")
body = f"<html><body>query: {q}<script>window.xss_ran=true;</script></body></html>"
return body, 200, {
"Content-Security-Policy": "default-src 'self'; script-src 'self'"
}
if __name__ == "__main__":
app.run(port=5195)
3-2. Inspecting Header and Body
check192.py:
import requests
for port, label in [(5192, "no CSP"), (5195, "with CSP")]:
r = requests.get(f"http://127.0.0.1:{port}/search",
params={"q": "<img src=x onerror=alert(1)>"})
print(f"--- {label} (:{port}) ---")
print("status:", r.status_code)
csp = r.headers.get("Content-Security-Policy")
print("CSP header:", csp if csp else "(none)")
print("payload survives in body:", "<img src=x onerror=alert(1)>" in r.text)
print("inline script survives in body:", "<script>window.xss_ran" in r.text)
Output (measured 2026-09-09):
--- no CSP (:5192) ---
status: 200
CSP header: (none)
payload survives in body: True
inline script survives in body: True
--- with CSP (:5195) ---
status: 200
CSP header: default-src 'self'; script-src 'self'
payload survives in body: True
inline script survives in body: True
How to read it: the decisive observation — even with CSP, the body is contaminated exactly the same. The server sends the payload as-is; the difference is a single header line. CSP is not "a filter that blocks contamination" but "an enforcement rule attached to a contaminated document." So if you trust CSP alone and neglect output escaping, one gap in the policy collapses everything.
3-3. The Scene Where the Browser Enforces (Screen example)
Open both pages in a browser and the difference shows (Screen example):
- No CSP: the
alert(1)popup appears. - With CSP: no popup, and a message like this remains in the developer tools Console:
Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self'".
In your environment, open both ports in a browser and confirm this contrast yourself. 3-2’s conclusion — "the server sends it contaminated, and the browser blocks it" — gets confirmed with your eyes.
3-4. DOM XSS — Tracing from Source to Sink by Hand
Below is the archetype of a page with built-in DOM XSS (the structure of PortSwigger’s DOM labs, code example):
<script>
// source: location.search — everything after the URL's ? is attacker-controllable via a link
var query = new URLSearchParams(location.search).get("q");
// sink: innerHTML — the string is interpreted as HTML syntax
document.getElementById("result").innerHTML = "query: " + query;
</script>
Tracing procedure:
- In the page source (Ctrl+U) and developer tools Sources, search for
location.andinnerHTML. - Follow where the variable from a source candidate (
location.search, etc.) flows through which functions and into what assignment. - If the sink is
innerHTML, pour in<img src=x onerror=alert(1)>; if it’s assignment tolocation.href, pour injavascript:alert(1).
How to read it: in this flow, the server has never seen the payload. The value after ?q= moves only inside the browser. That’s why, no matter how good a server-side WAF (web application firewall) is, there are cases where DOM XSS can’t be blocked — the defense must happen in client code, i.e., avoiding the sink (using textContent).
3-5. The Order for Attacking PortSwigger Labs (Screen example)
The recommended order for solving the CSP and DOM labs on Academy’s "Cross-site scripting" path.
- DOM XSS labs first: apply 3-4’s tracing procedure to the lab page. Find the source and sink in developer tools, make a payload link, and open it yourself.
- CSP labs: first copy the response header and write the allowlist on paper. Check each domain/keyword in
script-src('unsafe-inline'? a broad CDN?) against 2-2’s list and find the exploitable one. - If stuck, read the policy again before looking at the lab’s hint — a CSP lab’s answer is almost always written inside the header.
4. Missions & Exercises
Mission — CSP Analysis and DOM Tracing Records
- Launch
lab192_nocsp.pyandlab192_csp.py, reproducecheck192.py‘s results, and confirm the popup difference between the two pages with your eyes in a browser. - Change the header to a loose policy like
script-src 'self' https://cdn.example.comand analyze this policy’s gap in three lines. - Build 3-4’s DOM XSS page yourself, draw the source→sink flow with arrows, and pop the popup with a payload. Then change the sink to
textContentand confirm the defense. - In your wiki, write
CSP-bypass-conditions.md— "5 conditions under which CSP still gets pierced," expanding 2-2.
Exercises
Exercise 1. In our measurement, even the CSP server had the payload alive in its body as-is. What does this fact say about CSP’s essence?
Exercise 2. Read Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com and explain the point an attacker would target.
Exercise 3. In DOM XSS, explain via the structure of an HTTP request why an attack using location.hash as a source leaves nothing in server logs.
Exercise 4. Why does DOM XSS defense rest on fixing client code (replacing the sink) rather than server-side filters?
5. Model Answers & Completion Criteria
Mission Model Answer
How to verify: ① do both servers’ responses confirm "CSP header present/absent" and "payload survives in body: True" (per the 2026-09-09 measurement)? ② In the browser, does the CSP page show no popup with a block message in the Console? ③ Does the loose-policy analysis contain the expression "via an allowed domain"? ④ On the DOM page, is the contrast confirmed — innerHTML → popup, textContent → text-only output?
Exercise Answers
Answer 1. That CSP is not a filter that sanitizes input but a header instructing the browser on the execution rules of a contaminated document. The server’s vulnerability (missing escaping) is still there; the blocker is the browser. So if the rules have a gap or the browser doesn’t support the rules, it’s defenseless — the fundamental defense remains output escaping.
Answer 2. script-src allows the external domain cdn.example.com. The attacker ① loads a vulnerable old-version library hosted on that CDN, or ② finds a feature on that domain that lets them upload content (uploads, JSONP), loading their own script via an allowed domain. Even with inline blocked, the structure makes "an allowed origin" itself an abuse channel.
Answer 3. The browser excludes everything after the URL’s # (the fragment) when sending a request to the server. Opening https://site/page#<script>... delivers only /page to the server. Since the payload never passes through the server even once, server logs hold no trace, and server-side detection/blocking is impossible.
Answer 4. Because in DOM XSS, data goes straight from source to sink inside the browser without passing through the server’s response-generation process. The server never gets a chance to filter the input (especially with the hash source). Therefore the right answer is replacing the sink with a safe API — using textContent, which inserts only text, instead of innerHTML, which triggers HTML interpretation.
Completion Criteria Checklist
- [ ] I can read the meaning of a CSP header’s
default-src,script-src, and'self' - [ ] I confirmed by measurement that CSP doesn’t change the body and the browser enforces it
- [ ] I can name at least three bypass paths of a loose CSP (via whitelist, JSONP, unsafe-inline)
- [ ] I can define source and sink and name at least two representative examples of each
- [ ] I built a DOM XSS page and confirmed source→sink tracing and the
textContentdefense - [ ] Mission: I finished the CSP analysis records + the
CSP-bypass-conditions.mdwrite-up
6. Common Pitfalls & Fixes
Wall 1. I set CSP but the popup still appears
Symptom: you attached the header but alert executes in the browser.
Cause #1: a header name/spelling error (the hyphens in Content-Security-Policy). #2: the policy contains 'unsafe-inline'. #3: the browser is showing a cached old response.
Fix: first check in developer tools’ Network tab whether the response header actually came down. The habit of printing headers in code, like 3-2’s check192.py, is the surest.
Wall 2. The header exists but the browser doesn’t apply CSP
Cause: CSP must be attached to every response. Attach it to some paths and not others, and the unattached paths become attack routes.
Fix: with Flask, attach it to all responses via middleware (@app.after_request). In real work, applying it wholesale at the web server layer (Nginx, etc.) is good for preventing omissions.
Wall 3. I put a payload into a DOM XSS lab and nothing happens
Cause #1: the source is location.hash but you put it in ?q= (or vice versa). Each source reads a different input position. #2: the sink is document.write, not innerHTML, so the context differs.
Fix: don’t reverse the tracing order — fix the source first (find the location. family in the code), then place the payload where that source reads.
Wall 4. I don’t know where to start on a CSP lab
Cause: you’re looking for the payload first. A CSP lab is not a payload problem but a reading-comprehension problem.
Fix: force the procedure — ① copy the whole response header, ② write script-src‘s allowlist one per line, ③ ask each entry "can I put my code on this origin?" If the answer still isn’t visible after all three, then look at the hint.
Wall 5. It still executes even after switching to textContent
Cause: the place you changed isn’t the sink, or there are multiple sinks and you changed only one. Or the value also leaks into another flow (eval, setTimeout(string)).
Fix: find all the sinks that value reaches. Putting the variable name in the search box and enumerating every reference is fastest. eval and string-argument setTimeout are sinks too.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| CSP | A defensive membrane where the server declares executable resource origins via a header and the browser enforces it |
script-src 'self' |
Scripts load only from the same origin — blocks inline scripts |
| Whitelist bypass | Loading malicious scripts via an allowed domain (CDN, JSONP) |
| Source | An attacker-controlled input point — location.search, location.hash, etc. |
| Sink | The point where input becomes code — innerHTML, eval, etc. |
| DOM Invader | A Burp embedded-browser extension that auto-detects source→sink flows |
Today’s Commands and Payloads
| Command/code | What it does |
|---|---|
Content-Security-Policy: default-src 'self'; script-src 'self' |
Grant a minimal CSP policy |
r.headers.get("Content-Security-Policy") |
Check a response’s CSP presence in code |
new URLSearchParams(location.search).get("q") |
DOM XSS’s typical source |
element.innerHTML = input |
A dangerous sink (replace with: textContent) |
Search location. / innerHTML in dev tools |
The start of manual source/sink tracing |
An Instinct More Important Than Commands
In advanced XSS, payload memorization is secondary. Real skill is two kinds of reading comprehension — reading headers (finding the weak link in CSP’s allowlist) and reading code (tracing the path data flows from source to sink). And the defense lesson is symmetric too: CSP is the second line of defense, not the first. Output escaping that blocks contamination at the source, and safe sink choices, come first — CSP is insurance on top.
Once every box is checked, Step 192 is complete.