The Runtime Theory
Security

SQL Injection Is Still Number One: Attack Mechanics and Real Mitigations

How SQL injection still tops the OWASP charts: union-based and blind injection mechanics, parameterized queries, and why ORMs still leak.

The Runtime Theory Team3 min read#sql-injection#owsap-top-10#parameterized-queries#orm#database-security
On this page

SQL injection has been on the OWASP Top 10 since the list was first published in 2003, and it is still there two decades later. That is not because the fix is unknown — every SQL driver on earth supports parameterized queries — but because the vulnerability is a composition problem: untrusted input reaching a SQL string at the wrong layer. This article walks through the actual mechanics of injection, what the payloads look like on the wire, and where the defenses fail.

What the Machine Actually Does

A vulnerable query builder concatenates strings:

python
username = request.form["username"]
query = f"SELECT * FROM users WHERE username = '{username}' AND active = 1"
cursor.execute(query)

The driver parses the final string as one grammar: the quotes that the programmer wrote and the quotes that the attacker typed are indistinguishable to the SQL parser. ' OR '1'='1 is not "clever" — it is the string completing the statement:

sql
SELECT * FROM users WHERE username = '' OR '1'='1' AND active = 1

'1'='1' is always true, so the row filter degenerates. One extra quote character changed the structure of the statement. That is the entire vulnerability: string concatenation gives user bytes parser-level authority over your query grammar.

Union-Based Injection

When an application dumps query results into a page, an attacker can extend the result set with UNION:

sql
' UNION SELECT username, password FROM users--

The -- comments out whatever SQL followed the injected point. The rule of UNION is that both sides must return the same number of columns, so attackers probe column counts first:

sql
' ORDER BY 1--   -- no error
' ORDER BY 5--   -- error: too many columns

then match column types. The result: a login page that renders the entire users table, or sqlite_master/information_schema to map the schema. Union-based injection requires the application to display query output, so blind injection exists for everything else.

Blind Injection

When no data comes back, attackers use the query as an oracle. Boolean-based blind injection encodes a condition into a comparison:

sql
' AND (SELECT SUBSTRING(password,1,1) FROM users LIMIT 1) = 'a'--

The page renders normally when true, differently when false — one bit at a time. Time-based blind injection removes the need for any observable output difference:

sql
'; IF (SELECT COUNT(*) FROM users) > 1000 WAITFOR DELAY '0:00:05'--

If the request takes five seconds, the condition is true. Modern tools like sqlmap automate both, so a single injectable parameter becomes full database read/write within minutes.

The Fix: Parameterized Queries

Parameterized queries separate the statement grammar from the data:

python
cursor.execute(
    "SELECT * FROM users WHERE username = %s AND active = 1",
    (username,),
)

The driver sends the SQL text and the parameters as separate protocol elements. The database compiles the statement with the placeholder as a value slot, then binds the parameter — it can never become SQL. This kills injection regardless of what the user types, because the user bytes never touch the parser. The same applies to every major database: %s in psycopg2, ? in sqlite3, $1 in pg, and @p1 in SQL Server.

Why ORMs Still Leak

ORM query builders are parameterized by default, so plain ORM usage is safe. The leaks are the escape hatches:

  1. Raw query APIs: Model.raw() (Peewee), EntityManager.createNativeQuery (JPA), query.raw() (Rails) — drop you back into string building, parameterization optional.
  2. Dynamic table/column names: identifiers cannot be parameterized, so order_by(user_input) or filter("column_" + x) concatenates. Whitelist identifiers against a known set instead.
  3. LIKE and IN expansion: ORMs that inline IN (...) values must be verified; some drivers interpolate instead of binding.
  4. Stored procedures with their own string-built SQL (EXEC(@sql)) inherit the risk behind a second layer.

Defense in Depth

Parameterized queries solve the class, not every instance. Layer the rest:

  • Least privilege: the app database user should not be DROP-capable. A leaked query is then a read leak, not a wipe.
  • Input validation as a backstop, not the fix: type-checking an integer or enum drastically shrinks injection surface.
  • WAF rules only obscure the request; they are defeated by encoding tricks and should never be the primary control.
  • Error handling: never return raw database error text to clients — it leaks schema details used to refine payloads.

Verdict

SQL injection persists not because the attack is sophisticated but because string composition is convenient. Every layer that interpolates user input into SQL — raw strings, dynamic identifiers, custom query builders — reopens the class. Parameterized queries everywhere, identifiers whitelisted, least privilege enforced: that combination makes the injection payloads in this article unparseable, not "filtered."

The question is never "is this parameter injectable?" but "can user bytes ever reach the parser?" If the answer is yes at any layer, the number-one vulnerability is alive in your system.