SQL Injection Explained: Attack Examples & Prevention
SQL injection lets an attacker turn user input into executable SQL commands. It is CWE-89 (Improper Neutralization of Special Elements used in an SQL Command) and has been the top entry in the CWE Top 25 for years. This guide covers the mechanics, a reproducible local lab, blind and out-of-band attack classes, detection rules, and parameterized-query fix patterns.
What SQL injection is and why it keeps mattering
SQL injection occurs when an application builds a SQL query by concatenating or interpolating user-supplied input into the query string. The attacker's input changes the query's syntactic structure: a fragment that was intended as a string literal value instead becomes part of the SQL command. The web server sends the manipulated query to the database, which executes the attacker's logic with the application's database privileges.
CWE-89 consistently ranks #1 in the CWE Top 25 most dangerous software weaknesses. OWASP maps SQL injection to A03:2021 Injection. The same root cause (untrusted data parsed as code) drives server-side template injection and insecure deserialization. Despite being one of the best-documented vulnerability classes, SQL injection continues to appear in major products: CVE-2026-72898 (Metabase, CVSS 10.0, CISA KEV) is a recent unauthenticated SQLi added to the Known Exploited Vulnerabilities Catalog in August 2026.
Attack anatomy and query-flow model
Every SQL injection follows the same query-flow pattern:
- The developer writes a query with placeholder insertion points.
- User input is concatenated directly into the SQL string.
- The attacker includes SQL meta-characters (
',",--,;,/*) that alter the parsed query structure. - The database executes the attacker's modified query under the application's connection privileges.
1. Attacker
Sends a value containing SQL meta-characters (' -- ;)
2. Application
Concatenates the value into the SQL string
3. Database
Parses the altered query structure
4. Database
Executes attacker logic with the app's privileges
A classic example in Python (Flask):
# VULNERABLE
username = request.args.get("username")
sql = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(sql)An attacker sending username=admin' OR '1'='1 produces the query:
SELECT * FROM users WHERE username = 'admin' OR '1'='1'The OR '1'='1' clause evaluates to true for every row, returning all users instead of the intended single user.
SQL injection attack classes
Not all SQL injection attacks look the same. The database response format determines which technique the attacker uses.
| Class | Mechanism | Detection signal | Example payload |
|---|---|---|---|
| Classic (in-band) | Data returned directly in the HTTP response | Visible column data or error messages in the page | ' UNION SELECT credit_card FROM payments -- |
| Blind, Boolean (content-based) | Application returns different response (size, status, content) for true vs false conditions | Response differs between 1=1 and 1=2 | ' OR SUBSTRING(password,1,1)='a' -- |
| Blind, time-based | Conditional causes a measurable database delay (e.g. SLEEP(), heavy query) | Response latency varies by condition | '; IF (1=1) WAITFOR DELAY '0:0:5' -- |
| Out-of-band (OOB) | Data exfiltrated via a separate channel (DNS, HTTP, SMB) to an attacker-controlled server, the same channel blind XXE uses | Network logs show outbound connection to external host | DECLARE @o INT; EXEC sp_oacreate MSXML2.ServerXMLHTTP, @o OUT |
| Second-order (stored) | Malicious payload is stored in the database and triggers when later retrieved and used unsafely | Input validation passes at write; exploit triggers at read in a different code path | Register username admin'; DROP TABLE logs; --, then profile page executes it |
Real-world SQL injection incidents
These recent and notable incidents demonstrate that SQL injection is not a legacy problem:
- CVE-2026-72898, Metabase (CVSS 10.0, CISA KEV): An unauthenticated attacker injects arbitrary SQL via the
/reset_passwordendpoint to gain administrator access. CISA confirmed active exploitation. NVD entry. - CVE-2026-34612, CVE-2026-34717 (April 2026): Recent SQLi vulnerabilities in enterprise software tracked in the Gecko Security SQLi CVE database. Multiple CVEs continue to appear each month.
- Capital One 2019 (SSRF, not SQLi): The attacker exploited SSRF against the AWS metadata service to obtain IAM credentials, then accessed 100+ million records from an S3 bucket. This was an SSRF breach, not SQLi. It is listed here because the WAF misconfiguration that allowed it is the same class of input-validation failure.
- Heartland Payment Systems 2008: 134 million credit card records stolen via SQL injection in the payment processing pipeline. The attacker used SQLi to install a custom sniffer that captured track data.
Reproducible local lab: Docker Compose
The following lab runs a Flask application with SQLite, exposing both a vulnerable endpoint and a fixed endpoint. The Docker Compose setup requires Docker and a few seconds to build.
Project structure
sqli-lab/
├── docker-compose.yml
├── requirements.txt
├── app.py # Flask app: /search (vuln) and /search-safe (fixed)
└── init.sql # Seeds the database with sample datadocker-compose.yml
version: "3.9"
services:
sqli-lab:
build: .
ports:
- "8001:5000"
environment:
FLASK_ENV: developmentapp.py (vulnerable and fixed endpoints)
from flask import Flask, request, jsonify
import sqlite3
app = Flask(__name__)
DATABASE = "/data/app.db"
def get_db():
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
return conn
# ---------- VULNERABLE ----------
@app.route("/search")
def search_vuln():
q = request.args.get("q", "")
conn = get_db()
sql = f"SELECT id, name, email FROM users WHERE name LIKE '%{q}%'"
try:
rows = conn.execute(sql).fetchall()
return jsonify([dict(r) for r in rows])
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
# ---------- FIXED (parameterized) ----------
@app.route("/search-safe")
def search_safe():
q = request.args.get("q", "")
conn = get_db()
sql = "SELECT id, name, email FROM users WHERE name LIKE ?"
try:
rows = conn.execute(sql, (f"%{q}%",)).fetchall()
return jsonify([dict(r) for r in rows])
except Exception as e:
return jsonify({"error": "internal error"}), 500
finally:
conn.close()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)init.sql
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
role TEXT DEFAULT 'user'
);
INSERT INTO users (name, email, role) VALUES
('Alice Admin', 'alice@example.com', 'admin'),
('Bob User', 'bob@example.com', 'user'),
('Charlie User', 'charlie@example.com', 'user'),
('David Dev', 'david@example.com', 'dev');requirements.txt
flask==3.1.0Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py init.sql .
RUN mkdir -p /data && apt-get update && apt-get install -y --no-install-recommends sqlite3 && sqlite3 /data/app.db < init.sql && rm -rf /var/lib/apt/lists/*
CMD ["python", "app.py"]Running the lab
docker compose up --build -d
# Test the vulnerable endpoint — normal query
curl "http://localhost:8001/search?q=Bob"
# SQLi — return all users
curl "http://localhost:8001/search?q=' OR 1=1 --"
# SQLi — UNION extraction of database metadata
curl "http://localhost:8001/search?q=' UNION SELECT 1, name, sql FROM sqlite_master --"
# Blind boolean — detect via response difference
curl "http://localhost:8001/search?q=' OR 1=1 --" # all rows
curl "http://localhost:8001/search?q=' OR 1=2 --" # no rows
# Fixed endpoint — same payloads return empty/no results (parameterized)
curl "http://localhost:8001/search-safe?q=' OR 1=1 --"The fixed endpoint uses a parameterized query (LIKE ?) where the user input is passed as data, not concatenated into the SQL string. The ' OR 1=1 -- payload becomes the literal search string ' OR 1=1 --: no rows match, and no injection occurs.
Detection rules: Semgrep
Semgrep can find SQL injection patterns statically by tracing tainted input into dangerous database APIs. The following rule catches string concatenation and f-string patterns in Python.
rules:
- id: python-sqli-detection
patterns:
- pattern-either:
- pattern: |
f"...$QUERY..."
- pattern: |
"..." + $QUERY + "..."
- pattern: |
"...".format($QUERY)
- metavariable-regex:
metavariable: $QUERY
regex: .*(request\.|input|args|form).*
- pattern-inside: |
$CURSOR.execute(...)
message: >
Potential SQL injection: user input concatenated into a query.
Use a parameterized query with ?/placeholder syntax instead.
severity: ERROR
languages: [python]Semgrep also ships a SQL injection-specific learning module with rules for Java, C#, Node.js, and Go.
Detection rules: Suricata (network IDS)
Suricata can detect SQL injection payloads transiting the network in HTTP requests. These rules match common SQL meta-character sequences and SQL keywords in query parameters.
# SQL injection — classic tautology payloads
alert http any any -> any any (
msg:"SQL Injection — tautology detection (OR 1=1)";
flow:established,to_server;
content:"OR";
nocase;
within:10;
pcre:"/(\bOR\b|\bAND\b)\s+[0-9]+(=|!=|<|>)\s*[0-9]+/i";
classtype:web-application-attack;
sid:1000001;
rev:1;
)
# SQL injection — UNION SELECT
alert http any any -> any any (
msg:"SQL Injection — UNION SELECT";
flow:established,to_server;
content:"UNION";
nocase;
within:15;
content:"SELECT";
nocase;
within:10;
classtype:web-application-attack;
sid:1000002;
rev:1;
)
# SQL injection — stacked query (semicolon injection)
alert http any any -> any any (
msg:"SQL Injection — stacked query attempt";
flow:established,to_server;
pcre:"/[?&][a-zA-Z_]+=[^&\s]*'\s*;/Ui";
classtype:web-application-attack;
sid:1000003;
rev:1;
)
# SQL injection — SLEEP/WAITFOR time-based
alert http any any -> any any (
msg:"SQL Injection — time-based (SLEEP/WAITFOR)";
flow:established,to_server;
pcre:"/(SLEEP|WAITFOR\s+DELAY|pg_sleep|DBMS_LOCK\.SLEEP)/i";
classtype:web-application-attack;
sid:1000004;
rev:1;
)These rules produce false positives on parameter values that genuinely contain SQL-like text. Tune by excluding trusted paths or using rate-based thresholds.
Fix patterns by language
The universal fix is parameterized queries (prepared statements): the SQL query structure is defined first, and data values are bound as parameters that the database engine keeps separate from the command text. Escaping is strongly discouraged (OWASP Defense Option 4) and should never be the primary defense.
Java (JDBC PreparedStatement)
// VULNERABLE
Statement stmt = conn.createStatement();
String sql = "SELECT * FROM users WHERE name = '" + request.getParameter("name") + "'";
ResultSet rs = stmt.executeQuery(sql);
// FIXED — parameterized query
String sql = "SELECT * FROM users WHERE name = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, request.getParameter("name"));
ResultSet rs = pstmt.executeQuery();C# (.NET SqlCommand)
// VULNERABLE
string sql = "SELECT * FROM users WHERE name = '" + Request["name"] + "'";
SqlCommand cmd = new SqlCommand(sql, conn);
// FIXED — parameterized
string sql = "SELECT * FROM users WHERE name = @name";
SqlCommand cmd = new SqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@name", Request["name"]);Python (sqlite3 / psycopg2 / mysql-connector)
# VULNERABLE
sql = f"SELECT * FROM users WHERE name = '{input_name}'"
cursor.execute(sql)
# FIXED — parameterized with ? placeholders
sql = "SELECT * FROM users WHERE name = ?"
cursor.execute(sql, (input_name,))
# PostgreSQL uses %s placeholders
sql = "SELECT * FROM users WHERE name = %s"
cursor.execute(sql, (input_name,))Node.js (pg / mysql2)
// VULNERABLE
const sql = "SELECT * FROM users WHERE name = '" + req.query.name + "'";
db.query(sql, callback);
// FIXED — parameterized with $1 placeholders (pg)
const sql = "SELECT * FROM users WHERE name = $1";
db.query(sql, [req.query.name], callback);
// mysql2 uses ? placeholders
const sql = "SELECT * FROM users WHERE name = ?";
db.query(sql, [req.query.name], callback);Go (database/sql)
// VULNERABLE
sql := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", r.URL.Query().Get("name"))
rows, _ := db.Query(sql)
// FIXED — parameterized with ? placeholders
sql := "SELECT * FROM users WHERE name = ?"
rows, err := db.Query(sql, r.URL.Query().Get("name"))ORM pitfalls
ORM libraries (SQLAlchemy, Entity Framework, Prisma, GORM) parameterize queries by default, but raw SQL escape-hatch methods bypass that protection:
# SQLAlchemy — VULNERABLE (raw SQL with format)
session.execute(text(f"SELECT * FROM users WHERE name = '{name}'"))
# SQLAlchemy — SAFE (bind parameter)
session.execute(text("SELECT * FROM users WHERE name = :name"), {"name": name})Verification checklist
Use this checklist to verify SQL injection coverage in your application codebase and pipeline:
| # | Check | Method |
|---|---|---|
| 1 | All database queries use parameterized statements or ORM-safe APIs | Code review: search for execute() calls with string concatenation operators |
| 2 | Raw SQL escape-hatch usage is audited and minimized | SAST scan with taint tracking (Semgrep, CodeQL) |
| 3 | Stored procedures avoid dynamic SQL (EXEC/EXECUTE with concatenation) | Database code review |
| 4 | Application connects with least-privilege database user (no DROP/ALTER/INSERT on production read paths); row access still needs object-level authorization checks | Database permission audit |
| 5 | Database error messages are never returned to the user | Manual test: send ' and inspect response |
| 6 | WAF or IDS rules cover SQL injection patterns | Validate Suricata/modsec rules against test payloads |
| 7 | CI/CD has a SAST gate blocking SQLi patterns | Verify CI pipeline fails on Semgrep/CodeQL SQLi findings |
Key takeaways
- SQL injection is CWE-89, the #1 weakness in the CWE Top 25 for multiple years running.
- Every database query with user-controlled input must use parameterized queries (prepared statements). String concatenation or interpolation of user input is never safe.
- Blind (boolean/time-based) and out-of-band SQLi let attackers exfiltrate data even when no query results are visible in the HTTP response.
- ORMs are not a silver bullet: their raw SQL escape-hatch methods reintroduce injection if used unsafely.
- Least-privilege database accounts limit what an attacker can do after injection: separate read-only accounts for queries, separate accounts for DDL.
- SAST (Semgrep, CodeQL) plus network-layer IDS (Suricata/modsec) provides layered detection, but neither replaces parameterized queries at the application level.
Frequently asked questions
- What is SQL injection?
- SQL injection (CWE-89) happens when an application builds a SQL query by concatenating user input into the query string, so the input changes the query structure instead of staying a data value. The database then runs the attacker-modified query with the application's privileges.
- Do parameterized queries fully prevent SQL injection?
- They prevent it for every value they bind: the query structure is fixed before the input is attached, so input can never become SQL syntax. Identifiers such as table or column names cannot be bound as parameters and must be checked against an allowlist instead.
- Does an ORM protect against SQL injection?
- By default, yes: ORMs such as SQLAlchemy, Entity Framework, Prisma and GORM parameterize their queries. Their raw-SQL escape hatches do not: formatting user input into text() or FromSqlRaw reintroduces injection, so those calls need an audit and bind parameters.
- How is blind SQL injection detected when no data is returned?
- Blind SQLi is inferred from side effects: a boolean-based payload changes the response between a true and a false condition, and a time-based payload makes the database pause. Out-of-band SQLi shows up as outbound DNS or HTTP connections from the database host.
Kokkuvõte eesti keeles
SQL-injektsioon (CWE-89) on nõrkus, kus kasutaja sisend liidetakse otse SQL-päringusse, võimaldades ründajal muuta päringu loogikat. See on olnud CWE Top 25 nimekirjas esikohal juba aastaid. Levinuimad ründevormid on klassikaline (in-band), pime SQLi (boolean ja ajapõhine), out-of-band ja teist järku (second-order) SQLi.
Kaitseks tuleb kasutada parametriseeritud päringuid (prepared statements): SQL-päringu struktuur defineeritakse enne ja kasutaja sisend seotakse parameetrina, mis hoitakse käsustusest eraldi. ORM-teekide toor-SQL võimalused (raw SQL) tuleb auditeerida. Andmebaasiõigused peaksid olema minimaalsed: lugemispäringuteks eraldi kasutaja, skeemimuudatusteks teine.
Semgrep ja CodeQL avastavad SQLi staatilisest analüüsist, Suricata tuvastab seda võrguliikluses. Kumbki ei asenda parametriseeritud päringuid, need on esmane kaitsekiht. Olulised reaalsed näited: Metabase CVE-2026-72898 (CVSS 10.0, CISA KEV), Heartland Payment Systems (134 miljonit kaardikirjet).
Sources
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command
- OWASP SQL Injection Prevention Cheat Sheet
- OWASP Top 10:2021: A03 Injection
- Semgrep: SQL Injection learning module
- NVD: CVE-2026-72898 (Metabase SQLi, CVSS 10.0)
- CISA Known Exploited Vulnerabilities Catalog
- PortSwigger Web Security Academy: SQL injection
- OWASP: SQL Injection attack description
- OWASP: Blind SQL Injection
- Bobby Tables: parameterized query examples in every language