Step 192. Advanced XSS: CSP Bypass, DOM Deep Dive — Attacking Where Defenses Exist

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.

  1. A broad whitelist in script-src: an external domain is allowed, like script-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.
  2. 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.
  3. 'unsafe-inline' allowed: the moment inline scripts are permitted, CSP’s XSS defense value nearly vanishes.
  4. Missing directives: a policy with holes, like having only default-src and no script-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 to location.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:

  1. In the page source (Ctrl+U) and developer tools Sources, search for location. and innerHTML.
  2. Follow where the variable from a source candidate (location.search, etc.) flows through which functions and into what assignment.
  3. If the sink is innerHTML, pour in <img src=x onerror=alert(1)>; if it’s assignment to location.href, pour in javascript: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.

  1. 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.
  2. 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.
  3. 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

  1. Launch lab192_nocsp.py and lab192_csp.py, reproduce check192.py‘s results, and confirm the popup difference between the two pages with your eyes in a browser.
  2. Change the header to a loose policy like script-src 'self' https://cdn.example.com and analyze this policy’s gap in three lines.
  3. 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 textContent and confirm the defense.
  4. 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 textContent defense
  • [ ] Mission: I finished the CSP analysis records + the CSP-bypass-conditions.md write-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. Click the checkbox in the sidebar to save your progress.