The Bug That Refuses to Die
In late 2023, the MOVEit Transfer breach hit roughly 2,600 organizations and exposed records belonging to over 90 million people. The root cause? A SQL injection vulnerability tracked as CVE-2023-34362. That’s not 1998. That’s a flaw class older than most of the engineers shipping code today, still cracking open production databases in the year ChatGPT writes half the queries.
SQL injection attacks should be dead by now. They aren’t. They sit comfortably in the OWASP Top 10 year after year, and every penetration test I run still surfaces at least one injectable parameter — usually somewhere nobody thought to look. A reporting endpoint. A legacy admin panel. A search box that was “temporary.”
This is my position, and I’ll defend it for the rest of the article: SQL injection persists not because the defense is hard, but because organizations treat it as a solved problem and stop looking. The fix is well-documented. The discipline to apply it everywhere is not.
How SQL Injection Attacks Actually Work
The mechanic hasn’t changed in twenty years. An application builds a query by concatenating user input into a SQL string. The attacker supplies input that breaks out of the expected data context and becomes code.
The textbook example, but worth showing because people still write logins this way:
-- Application code builds this:
-- SELECT * FROM users WHERE username = '[input]' AND password = '[input]';
-- Attacker submits username: admin' --
-- Resulting query the database executes:
SELECT * FROM users WHERE username = 'admin' --' AND password = 'anything';
The -- comments out the password check. Authentication bypassed. No exotic payload, no zero-day, just string concatenation meeting unvalidated input.
The Attacker’s Workflow
When I’m assessing an application, the SQL injection hunt follows a predictable rhythm. This maps directly to MITRE ATT&CK technique T1190 (Exploit Public-Facing Application).
Reconnaissance: Enumerate every parameter the application accepts. POST/GET parameters, cookies, custom HTTP headers, hidden form fields, JSON bodies hitting API endpoints. Anything that lands in a SQL query is fair game.
Fingerprinting: Identify the database engine. Error messages leak everything — a stack trace mentioning ORA-00933 screams Oracle, You have an error in your SQL syntax says MySQL or MariaDB, Unclosed quotation mark points to MSSQL. Even when errors are suppressed, response timing and behavior differences expose the backend.
Initial probes: Single quote, double quote, backtick. Then boolean tests:
# Confirm injection point
parameter=test'
parameter=test"
# Boolean-based confirmation - response differs between true and false
parameter=test' OR '1'='1
parameter=test' AND '1'='2
# Column count enumeration via UNION
' UNION SELECT NULL-- -
' UNION SELECT NULL,NULL-- -
' UNION SELECT NULL,NULL,NULL-- -
Exploitation: Once columns are mapped, UNION-based extraction pulls schema metadata, credentials, session tokens, whatever’s reachable. Blind SQLi via timing (SLEEP(5), WAITFOR DELAY) works when responses don’t reflect data directly.
This isn’t theoretical. Sqlmap automates the entire chain. A junior tester with sqlmap and a vulnerable parameter can dump a production database in fifteen minutes.
Where I Keep Finding SQL Injection in 2026
We caught this issue during a quarterly security review for a financial services client running a Laravel application. The main user-facing routes were clean — Eloquent ORM handled queries safely, prepared statements everywhere. But the internal analytics dashboard, written by a contractor in 2019 and never touched since, concatenated a date-range filter directly into a raw query.
One injectable parameter. Read access to the entire customer database including PII for roughly 180,000 accounts. The dashboard was only accessible to authenticated employees, which the client felt was “low risk” — until I pointed out that any successful phishing attack against a single junior analyst gave the same blast radius.
The pattern repeats across engagements:
- Legacy admin panels. Internal tools written before the team adopted an ORM. Nobody refactored them because “only employees use it.”
- Reporting and export endpoints. Custom WHERE clauses built dynamically from user-selected filters. Devs assume the dropdown limits input. It doesn’t.
- Stored procedures with dynamic SQL. Teams move to stored procs thinking they’re safe, then use
EXEC()orsp_executesqlwith concatenated parameters. Same bug, different layer. - GraphQL resolvers. The newer the framework, the more confident developers are that they’re immune. They aren’t. Resolvers that build raw queries from input arguments are just as vulnerable.
The Web Application Security playbook recommends focusing your codebase review past the server-side routing layer. Skip the client. Hit the API and route handlers, and don’t forget the supporting routes — analytics, logging, anything that touches a database is in scope.
Why Prepared Statements Are the Answer (And Why That’s Not Enough)
Parameterized queries — also called prepared statements — are the canonical fix. The developer defines the query structure with placeholders, then binds user input as data values. The database engine treats the input as data, never as executable SQL code.
PHP with PDO, done correctly:
// Safe: query structure is fixed, $_GET['name'] is bound as a parameter
$stmt = $dbh->prepare("SELECT * FROM registry WHERE name LIKE ?");
$stmt->execute(['%' . $_GET['name'] . '%']);
Python with psycopg2:
# Safe: %s is a parameter placeholder, not Python string formatting
cursor.execute("SELECT * FROM users WHERE email = %s", (user_email,))
# DANGEROUS: this is f-string concatenation, not parameterization
cursor.execute(f"SELECT * FROM users WHERE email = '{user_email}'")
The distinction matters. I’ve seen devs use parameterized libraries and still concatenate. The library doesn’t enforce correctness — the developer does.
The Counterargument I Hear From Devs
“We use an ORM. We’re fine.”
Mostly true. Mostly. ORMs like Eloquent, Django ORM, SQLAlchemy, and Hibernate use parameterized queries under the hood for standard operations. But every ORM exposes a raw query escape hatch — DB::raw(), cursor.execute(), session.execute(text()) — and those are where I find the bugs.
The other counterargument: “Our WAF catches it.” Web application firewalls help, but they’re a speed bump, not a wall. SQLi payloads using comment-based evasion (/*!50000UNION*/), case variation, encoding tricks, and second-order injection routinely bypass signature-based WAFs. A WAF is defense in depth. It is not the defense.
The Layered Defense That Actually Works
Here’s the prevention stack ranked by complexity and impact, drawn from what I’ve deployed across client environments. The technique table from Cybercrime and Digital Security matches what I see working in practice:
| Technique | Complexity | Where It Fits |
|---|---|---|
| Prepared statements / parameterized queries | Medium | Every database interaction. Non-negotiable. |
| Least privilege database accounts | Low | App users should never have DDL or admin rights. |
| Input validation (allowlist, not denylist) | Medium | Layered on top of prepared statements, not instead of. |
| ORM frameworks (used correctly) | Medium | Default for new code. Audit raw query usage. |
| Stored procedures (no dynamic SQL inside) | High | Useful when query logic must live in the DB. |
| Web application firewall | Medium | Defense in depth. Catches lazy attackers, not skilled ones. |
| Regular pentesting and SAST | Medium | Find what review missed before attackers do. |
The non-obvious one is least privilege. If your application’s database user has SELECT, INSERT, UPDATE, DELETE on only the tables it needs — and nothing else — a successful injection drops from “full database compromise” to “limited data exposure.” I’ve seen this single control turn a critical finding into a medium during post-incident reviews.
If your stack involves cloud-hosted databases, this pairs well with the principles in our automated vulnerability scanning checklist for cloud resources. Database IAM roles and security groups should restrict who and what can even attempt to connect.
Input Validation: Allowlist, Not Denylist
Validation is supplementary, not primary. But where you do it, do it right.
Denylists fail. Trying to block ', --, UNION, and SELECT breaks legitimate input (any name with an apostrophe) and misses encoded payloads. Allowlists work. If a parameter should be an integer ID, validate it’s an integer before it touches a query. If it should be a UUID, regex-match the format. If it’s an email, validate against RFC 5322.
Pin types at the boundary. The further unvalidated input travels into your application, the more likely it lands somewhere unsafe.
Detection Engineering for SQL Injection Attacks
Prevention is half the job. You also need detection — for the cases where prevention fails and for the attempts that signal active reconnaissance.
The detection signals I tune for in client SIEMs:
- Database error responses in HTTP logs. Spikes in 500-level responses correlated with specific URL parameters are reconnaissance signatures. Attackers iterate payloads until they find one that errors meaningfully.
- Unusual query patterns at the database layer. Long-running queries from the application user, queries against
information_schemaorsys.tables, queries containingSLEEPorBENCHMARKfunctions. These rarely come from legitimate application code. - WAF logs correlated with application logs. A WAF block followed by a successful 200 response from the same source on a slightly modified payload is the signature of an attacker tuning evasion.
A reasonably effective Sigma-style detection for MySQL information_schema reconnaissance:
# Detects: enumeration of database schema via information_schema queries
# from an application database user that shouldn't be running them
title: Suspicious information_schema Access from App User
detection:
selection:
user|startswith: 'app_'
query|contains:
- 'information_schema.tables'
- 'information_schema.columns'
condition: selection
If your application’s database user is querying information_schema, either your ORM is doing introspection (audit and allowlist) or someone is enumerating your schema through an injection point.
The Caveat Nobody Wants to Hear
Here’s where I have to be honest about the limits of any single defense.
Even with prepared statements everywhere, least privilege enforced, ORMs in use, and a tuned WAF, you can still be compromised through second-order SQL injection. That’s where attacker input is stored safely, then later concatenated into a different query without re-sanitization. The first write was safe. The second read wasn’t.
You can also be compromised through trusted-source injection — where data from an upstream system (a partner API, an internal microservice, a queue message) is assumed safe and concatenated into queries without parameterization. The data crossed a trust boundary your developers didn’t recognize as a boundary.
And if an attacker reaches a database with backup credentials cached in plaintext, the injection becomes a foothold for far worse. Which is why a tested ransomware backup strategy with offline copies matters even when your application security is solid. Database compromise is recoverable. Database compromise with no clean backups is an extinction event.
What to Do Monday Morning
If you own the security posture of an application, here’s the prioritized action list. This is what I hand clients after every assessment that surfaces SQL injection findings.
- Inventory every database query in your codebase. Grep for raw query methods specific to your stack:
DB::raw,execute(,cursor.execute,createQuery. Anywhere strings touch SQL, audit. - Convert every raw query to parameterized form. No exceptions for “trusted” input. Inputs you trust today become untrusted tomorrow when the upstream changes.
- Reduce database account privileges. Application users get the minimum grants needed. Separate read and write users where the architecture allows.
- Add SQL injection rules to your SAST pipeline. Semgrep has solid baseline rules. CodeQL goes deeper if you have it.
- Schedule annual penetration tests against public-facing applications. Internal review catches the obvious. External testers catch what you’ve stopped seeing.
- Run tabletop exercises on database compromise. If your application database is dumped tomorrow, what’s the blast radius? Who do you notify? How fast can you rotate every secret stored in there?
Mature organizations also align their database hardening to the CIS Benchmarks for their specific database engine, and map their controls to MITRE ATT&CK techniques to validate detection coverage. If you want help building that program — or if you found a vulnerability in your own stack reading this and you’re not sure what to do next — get in touch. We’ve worked through this with companies running everything from monolithic PHP apps to multi-region microservices. For broader infrastructure hardening that surrounds the application layer, our IT infrastructure consulting team handles the surrounding controls — IAM, network segmentation, logging pipelines.
The Practical Takeaway
Run this query against your own codebase today: search every file for raw SQL string concatenation involving user-supplied data. Every result is a finding. Fix them this sprint. Don’t wait for an attacker to find them first — the MOVEit victims didn’t get that luxury.