Skip to main content

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:

  1. The developer writes a query with placeholder insertion points.
  2. User input is concatenated directly into the SQL string.
  3. The attacker includes SQL meta-characters (', ", --, ;, /*) that alter the parsed query structure.
  4. The database executes the attacker's modified query under the application's connection privileges.
  1. 1. Attacker

    Sends a value containing SQL meta-characters (' -- ;)

  2. 2. Application

    Concatenates the value into the SQL string

  3. 3. Database

    Parses the altered query structure

  4. 4. Database

    Executes attacker logic with the app's privileges

SQL injection query flow: attacker input crosses from data into SQL syntax at the concatenation step.

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.

ClassMechanismDetection signalExample payload
Classic (in-band)Data returned directly in the HTTP responseVisible 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 conditionsResponse differs between 1=1 and 1=2' OR SUBSTRING(password,1,1)='a' --
Blind, time-basedConditional 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 usesNetwork logs show outbound connection to external hostDECLARE @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 unsafelyInput validation passes at write; exploit triggers at read in a different code pathRegister 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_password endpoint 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 data

docker-compose.yml

version: "3.9"
services:
  sqli-lab:
    build: .
    ports:
      - "8001:5000"
    environment:
      FLASK_ENV: development

app.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.0

Dockerfile

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:

#CheckMethod
1All database queries use parameterized statements or ORM-safe APIsCode review: search for execute() calls with string concatenation operators
2Raw SQL escape-hatch usage is audited and minimizedSAST scan with taint tracking (Semgrep, CodeQL)
3Stored procedures avoid dynamic SQL (EXEC/EXECUTE with concatenation)Database code review
4Application connects with least-privilege database user (no DROP/ALTER/INSERT on production read paths); row access still needs object-level authorization checksDatabase permission audit
5Database error messages are never returned to the userManual test: send ' and inspect response
6WAF or IDS rules cover SQL injection patternsValidate Suricata/modsec rules against test payloads
7CI/CD has a SAST gate blocking SQLi patternsVerify 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

Related Guides

All security guides by attack family