SQL Injection
A Complete 10-Chapter Security Course
Table of Contents
- What SQLi Is & Why It Works
- Anatomy of an Injection
- The Types of SQLi
- UNION-Based Data Extraction
- Blind SQL Injection
- Beyond SELECT
- The Primary Defence: Parameterized Queries
- Defence in Depth
- SQLi in Modern Stacks
- Testing, Tooling & Hardening Checklist
What SQLi Is & Why It Works
SQL injection (SQLi) is what happens when untrusted input is mixed into a database query so the database interprets part of that input as SQL code rather than mere data. If that sounds familiar from the XSS course, it should: it's the same root cause β data treated as code β aimed at a different target. XSS subverts the browser; SQLi subverts the database, where your most sensitive data lives. It has sat near the top of the OWASP risk list for over two decades.
The Core Idea: A Query Built by String Concatenation
A web app builds SQL queries to talk to its database. The fatal pattern is constructing that query by gluing user input directly into the query string:
The developer intended the input to be a value slotted between the quotes β pure data. But the input and the query are the same string, so if the input contains SQL syntax (like a quote), it can escape the data slot and become part of the command. The database has no way to know which characters the developer wrote and which the attacker supplied β it just parses the final string as SQL.
The Canonical Example
Supply ' OR '1'='1 as the username, and watch the query's meaning change:
The injected OR '1'='1' is always true, so the WHERE clause matches every row β the query returns all users (often logging the attacker in as the first one, typically an admin). The single quote in the input closed the string the developer opened, and everything after it was read as code. That one-character breakout is the entire essence of SQLi.
Same Root Cause as XSS β Different Target
| XSS | SQL Injection | |
|---|---|---|
| Untrusted input becomes⦠| HTML/JavaScript | SQL |
| Interpreted by | the browser | the database |
| Runs where | client-side (victim's browser) | server-side (your database) |
| Breakout character | < (opens a tag) | ' (closes a string) |
| Primary fix | context-aware output encoding | parameterized queries |
Why It's So Damaging: It Targets the Database
XSS attacks a user's session; SQLi attacks the data store itself β which makes it one of the highest-impact web vulnerabilities. Depending on the query and the database account's privileges, an attacker can:
- Read any data β dump entire tables: all users, password hashes, personal data, payment records (often via
UNION, Chapter 4). - Bypass authentication β the
' OR '1'='1login bypass (ties to the Auth course β this is one route to account takeover with no credentials). - Modify or destroy data β
UPDATE/DELETE/DROPif the injection reaches a write or stacked query (Chapter 6). - Escalate further β read files, run OS commands, or pivot into the network on misconfigured/over-privileged databases.
sqlmap (Chapter 10) can find and exploit it with little manual effort, which means even low-skill attackers can weaponize a single mistake. This is why SQLi, despite being old and well-understood, remains both common and catastrophic β and why the one real fix (Chapter 7) must be applied everywhere, not just on the obvious inputs.
Where Injectable Input Comes From
Any data that originates outside your code and reaches a query is a candidate β not just the obvious login form. URL parameters, form fields, HTTP headers (User-Agent, cookies), JSON API bodies, and even data already stored in your database (second-order SQLi, Chapter 6) can all carry a payload. The discipline isn't "sanitize the login box" β it's treating every value that flows into a query as untrusted until it's safely parameterized.
Hands-On Exercises
In your own words, explain the root cause of SQLi in "data vs code" terms. Take the concatenated login query and show exactly how the input ' OR '1'='1 changes its meaning, identifying which character causes the breakout and why the database can't tell code from data.
Draw the parallel between SQLi and XSS: for each, name what the input becomes, who interprets it, where it runs, the breakout character, and the primary fix. Then state the single root cause both share and why understanding one helps you understand the other.
π View solutionList five distinct things an attacker could achieve through a single SQLi, and for each note what it depends on (query type, DB privileges). Then explain why "we sanitize the login form" is an inadequate framing of the defence, given where injectable input can originate.
π View solutionChapter 1 Quick Reference
- SQLi = untrusted input mixed into a query so the database parses it as SQL code, not data
- Root cause = data treated as code β the same bug as XSS, aimed at the database instead of the browser
- The fatal pattern: building queries by string concatenation of user input
- Canonical breakout:
' OR '1'='1β the'closes the string, the rest becomes always-true logic β returns all rows / login bypass - The database can't distinguish developer SQL from injected input β it parses the final string
- Impact: read any data, bypass auth, modify/destroy data, sometimes run OS commands β a single param can breach the whole DB
- Injectable input is everywhere: URLs, forms, headers, cookies, JSON, even stored data β not just the login box
- One model spans injection bugs: keep data separate from the code channel (parameterized queries, Chapter 7)
- Next chapter: anatomy of an injection β string/numeric contexts, comments, and subverting a login
Anatomy of an Injection
Every injection has the same three moves: break out of the data context, inject your SQL, and fix up the rest of the query so it still parses. This chapter looks at how each move works β and the first question an attacker (or tester) asks is: what context is my input in?
The First Question: String or Numeric Context?
Where the input lands in the query determines how you break out of it β exactly like the injection contexts from the XSS course, but for SQL. The two main cases:
| Context | Query looks like | To break out you need⦠|
|---|---|---|
| String | WHERE name = 'INPUT' | a quote ' to close the string first |
| Numeric | WHERE id = INPUT | no quote β inject directly |
id=5 OR 1=1 works directly. And in string context, quote-stripping/escaping is fragile (encodings, multi-byte tricks, and it breaks legitimate data like O'Brien). The point: you cannot reliably defend by manipulating quotes β only by parameterizing (Chapter 7), which removes the question of context entirely.
Probing: Does It Even Inject?
Before crafting an attack, a tester confirms a parameter is injectable. The classic probes:
- A lone quote
'β if it causes a database error or changed behaviour, the input is reaching the query unescaped (string context). - Always-true vs always-false β
' OR '1'='1(more results) vs' AND '1'='2(no results). A difference proves your input is altering the query's logic. - Arithmetic β in numeric context,
id=5vsid=4+1: if both return the same record, the database evaluated4+1β it's numeric and injectable.
Comments: Cutting Off the Rest of the Query
After injecting, the developer's original query continues β often with a trailing quote or an AND password='...' that would break your syntax. The fix-up move is a SQL comment, which tells the database to ignore everything after it:
| Comment | Syntax | Database |
|---|---|---|
| Double-dash | -- (note the trailing space) | most (ANSI standard) |
| Hash | # | MySQL |
| Inline | /* β¦ */ | most |
Putting It Together: Subverting a Login
Now the classic auth-bypass in full. The login query:
An attacker who wants to log in as a specific known user (admin) without the password supplies the username:
admin'-- logs you in as a specific user (admin) by terminating the username and commenting away the password check. (2) ' OR '1'='1 (Chapter 1) makes the whole WHERE true and returns all users β you get logged in as whoever the app picks from the result set (often the first row). The first is precise; the second is a blunt "match everyone." Both exploit the same flaw, and both vanish under parameterized queries β and note this is also an authentication bypass (Auth course): account access with no credential, purely from a query-construction bug.
The Three Moves, Generalized
Every injection payload, however complex, is these three moves:
- Break out β a
'(string context) or nothing (numeric) to escape the data slot into code position. - Inject β your SQL: boolean logic (
OR 1=1), aUNION(Chapter 4), a stacked statement (Chapter 6), etc. - Fix up β a comment (
--) to discard the trailing query, or carefully balanced quotes so the whole thing still parses.
Recognizing these three moves lets you read any payload β and the next chapters expand the middle move (what you inject) for data extraction, blind inference, and beyond.
Hands-On Exercises
For a string-context query WHERE name = 'INPUT' and a numeric-context query WHERE id = INPUT, write an always-true injection for each. Explain why the numeric case needs no quote, and why "we strip quotes from input" fails to protect the numeric query.
Explain what role a SQL comment (-- ) plays in an injection. Given WHERE username='$u' AND password='$p', show the exact username payload that logs you in as admin with no password, and write out the resulting query. Note the trailing-space requirement of -- .
Break down the three moves (break out / inject / fix up) for the payload admin'-- and for ' OR '1'='1. Explain how the two login-bypass shapes differ (target a specific user vs match all), and confirm both are eliminated by parameterized queries.
Chapter 2 Quick Reference
- Every injection = break out (escape the data slot) β inject (your SQL) β fix up (comment/balance so it parses)
- String context (
name='INPUT') needs a'to break out; numeric context (id=INPUT) needs no quote - "We quote/strip quotes" is not a defence β numeric context injects with no quote; quote-handling is fragile and breaks real data
- Probe injectability: a lone
'(error),' OR '1'='1vs' AND '1'='2(logic change),4+1arithmetic (numeric) - Comments (
--with trailing space,#MySQL,/* */) cut off the rest of the developer's query - Login bypass:
admin'--(log in as a specific user) vs' OR '1'='1(match all) β both are auth bypass with no credential - All of these vanish under parameterized queries (Chapter 7) β context and breakout become irrelevant
- Next chapter: the types of SQLi β in-band (error/union), blind (boolean/time), and out-of-band
The Types of SQLi
All SQLi shares the root cause (Chapter 1) and the three moves (Chapter 2), but injections are classified by how the attacker gets the answer back. That channel depends on what the app shows you: query results? errors? nothing at all? The type dictates the technique β so the first thing a tester establishes is which type they're dealing with.
The Three Families
| Type | How data returns | Use when⦠|
|---|---|---|
| In-band | through the app's own response (results or errors) | the app shows query output or DB errors |
| Blind (inferential) | inferred from app behaviour (true/false, timing) | no output and no errors are shown |
| Out-of-band (OOB) | via a separate channel (DNS/HTTP from the DB) | no in-band feedback, but the DB can make network requests |
In-Band SQLi (the easy case)
The data comes back through the same channel you injected β the app's normal response. Two sub-types:
Error-Based
The app displays database error messages, and the attacker crafts input that makes the DB leak data inside an error (e.g. forcing a type-conversion error that includes a queried value). Verbose errors can reveal table names, column types, query structure, and even extracted values:
Union-Based
The most powerful in-band technique: UNION SELECT appends the results of a second query onto the original, so data from other tables appears right in the app's normal output (a product listing, search results). This is the workhorse of data extraction and gets its own chapter (Chapter 4).
Blind SQLi (no visible output)
When the app shows no query results and no errors, you're flying blind β but the injection still works; you just can't see the answer directly. Instead you ask the database true/false questions and read the answer from the app's behaviour. Two sub-types:
- Boolean-based β inject a condition and watch whether the page changes.
' AND 1=1--(page loads normally) vs' AND 1=2--(page differs / no results). By substituting real conditions (' AND SUBSTRING(password,1,1)='a'--), you extract data one character at a time from the true/false signal. - Time-based β when even the page response is identical for true and false, you make the database pause on "true":
' AND IF(condition, SLEEP(5), 0)--. A 5-second delay means the condition was true. You read data through the response time.
Blind extraction is slow (one bit/character per request) but completely effective β and trivially automated (Chapter 5 + sqlmap in Chapter 10). It's covered in depth next chapter-but-one.
Out-of-Band SQLi (a separate channel)
Sometimes there's no usable in-band output and no reliable behavioural/timing difference. If the database can make outbound network requests, the attacker exfiltrates data through a different channel β typically by making the DB perform a DNS or HTTP lookup to a domain they control, with the stolen data encoded in the request:
OOB is the fallback when in-band and blind are impractical (or to speed up exfiltration), and it depends on database features and network egress being available. It's less common but worth knowing exists.
Why the Type Doesn't Change the Fix
Note the through-line: error-based, union-based, boolean-blind, time-blind, and out-of-band are all the same vulnerability β an injectable query β read through different channels. Defenders sometimes try to close the channel (hide errors, normalize responses), which only forces the attacker to a different type. The fix targets the cause, not the readout: parameterized queries (Chapter 7) prevent the injection from happening at all, so there's nothing to read back through any channel.
Hands-On Exercises
Classify each scenario as in-band, blind, or out-of-band, and name the sub-type: (a) the app prints "SQL error nearβ¦" with table names; (b) a search page shows extra rows from another table after a UNION; (c) the page looks identical but takes 5s longer with a SLEEP payload; (d) the DB makes a DNS lookup to your domain. Justify each with "how does the data get back?"
Explain why hiding database error messages is good practice but not a fix for SQLi. Describe exactly what it changes for the attacker (which type they switch to) and why the underlying vulnerability is unchanged.
π View solutionExplain how boolean-based and time-based blind SQLi each extract a single character of a password, given no visible output. Then explain why blind SQLi is slower than union-based but equally effective, and why neither channel-closing defence stops the underlying injection.
π View solutionChapter 3 Quick Reference
- SQLi is classified by how the answer gets back β ask "can I see the data, and how?"
- In-band β data returns via the app's own response; sub-types: error-based (data leaked in errors) & union-based (appended results)
- Blind β no visible output; infer from behaviour: boolean-based (page changes on true/false) & time-based (
SLEEPdelay on true) - Out-of-band β exfiltrate via a separate channel (DB makes a DNS/HTTP request to attacker domain with data encoded in it)
- Blind extracts data one character at a time β slower than union, but fully effective and automatable
- Verbose DB errors leak schema/version/structure β show generic errors, log details β but this only downgrades to blind
- All types are the same vulnerability read through different channels β closing a channel just forces another type
- The fix targets the cause: parameterized queries (Chapter 7) prevent the injection entirely β nothing to read back anywhere
- Next chapter: union-based data extraction in depth β column counts, types, and reading the schema
UNION-Based Data Extraction
Chapter 3 introduced UNION-based SQLi as the most powerful in-band technique. This chapter works it in full, because it's the canonical way an attacker turns "I can inject" into "I have your entire users table." The idea: UNION lets you bolt a second query onto the first, so data from any table the DB account can read appears in the app's normal output. (Defensive framing as always β practise only on targets you own / training labs.)
What UNION Does
UNION SELECT combines the rows of two SELECT statements into one result set. If the app displays the results of its query, an injected UNION SELECT makes your chosen data show up alongside (or instead of) the legitimate rows:
But UNION has two strict requirements that the attacker must satisfy first, and the early steps of every union attack are about discovering them.
Requirement 1: Matching Column Count
A UNION requires both queries to return the same number of columns, or the database errors. So step one is finding how many columns the original query has. Two standard methods:
ORDER BY probing
Increase the ORDER BY column index until it errors β the last value that works is the column count:
UNION SELECT NULL probing
Or increase the number of NULLs until the union succeeds (NULL is type-compatible with anything, so it isolates the count question from the type question):
Requirement 2: Compatible Column Types
The columns you inject must be type-compatible with the original's columns in the positions you want to display β specifically, the data you want to read (usually text) must go in a column the app renders as a string. Find a string-friendly column by placing a test marker:
Whichever marker shows up in the page is a column you can exfiltrate string data through. Now you know the count and a usable column position β you can extract.
Reading the Schema: information_schema
To steal data you need to know what tables and columns exist. SQL databases expose their own structure through a metadata catalogue β most commonly information_schema β which you can query through the union:
SELECT username || ':' || password (standard / Postgres / Oracle), CONCAT(username,':',password) (MySQL), or username + ':' + password (SQL Server). One visible column then carries an entire row's worth of stolen data β which is how a single injectable search box dumps a whole credentials table row by row. (Syntax differs per database, which is also how attackers fingerprint which database they're hitting.)
The Full Attack, Start to Finish
- Confirm injectable β a lone
'or' OR '1'='1changes behaviour (Chapter 2). - Find the column count β
ORDER BY nuntil it errors, orUNION SELECT NULL,...until it works. - Find a displayable text column β place a string marker in each position.
- Map the schema β query
information_schemafor tables and columns. - Extract β
UNION SELECTthe target columns (concatenated if needed) and read them from the output.
information_schema, and dump tables β often in minutes, with no manual SQL. The practical consequence: a single union-injectable parameter is not a "theoretical" risk; it is a near-automatic full-database disclosure. And β the recurring point β none of this is possible if the query was parameterized (Chapter 7), because the input never becomes part of the SQL to UNION with.
Hands-On Exercises
Explain why a UNION attack must first determine the column count, and show both methods (ORDER BY probing and UNION SELECT NULL) for a query that turns out to have 3 columns. Explain why NULL is used in the second method.
Given a 3-column product search where only one column is displayed, write the sequence of payloads to: find which column shows text, list the tables, list the users columns, and dump username+password as a single concatenated value. Note the DB-specific concatenation syntax.
Walk the full 5-step union attack from "is it injectable?" to "whole table dumped," and explain why each step is mechanical/automatable (sqlmap). Then explain precisely why parameterized queries make the entire sequence impossible.
π View solutionChapter 4 Quick Reference
- UNION SELECT appends a second query's rows to the first β data from any readable table shows in the app's output
- Requirement 1 β column count must match: find it via
ORDER BY n(until error) orUNION SELECT NULL,NULL,β¦(until success) NULLis type-compatible with everything β isolates the count question from the type question- Requirement 2 β compatible types: place a string marker (
'aaa') per position to find a displayable text column - Read the schema via
information_schema.tables/.columnsβ list tables, then columns, then extract - Concatenate multiple values into one display column (
||/CONCAT/+, DB-specific) to dump a whole row through one slot - Full attack: confirm β column count β text column β map schema β extract β all mechanical & automated by sqlmap
- A single union-injectable parameter β full-database disclosure; impossible under parameterized queries (Chapter 7)
- Next chapter: blind SQLi in depth β boolean & time-based extraction when there's no visible output
Blind SQL Injection
Union extraction (Chapter 4) needs the app to show query results. Often it doesn't β a login that just says "success" or "failure," a search that returns a generic page. The query is still injectable, but you can't see the answer. Blind SQLi recovers the data anyway, by turning the application into a yes/no oracle and reading one bit at a time. It's slower than union-based, but no less complete β and fully automatable. (Defensive framing: own systems / training labs only.)
The Core Idea: The App as a Boolean Oracle
You can't see data, but you can ask the database a true/false question and observe whether the app behaves differently. If you can reliably distinguish "the condition was true" from "the condition was false," you can ask questions about the data and reconstruct it bit by bit. The two ways to get that true/false signal define the two sub-types.
Boolean-Based Blind
Here the page content differs (even subtly) between a true and a false condition β different text, different length, present-or-absent results, a 200 vs a 500. First establish the tell:
Now swap the constant for a question about the actual data, and read the answer from which page you get back:
Binary Search: ~7 Requests per Character
Testing every possible character one by one is wasteful. Instead use a binary search on each character's ASCII value β each request halves the remaining range, so a full byte (0β127 printable, really ~95 candidates) is pinned in about 7 requests:
Then advance to position 2 (SUBSTRING(...,2,1)), repeat, and you've reconstructed the whole password. You typically first extract the length (' AND LENGTH(password)=N) so you know when to stop.
Time-Based Blind
Sometimes the page is identical for true and false β no content difference at all. Then you manufacture your own signal: make the database pause when the condition is true, and read the answer from the response time:
| Database | Conditional delay |
|---|---|
| MySQL | ' AND IF(condition, SLEEP(5), 0)-- |
| PostgreSQL | ' AND (CASE WHEN condition THEN pg_sleep(5) ELSE pg_sleep(0) END)-- |
| SQL Server | '; IF (condition) WAITFOR DELAY '0:0:5'-- |
Why Blind Is Slow but Not Safe
The contrast with union-based is stark and worth internalizing:
| Union-based | Blind | |
|---|---|---|
| Data per request | whole values / rows / tables | one bit (true/false) |
| Speed | fast (seconds) | slow (1 char β 7 requests; +delay for time-based) |
| Needs visible output? | yes | no (content) / none at all (timing) |
| Recovers the same data? | yes | yes β eventually, completely |
sqlmap (Chapter 10) drives boolean and time-based blind extraction automatically, issuing the thousands of inference requests and reassembling the data with no manual effort. So the slowness of blind SQLi protects nobody in practice; the data comes out just the same. The lesson repeats: don't rely on hiding output β only parameterized queries (Chapter 7) remove the injectable oracle entirely, so there's no true/false to read and no delay to time.
Hands-On Exercises
Explain how a tester first establishes a boolean oracle (the true/false tell) on a page with no visible data, then walk a binary search extracting the first character of a secret, showing why it takes ~7 requests rather than ~95.
π View solutionExplain when you must use time-based instead of boolean-based blind, and write a MySQL time-based payload that tests whether the first character of the admin password is 'a'. Explain how the answer is read, and one reason time-based is noisier/less reliable.
A developer argues "our login only returns success/failure and shows no errors, so SQLi can't leak data." Rebut this using blind SQLi: explain how data still leaks with no output, why time-based works even on identical responses, and why only parameterization actually closes it.
π View solutionChapter 5 Quick Reference
- Blind SQLi β no visible output; turn the app into a true/false oracle and extract data one bit at a time
- Boolean-based β page content differs on true vs false (
' AND 1=1vs' AND 1=2); ask data questions and read the page - Binary search on each character's ASCII (
> 109?) β ~7 requests/char; grabLENGTH()first to know when to stop - Time-based β make the DB pause on true (
SLEEP/pg_sleep/WAITFOR DELAY); read the answer from response time - Time-based works even when responses are byte-for-byte identical β the universal fallback; only needs your SQL to execute
- Blind recovers the same data as union, just slower (one bit/request); βslowβ β βsafeβ β sqlmap automates it
- Hiding output/errors only forces boolean β time-based; it never removes the injectable oracle
- Only parameterized queries (Chapter 7) eliminate the oracle β no true/false to read, no delay to time
- Next chapter: beyond SELECT β INSERT/UPDATE/DELETE, stacked queries, and second-order SQLi
Beyond SELECT
So far the injections have read data through SELECT queries. But SQLi isn't limited to reads β it can modify data, chain whole new statements, and even lie dormant until triggered later. This chapter covers the write-side and the trickier delivery patterns, including the one that defeats input-time validation entirely. (Defensive framing β own systems / labs only.)
Injection Into Write Statements
Any statement built with user input is injectable, not just SELECT. An UPDATE or INSERT that concatenates input can be subverted to change other columns or rows:
The same applies to WHERE clauses on writes: an injectable UPDATE β¦ WHERE id=$id with id = 5 OR 1=1 updates every row. A DELETE with the same flaw deletes the whole table. So injection into a write is an integrity and availability attack, not just confidentiality.
Stacked Queries: Running Entirely New Statements
Some database drivers allow multiple statements separated by a semicolon in one call. Where that's enabled, an attacker can append a completely separate command after the original β turning a read into a write, a drop, or anything else:
mysql_query/PHP, and many parameterized-query APIs run exactly one statement), which is why the infamous '; DROP TABLE doesn't always work. But others do allow stacking (often SQL Server, PostgreSQL, and MySQL with multi-statements enabled), so it's a real risk, not a myth. The reassuring part isn't "stacking is usually off" β it's that parameterized queries don't just block stacking, they prevent the injection that would attempt it. Never rely on a driver setting as your defence.
Authentication Bypass β the Write/Logic Variant
Chapter 2 showed login bypass via SELECT. Note it ties to the whole Auth course: injection can also create or elevate accounts (the role='admin' trick above), reset another user's password through an injectable update, or satisfy a privilege check β turning a query-construction bug into full account compromise with no credential. SQLi is frequently the first step in a breach, then pivots through these write capabilities.
Second-Order SQLi: The Payload That Waits
The subtlest and most important pattern in this chapter. Second-order (stored) SQLi splits the attack across two requests: the payload is safely stored first, then triggers later when some other code reads it back into a query unsafely.
The Unifying Point
Reads, writes, stacked statements, and second-order delivery look different, but they're all the same root cause from Chapter 1 β untrusted input parsed as SQL β at different statement types and via different timing. And they all collapse under the same fix: a parameterized query binds input as data regardless of whether the statement is a SELECT or an UPDATE, whether stacking is on or off, and whether the value arrived just now or was read from a table. One discipline, applied to every query, closes all of it (Chapter 7).
Hands-On Exercises
Show how an injectable UPDATE users SET nickname='$n' WHERE id=$id can be used to (a) escalate the attacker's own privileges and (b) modify every row. Explain why injection into writes is an integrity/availability attack, not just data theft.
Explain what stacked queries are and give an example that turns a SELECT into a destructive command. Then explain why '; DROP TABLE doesn't always work, and why "our driver blocks multiple statements" is a weak thing to rely on.
Explain second-order SQLi end to end: how a payload is stored safely in request 1 and fires in request 2. Explain precisely why input validation/sanitization fails to stop it, and state the correct principle for deciding what data is "trusted."
π View solutionChapter 6 Quick Reference
- SQLi isn't only reads β INSERT/UPDATE/DELETE are injectable too: alter other columns/rows, escalate privileges (
role='admin'), delete everything - Injection into writes is an integrity & availability attack (modify/destroy data), not just confidentiality
- Stacked queries β a
;appends a whole new statement ('; DROP TABLE users--); works only where the driver allows multi-statements '; DROP TABLEoften fails because many drivers/APIs run one statement β but some allow stacking; don't rely on the driver setting- SQLi ties to the Auth course: create/elevate accounts, reset others' passwords β query bug β account compromise
- Second-order SQLi β payload stored safely in request 1, fires when a different path reads it into a query in request 2
- Second-order defeats input-time validation β the data comes from your own DB; trust by origin (a user), not by where it currently sits
- All variants = one root cause; all close under parameterizing every query (SELECT and writes, reading user input and your own tables)
- Next chapter: the primary defence β parameterized queries / prepared statements, in full
The Primary Defence: Parameterized Queries
Six chapters of attacks, all the same root cause and all ending with the same sentence: "...impossible under parameterized queries." This is that chapter. Parameterized queries (a.k.a. prepared statements with bound parameters) are the defence against SQL injection β not one option among several, but the actual fix that removes the vulnerability rather than papering over it. Everything else (Chapter 8) is defence in depth on top.
The Core Idea: Separate the Query From the Data
Recall the root cause (Chapter 1): SQLi happens because the query and the data are the same string, so the database can't tell which characters are code and which are input. Parameterization fixes this at the source by sending them on two separate channels:
- You send the query text with placeholders (
?or:name) where values go β and only the placeholders, no values. - You send the values separately, bound to those placeholders.
- The database parses and plans the query first (with placeholders), then slots the values in as pure data β after parsing is already done.
' in the input can change the query's structure. With a prepared statement, the database receives and parses the query template while the placeholders are still empty β the structure (which clauses, which tables, how many conditions) is locked in before your value exists in the picture. When the value is then bound, parsing is over; the value can only ever be data filling a slot, never new SQL syntax. A username of ' OR '1'='1 becomes a search for a user literally named "' OR '1'='1" β the quote is just a character. There is no breakout because there is nothing left to break out of.
Before & After
The Same Fix Across Languages
| Stack | Parameterized form |
|---|---|
| Node (mysql2/pg) | db.query("β¦ WHERE id = ?", [id]) / $1 in pg |
| PHP (PDO) | $stmt = $pdo->prepare("β¦ = ?"); $stmt->execute([$id]); |
| Python (sqlite3/psycopg) | cur.execute("β¦ = ?", (id,)) / %s |
| Java (JDBC) | PreparedStatement + setString(1, id) |
| C# (ADO.NET) | cmd.Parameters.AddWithValue("@id", id) |
db.query(f"β¦ WHERE id = {id}") (Python f-string), db.query(`β¦ = ${id}`) (JS template literal), "β¦ = " + id, or sprintf/%-formatting β these all produce a concatenated string with the value already baked in, exactly like the vulnerable version. The value must be passed as a separate argument to the driver (the [id] array, the execute() tuple, setString) so the driver does the binding. If the value is inside the query string, it's not parameterized β no matter how clean it looks.
What Parameters Can β and Can't β Bind
Placeholders bind values (the things in WHERE x = ?, VALUES (?), SET col = ?). They cannot bind identifiers β table names, column names, ORDER BY directions, LIMIT in some drivers β because those are part of the query structure, decided at parse time. This matters for dynamic queries:
const col = {name:'name', date:'created_at'}[req.query.sort] ?? 'name'; β the user picks a key, never the literal column name. This is the one place dynamic SQL is unavoidable, and the rule is strict: identifiers come from your allowlist, values come from parameters; user input is never directly placed into the query structure.
Why This Beats Escaping
Escaping (manually backslashing or doubling quotes in input) tries to make input safe to concatenate. It's strictly worse than parameterization and only a last resort (Chapter 8), because: it's easy to forget on one query out of hundreds; it's database- and charset-specific (multi-byte encoding tricks have bypassed escaping); it does nothing for numeric context (no quotes to escape, Chapter 2); and it corrupts legitimate data. Parameterization sidesteps all of it β there's nothing to escape because the data never enters the SQL text. Prefer parameterized queries; treat escaping as a fallback for the rare case you truly can't parameterize.
A Bonus: Performance and Clarity
Parameterized queries aren't only safer β they're often faster and cleaner. The database can parse and cache the query plan once and reuse it for many different parameter values (no re-parsing per request), and the code is more readable (no quote-juggling). Security, performance, and clarity all point the same way: there is essentially no reason to build queries by concatenation. The rule is simply: parameterize every query, every time.
Hands-On Exercises
Explain why parameterized queries stop SQLi at the root, focusing on the "parse before bind" mechanism. Show the vulnerable concatenated login query and its parameterized fix, and trace what happens to a ' OR '1'='1 username in each.
Identify which of these are actually parameterized and which are still vulnerable, and why: (a) db.query(`SELECT β¦ WHERE id = ${id}`); (b) db.query("SELECT β¦ WHERE id = ?", [id]); (c) cur.execute("β¦ = %s" % name); (d) cur.execute("β¦ = %s", (name,)). State the rule that distinguishes them.
A sortable table lets the user choose the sort column via ?sort=. Explain why you can't fix this with a bound parameter, and write the correct allowlist-based approach. Then explain why parameterization is preferred over escaping, covering numeric context and multi-byte bypasses.
Chapter 7 Quick Reference
- Parameterized queries / prepared statements are the fix β they remove the vulnerability, not mask it
- Send the query (with placeholders) and the values on separate channels; bind values as data
- Why it works: the DB parses the query before the data is bound, so input can only ever be a value, never SQL syntax β no breakout possible
- Same fix everywhere:
?/$1/:name/%s+ a separate values argument (Node, PHP PDO, Python, JDBC, ADO.NET) - String-formatting β parameterizing β f-strings, template literals,
+,sprintfbake the value into the query string β still vulnerable - Parameters bind values, not identifiers (table/column/
ORDER BY) β for those, use an allowlist, never concatenation - Prefer parameterization over escaping β escaping is fragile (charset/multi-byte), useless for numeric context, easy to forget; last resort only
- Bonus: prepared statements are often faster (cached plan) and clearer β no reason to concatenate
- Next chapter: defence in depth β validation, least privilege, ORMs/stored procedures, and where escaping/WAFs fit
Defence in Depth
Parameterized queries (Chapter 7) are the fix. This chapter is everything you add around them so that a single mistake isn't catastrophic β and, just as importantly, a clear-eyed account of what each extra layer does and does not achieve. The recurring caution: none of these replaces parameterization; they reduce likelihood and limit blast radius.
Layer 1: Input Validation (allowlist)
Validate that input matches its expected shape β an ID is a positive integer, an email looks like an email, a status is one of a fixed set. Reject anything off-format (an allowlist, not a blocklist of "bad characters"):
O'Brien, free-text comments, search) must accept the very characters payloads use, so you can't reject them without breaking the feature; blocklisting "bad" characters is endlessly bypassable; and it does nothing for second-order SQLi (Chapter 6), where the payload arrives from your own database. Validate for data quality and to shrink the attack surface β then still parameterize every query.
Layer 2: Least-Privilege Database Accounts
The app should connect to the database as an account with the minimum permissions it actually needs β not as root/sa/a DB owner. This doesn't prevent injection, but it dramatically limits the damage if one occurs:
- Read-only where possible β a reporting endpoint's account with only
SELECTcan't be used toUPDATE/DROPvia a stacked query. - Scope to needed tables β no access to tables the feature doesn't use.
- No dangerous privileges β no
FILE, no ability to create users, noxp_cmdshell; these are the rungs from SQLi to OS compromise (Chapter 1). - Separate accounts per service β a breach of one is contained.
Least privilege is the difference between "an attacker read one table" and "an attacker dropped the database and ran commands on the server."
Layer 3: Stored Procedures (only if parameterized inside)
Stored procedures are often cited as a SQLi defence β but the protection comes from how they use parameters, not from being a stored procedure:
EXECs a dynamic SQL string by concatenating its parameters is exactly as injectable as application-side concatenation β the vulnerability just moved into the database. Stored procedures can be a fine layer (and help with privilege separation), but the actual protection is the same one as always: parameters used as data, never concatenated. Don't treat "we use stored procs" as a substitute for that discipline.
Layer 4: ORMs & Query Builders
ORMs (Sequelize, Hibernate, Django ORM, ActiveRecord, etc.) parameterize by default β calling User.find({ where: { id } }) generates a bound query, which is why ORM-heavy code has far less SQLi. But they're not a force field: raw query escape hatches reintroduce it (covered fully next chapter). For now: ORMs are a strong layer because they make the safe path the default, but their raw-SQL methods are where injection creeps back.
Layer 5: Escaping β Last Resort Only
Manually escaping input (via a vetted, charset-correct library function) to make it safe to concatenate is the weakest option and should be reserved for the rare case you genuinely can't parameterize. Recap from Chapter 7: it's fragile (charset/multi-byte bypasses), useless for numeric context, easy to forget, and corrupts data. If you find yourself escaping, ask first whether you can parameterize or use an allowlist instead.
Layer 6: WAFs β A Backstop, Not a Fix
A Web Application Firewall inspects requests and blocks ones matching known SQLi patterns. It can stop opportunistic/automated attacks and buy time to patch β useful as an outer layer. But it's pattern-matching, so it's bypassable (encoding, comments, case tricks, novel payloads β the same blocklist-loses problem from the XSS course), and it does nothing about second-order injection or the underlying bug.
The Layered Picture
| Layer | What it does | Status |
|---|---|---|
| Parameterized queries | removes the vulnerability | THE fix (Chapter 7) |
| Input validation (allowlist) | shrinks attack surface; rejects malformed input | defence in depth |
| Least privilege | limits blast radius if injected | defence in depth (critical) |
| Stored procedures | safe only if internally parameterized | conditional |
| ORM | makes safe the default; raw queries leak | strong default |
| Escaping | fragile concatenation safety | last resort |
| WAF | blocks known patterns at the perimeter | backstop |
Stack them β but the load-bearing wall is parameterization; everything else assumes it's there and exists to catch what slips through (a forgotten query, a new bug) and to contain the damage.
Hands-On Exercises
Explain why input validation helps but cannot replace parameterized queries. Give a field where allowlist validation genuinely blocks injection and one where it can't (without breaking the feature), and explain why second-order SQLi defeats input validation entirely.
π View solutionExplain why least-privilege database accounts don't prevent SQLi but are still essential. For a read-only reporting endpoint, list the privileges its DB account should and shouldn't have, and describe how each restriction limits a specific attack from earlier chapters (stacked DROP, FILE read, xp_cmdshell).
π View solutionDebunk two myths: "stored procedures prevent SQLi" and "our WAF protects us." For each, show the case where it fails (a concatenating proc; a WAF bypass / second-order), and state its correct role as defence in depth alongside parameterization.
π View solutionChapter 8 Quick Reference
- Defence in depth layers around parameterization β none replaces it; they cut likelihood & blast radius
- Input validation (allowlist) β reject malformed input; helps strict-format fields, but not a fix (free-text fields, second-order) β same lesson as XSS
- Least privilege β app connects with minimum rights (read-only where possible, no FILE/xp_cmdshell, scoped tables) β contains damage, doesn't prevent injection
- Stored procedures β safe only if they use bound parameters internally; a proc that concatenates dynamic SQL is just as injectable
- ORMs β parameterize by default (strong), but raw-query escape hatches reintroduce SQLi (Chapter 9)
- Escaping β last resort; fragile (charset/multi-byte), useless for numeric context, easy to forget
- WAF β perimeter backstop against known patterns; bypassable & blind to second-order β never a substitute for fixing the code
- Stack them, but the load-bearing wall is parameterized queries; the rest catches slips and limits damage
- Next chapter: SQLi in modern stacks β ORM pitfalls and NoSQL injection
SQLi in Modern Stacks
ORMs and NoSQL databases made classic string-concatenated SQLi rarer β but, exactly like framework auto-escaping did for XSS (the "less, not gone" story), they didn't eliminate injection. They moved it. This chapter covers where injection hides in ORM-heavy code and the NoSQL cousin that catches teams who think "we don't use SQL, so we're safe."
ORMs: Safe by Default, Until the Escape Hatch
ORMs (Sequelize, Prisma, Hibernate/JPA, Django ORM, ActiveRecord, Eloquent) generate parameterized queries automatically for normal operations β which is why ORM code has far less SQLi. The danger is the raw-SQL escape hatches every ORM provides, where you drop to hand-written SQL and the auto-parameterization stops:
dangerouslySetInnerHTML. Search for: sequelize.query / QueryRaw, $queryRawUnsafe (Prisma), .raw() / extra() (Django), find_by_sql / string conditions (ActiveRecord), createQuery with string concatenation (Hibernate/JPQL), and DB::raw / whereRaw (Laravel). Each is a spot where a developer left the safe default β and where injection re-enters if user input is concatenated rather than passed as a binding.
ORM Pitfalls Beyond Raw Queries
Even without dropping to raw SQL, ORMs have sharper edges than "always safe":
- Concatenating into a raw fragment β
whereRaw("age > " + input)is injectable even though it's "using the ORM." - Dynamic column/table/order names β ORMs can't parameterize identifiers either (Chapter 7); passing a user-chosen column/sort needs an allowlist, or it's injectable.
- Operator/structure injection β some ORMs build queries from objects; if you spread untrusted JSON straight into a
whereclause, an attacker may inject operators (this overlaps with NoSQL injection below). - "LIKE" and wildcard handling β user input in a
LIKEneeds its wildcards (%,_) handled, separate from SQLi but a related correctness/abuse issue.
NoSQL Injection: The Cousin That Surprises People
"We use MongoDB, so SQL injection doesn't apply." True for SQL injection β but NoSQL databases have their own injection class, the same root cause (untrusted input changing query structure) in a different query language. The classic is operator injection in MongoDB:
{$ne: null}), and because the code spread the request body straight into the query, that operator became part of the query's logic. That's structurally identical to SQLi: input crossing the boundary from data into query semantics. Other MongoDB vectors include $where (which can run JavaScript) and $regex for inference (a NoSQL analogue of blind extraction). The mental model from Chapter 1 transfers exactly β only the syntax differs.
Defending NoSQL
- Validate types & structure β ensure
passwordis a string, not an object; reject request fields that are objects/arrays where a scalar is expected. This kills operator injection at the source. - Cast/coerce inputs β explicitly treat values as the type you expect (
String(req.body.password)) before they reach the query. - Avoid dangerous operators β disable/forbid
$whereand JavaScript execution; don't pass user input into$regexunescaped. - Use the driver/ODM safely β Mongoose schemas enforce types (a String field rejects an object); don't bypass them with loose queries built from raw request bodies.
The Persistent Lesson
Across ORMs and NoSQL, the same pattern from the XSS course recurs: modern tools make the safe path the default, which slashes injection rates, but every escape hatch and every "spread untrusted input into the query" is where it returns. The defences also rhyme β keep untrusted input as data, never let it become query structure: parameterize raw SQL, allowlist identifiers, and validate types so a JSON object can't sneak in where a string belongs. The framework helps; it doesn't absolve you of the data-vs-code discipline.
Hands-On Exercises
You're security-reviewing an ORM-based codebase. Explain why normal ORM calls are safe and list the specific raw-query methods/patterns you'd grep for across a few ORMs. Then give a "looks like ORM but is injectable" example and its fix.
π View solutionExplain MongoDB operator injection using the login example. Show how { "password": { "$ne": null } } bypasses authentication, why it's the same root cause as SQLi, and write the type-validation fix that stops it.
A team says "we switched to an ORM and MongoDB, so injection is no longer a concern." Rebut this, naming the residual risks in both (ORM raw/identifier/operator injection; NoSQL operator/$where/$regex), and state the unifying principle and defences that still apply.
π View solutionChapter 9 Quick Reference
- ORMs & NoSQL made classic SQLi rarer, not gone β same "less, not gone" story as XSS framework escaping
- ORMs parameterize by default; injection returns through raw-SQL escape hatches (concatenation in
sequelize.query,$queryRawUnsafe,.raw(),whereRaw,find_by_sql,DB::raw) - Audit ORM code by grepping the raw-query methods (like XSS's escape hatches)
- Other ORM pitfalls: concatenating into raw fragments, dynamic identifiers (need an allowlist), operator/structure injection, LIKE wildcards
- NoSQL injection β same root cause, different language; MongoDB operator injection:
{ "password": { "$ne": null } }= always-true β auth bypass - Other Mongo vectors:
$where(runs JS),$regex(blind-style inference) - Defend NoSQL: validate types/structure (password must be a string, not an object), coerce inputs, forbid
$where, use schema enforcement (Mongoose) - Unifying rule: keep input as data, never query structure β parameterize raw SQL, allowlist identifiers, validate types
- Next chapter: testing, tooling (sqlmap) & the hardening checklist β the course finale
Testing, Tooling & Hardening Checklist
The finale turns the course into practice: how to test for SQLi, how the standard tooling works, the broken-defence patterns to recognize, and a single deployable checklist that pulls all ten chapters together. (Test only systems you own or are authorized to assess.)
How to Test for SQLi
- Map every input that reaches a query β URL params, form fields, JSON bodies, headers, cookies β and remember stored values too (second-order, Chapter 6).
- Probe injectability β a lone
'(error/changed behaviour),' OR '1'='1vs' AND '1'='2(logic change), arithmetic like4+1in numeric params (Chapter 2). - Identify the context & type β string vs numeric (Ch. 2); in-band, blind, or out-of-band (Ch. 3) β this dictates the technique.
- Confirm impact safely β extract something harmless and unique (e.g.
@@version/version()), not destructive payloads, to prove the finding. - Check for blind β if no output/errors, test boolean (page difference) and time-based (
SLEEP) inference (Chapter 5).
information_schema, and dumps tables β and can escalate to reading files or OS commands where privileges allow. The lesson for defenders is sobering: everything attackers do in Chapters 2β6 is fully automated and requires no manual SQL. A single injectable parameter is, in practice, a one-command full-database disclosure. (Use sqlmap only against authorized targets / labs like PortSwigger, DVWA, Juice Shop.) Other tools: Burp Suite / OWASP ZAP scanners flag candidates; sqlmap confirms and exploits.
The Broken-Defence Catalogue
| Broken practice | Why it fails | Chapter |
|---|---|---|
| String-concatenated queries | the root cause β input parsed as SQL | 1, 2 |
| "We quote/strip quotes" | numeric context needs no quote; fragile escaping | 2 |
| Hiding DB errors as "the fix" | only downgrades error-based to blind | 3 |
| "No output, so it can't leak" | time-based blind works on identical responses | 5 |
| Validating only "user input" | second-order injection from stored data | 6 |
| String-formatting β parameterizing | f-strings/template literals bake value into SQL | 7 |
| "Stored procedures are safe" | concatenating procs are still injectable | 8 |
| "We have a WAF" | bypassable; blind to second-order | 8 |
| "We use an ORM / NoSQL, so we're safe" | raw queries; operator injection | 9 |
The Hardening Checklist
$where; schema enforcementHow the Course Fits Together
The arc: understand the bug (Ch. 1 data-vs-code, Ch. 2 anatomy), the channels & techniques (Ch. 3 types, Ch. 4 union, Ch. 5 blind, Ch. 6 writes/stacked/second-order), then the defences β parameterized queries as the foundation (Ch. 7), defence in depth around it (Ch. 8), the realities of modern ORMs/NoSQL (Ch. 9), and operational testing/hardening (Ch. 10). It also pairs with the wider security set: SQLi and XSS are the two great injection bugs (database vs browser); Auth is what SQLi login-bypass attacks; and like all of them, the defence is a discipline applied everywhere, because the weakest query sets your exposure.
Hands-On Exercises
Write a SQLi test plan for a single endpoint: how you'd map inputs (including stored/second-order), probe injectability, identify context and type, and safely confirm impact. Explain what sqlmap automates and why "a single injectable parameter" is a serious finding.
π View solutionAudit this app against the broken-defence catalogue: it concatenates a numeric id, "strips single quotes" from inputs, hides DB errors, validates only request inputs (not stored data), uses a stored procedure that builds dynamic SQL, and relies on a WAF. List every flaw, its chapter, and the fix.
Produce a prioritized SQLi hardening plan for a new app (relational DB, an ORM, a couple of raw reporting queries, a search feature). Order the measures, justify the ordering, and explain how the one core principle (data vs code) unifies SQLi with XSS and connects to the Auth course.
π View solutionChapter 10 Quick Reference
- Test: map all inputs (incl. stored/second-order) β probe (
',OR 1=1, arithmetic) β identify context/type β confirm safely (version()) β check blind - sqlmap automates detect β type/context β DBMS fingerprint β enumerate schema β dump tables (β files/OS where allowed); a single injectable param β full-DB disclosure
- Broken defences: concatenation Β· quote-stripping Β· hiding errors Β· "no output" Β· validating only user input Β· string-formatting Β· "stored procs/WAF/ORM make us safe"
- Checklist: parameterize everything Β· allowlist identifiers Β· parameterize reads of own data Β· validate types Β· least privilege Β· generic errors Β· ORM raw audit Β· NoSQL type validation Β· WAF backstop Β· test
- The one principle: untrusted input stays data, never query code β parameterization sends query & data on separate channels
- Defence in depth reduces likelihood/blast radius; the load-bearing wall is parameterized queries
- Same principle as XSS (encode on output); SQLi & XSS are the two big injection families β database vs browser
β SQL Injection Complete β 10 / 10 chapters
From data-vs-code foundations through injection anatomy, the in-band/blind/out-of-band types, union extraction, blind inference, writes/stacked/second-order, then the defences β parameterized queries as the one real fix, defence in depth, modern ORM/NoSQL realities, and a deployable testing & hardening checklist. Paired with HTTPS, CSRF, XSS, and Authentication, you now hold a five-course web-security set β and the unifying truth across all the injection bugs: keep untrusted input as data, never let it become code.