Filter Evasion
Working around application-level defences: blocked quotes, stripped keywords, escaped input, and length caps — without a WAF in sight.
Application Filters vs. WAFs
WAF bypass deals with a separate appliance inspecting HTTP. This guide covers filters written inside the application: a blocklist in a validator, a str_replace that strips keywords, an escaping routine applied inconsistently.
These are usually weaker than a commercial WAF because they are hand-rolled, but they are also closer to the query, so the constraints are tighter — you often have no control over encoding, and the filter sees exactly what the database will see.
The recurring lesson is that a filter attempts to enumerate badness. SQL's grammar is large enough that the enumeration is never complete.
Injecting Without Quotes
Quote-stripping is the most common hand-rolled defence, and the easiest to work around when you need a string literal.
Hex literals (MySQL). 0x61646d696e is 'admin' with no quote character present.
CHAR() / CHR(). Build the string from character codes. Portable across engines with per-engine syntax.
Concatenation of unquoted values. Column names, numeric literals, and function results can often substitute for the literal you wanted.
Avoid needing a literal at all. Many payloads only need numbers: AND 1=1, ORDER BY 3, UNION SELECT 1,2,3 require no strings whatsoever. Schema enumeration needs strings, but confirming injection does not.
In a numeric context there is no quote to escape in the first place, which is why quote filters give such false confidence.
String literals without quotes
-- Hex literal — no quotes anywhere
SELECT * FROM users WHERE name = 0x61646d696e
-- CHAR()
SELECT * FROM users WHERE name = CHAR(97,100,109,105,110)
-- Adjacent literal concatenation (MySQL-specific), if partial
-- quoting survives
SELECT 'ad' 'min'
-- Comparing without a literal at all
SELECT * FROM users WHERE name = (SELECT name FROM users LIMIT 1)
-- unhex
SELECT UNHEX(61646d696e)Exploiting Strip-Based Filters
A filter that removes a matched pattern rather than rejecting the request is worse than no filter, because you can construct input that becomes the payload only after filtering.
If union is stripped once, non-recursively:
Input: UNIunionON SELselectECT 1,2,3
After: UNION SELECT 1,2,3
The same trick defeats space-stripping ( → ), comment-stripping, and quote-removal. Test for it by sending a doubled keyword and seeing whether the query succeeds.
Related: filters that run in the wrong order. If the application strips comments and then strips keywords, UN/**/ION survives the first pass as UNION and is caught. Reverse the order and it does not. Probing both arrangements is worth two requests.
Defeating Escaping
Applications that escape rather than parameterise fail in specific, well-known ways.
Numeric contexts. Escaping adds backslashes before quotes. In WHERE id = $input there are no quotes, so escaping is a no-op and 1 OR 1=1 passes untouched. This is by far the most common escaping failure.
Charset mismatch. Historically, addslashes() on a connection using a multi-byte charset such as GBK allowed %bf%27 to become a valid two-byte character plus a free quote, consuming the escaping backslash. Fixed by setting the connection charset correctly (mysqli_set_charset), but still present in legacy code.
Double escaping and unescaping. Input escaped, stored, then unescaped on read, then concatenated. See Second-Order.
Escaping the wrong things. An escaper written for string literals does nothing for LIKE metacharacters, identifiers, or ORDER BY targets.
Backslash handling differences. PostgreSQL with standard_conforming_strings on treats backslash literally, so a MySQL-style escaper that turns a quote into backslash-quote leaves the quote intact — it still terminates the literal.
The numeric-context blind spot
<?php
// The developer escaped, so this is "safe".
$id = mysqli_real_escape_string($conn, $_GET['id']);
// But there are no quotes in the query. Escaping quotes
// accomplishes nothing here.
$sql = "SELECT * FROM products WHERE id = $id";
// ?id=1 OR 1=1 -> works, nothing to escape
// ?id=1 UNION SELECT 1,2,3 -> works
// ?id=(SELECT ...) -> worksWorking Within Length Limits
A maxlength on a field or a column width in the database can cap your payload. Short forms help:
-- Instead of: ' OR 1=1-- - (11 chars)
'||'1 -- Oracle/PG concat trick, 4 chars
' OR 1# -- MySQL, 6 chars
' OR''=' -- string context, 7 chars, no digits or comment
The ' OR''=' form is worth remembering: it closes the literal, ORs in the comparison ''='' (true), and lets the application's own trailing quote balance the statement, so no comment is needed at all.
Also consider splitting the payload across multiple parameters that are concatenated into the same query — each stays under the limit, and the query assembles them for you. This is common in search forms that build a WHERE clause from several fields.
Why Blocklists Lose
Consider the effort asymmetry. To block injection with a blocklist you must enumerate every keyword, every alias, every encoding, every comment form, and every whitespace variant, across every engine, forever — and you must not break legitimate input containing the word "union" or an apostrophe.
To bypass it you need to find one gap.
Concretely, blocking SELECT still leaves ORDER BY oracles, boolean inference, and time-based extraction, none of which require the keyword. Blocking quotes leaves numeric contexts and hex literals. Blocking spaces leaves parentheses and comments. Blocking information_schema leaves sys.*, mysql.innodb_table_stats, and error-based schema leakage.
This is why the entire industry consensus is parameterisation. Not because filters are useless, but because they are unbounded work with a bounded adversary cost.
Prevention
- Parameterise. Once values are bound, none of this page applies.
- Reject, never sanitise. If input fails validation, return an error. Silently stripping characters creates the strip-based bypasses above and corrupts legitimate data.
- Validate positively. "Must match
^[0-9]+$" is a complete defence for a numeric ID. "Must not containSELECT" is not a defence for anything. - Allowlist identifiers. The one place parameterisation cannot reach.
- Set the connection charset explicitly if any legacy escaping remains, to close the multi-byte class.
- Never rely on
maxlengthor client-side validation. Both are advisory. - Turn off prepared-statement emulation in PDO so binds happen in the database rather than in string-building code.
Related
Web application firewalls match patterns; databases parse grammar. Every gap between those two is a bypass.
Where your input lands in the query determines which payloads can possibly work. Get the context wrong and every payload fails for the wrong reason.
PDO prepared statements, the emulated-prepares trap that makes them lie, and Laravel's raw-query escape hatches.
Input that is stored safely and then used unsafely somewhere else entirely. The payload and the vulnerable query live in different requests, different code paths, and often different applications.
Parameterisation removes the bug; these layers limit the blast radius when a parameterisation is missed. Least privilege, allowlists, monitoring, and what each is actually worth.