How a WAF gets bypassed (and why a blocklist is rarely enough)
"We have a WAF" reassures people more than it should. A well-placed Web Application Firewall stops the noise and automated scanners, but most run on a blocklist: they block "known-bad" strings. And a blocklist chases text, not intent. Since there are infinite ways to write the same attack, there is almost always one the list didn't foresee.
What a WAF is (and isn't)
A WAF inspects requests and blocks those matching its rules. Two philosophies: blocklist (deny known-bad — most of them) and allowlist / positive security (allow only what's expected — safer, harder to maintain). The blocklist is a speed bump: it slows you, it doesn't stop you. Here are the four bypasses you see again and again.
1. Same attack, different form (XSS without <script>)
The classic mistake: block <script> and think you stopped XSS. But JavaScript runs in many ways. An on… attribute on any tag does the job:
Blocked: <script>alert(1)</script>
Passes: <img src=x onerror=alert(1)>
Passes: <svg onload=alert(1)>
The rule chased one specific tag; the attack (running JS) has dozens of routes.
2. Encode what's forbidden (path traversal)
The WAF blocks raw ../, but the server decodes %XX before using the path. Encode the dots and slashes and the filter won't see the pattern while the server rebuilds it:
Blocked: ../../etc/passwd
Passes: %2e%2e%2f%2e%2e%2fetc%2fpasswd
The same idea sneaks "forbidden" characters into almost any parameter.
3. Break the pattern (case, comments, no spaces)
Many SQLi rules are naive: they look for lowercase or with spaces, or the word UNION. They break by changing the form without changing the meaning:
Blocked: ' OR 1=1 --
Passes: '/**/OR/**/1=1 (inline comments as separators)
Passes: 'oR'1'='1' (no spaces, string comparison)
Mixed case, /**/ comments, tabs instead of spaces… all point to the same thing: the SQL engine is tolerant; the filter is literal.
4. Double encoding (against the "smart" WAF)
Some WAFs decode once and then filter. The answer is to encode twice: the WAF sees a clean layer, and the app, decoding again, reveals the attack.
%3C -> "<" (one layer: the WAF blocks it)
%253C -> "%3C" -> "<" (two layers: WAF sees %3C, app sees <)
Why there's always a gap
Because a blocklist tries to enumerate the infinite. The attack is an intent (run JS, read a file, alter a query); the representations of that intent are countless (encodings, case, comments, alternative characters, fragmentation…). The defender must catch them all; the attacker needs just one.
What actually closes the hole
- SQLi → parameterized queries (prepared statements). Don't concatenate input into the query.
- XSS → context-aware output encoding + a strict CSP; don't rely on filtering input.
- Path traversal → canonicalize the path and validate against an allowlist; never pass user input straight to the filesystem.
- The WAF → as an extra layer (defense in depth), in positive-security mode if you can, and with logging to catch the attempts.
Checklist
- ✅ Is your WAF a blocklist? Assume it's bypassable; it's not your only defense.
- ✅ XSS: blocking
<script>is not enough; thinkon…handlers. - ✅ Normalize/decode before filtering, to a single canonical form.
- ✅ Fix the vuln in the code; the WAF is borrowed time.
A WAF is a good guard at the door, but it doesn't repair the broken lock inside. Use it for what it is —a layer— and fix the flaw where it really lives: in the code.