Skip to content
yutils

How SQL Injection Actually Works

Classic UNION-based vs blind vs time-based injection, why string concatenation always loses, how prepared statements actually work at the wire level, why ORMs don't fully save you, and second-order injection through stored data.

~9 min read

An OWASP Top 10 staple since 1998. Yet 25+ years later it still shows up in new systems. The reason — string concatenation feels too natural. This guide covers the three types (UNION / blind / time-based), exactly how prepared statements stop it at the wire level, and why even ORMs aren't bulletproof.

Classic Injection — UNION-based

// Vulnerable
const sql = "SELECT * FROM users WHERE id = " + userInput;
db.query(sql);

input: 1
SQL: SELECT * FROM users WHERE id = 1   ← OK

input: 1 OR 1=1
SQL: SELECT * FROM users WHERE id = 1 OR 1=1   ← every user!

input: 1 UNION SELECT username, password FROM admin
SQL: SELECT * FROM users WHERE id = 1 UNION SELECT username, password FROM admin
     ← admin data returned as if it were user data

input: 1; DROP TABLE users; --
SQL: SELECT * FROM users WHERE id = 1; DROP TABLE users; --
     ← DB obliterated (with multi-statement enabled)

Blind Injection — Possible Without Errors

Modern apps hide SQL errors from users.
Still exploitable — extract data one bit at a time via true/false responses.

// Vulnerable
const sql = "SELECT * FROM users WHERE id = " + userInput;
const found = db.query(sql).length > 0;
return found ? "exists" : "not found";

Attack:
input: 1 AND substring((SELECT password FROM admin), 1, 1) = 'a'
→ "exists" returned ⇒ admin password's first char is 'a'

Automation:
  for char in [a, b, c, ..., z, 0-9]:
    if response == "exists": first char = char
  → next char, next ... full password extracted in minutes

Time-based Blind — Identical Responses, Only Timing

Even when true/false responses look identical (both "OK"),
attack via response time, one bit at a time.

input: 1; IF (SELECT password FROM admin WHERE id=1) LIKE 'a%' WAITFOR DELAY '0:0:5'

→ Response takes 5s → first char is 'a'
→ Instant response → not 'a'

Automatable. Works even when every endpoint returns 200 OK.

Second-Order Injection — Through Stored Data

Malicious input is only stored at first (not executed). Later, another
query that uses it interpolates it → fires.

Step 1: Register
  username = "admin' --"
  → Stored as-is in DB (simple INSERT, assume escaped)

Step 2: User edits profile (their name flows back into SQL)
  sql = "UPDATE users SET active=1 WHERE name='" + name + "'"
  →     "UPDATE users SET active=1 WHERE name='admin' --'"
                                                    ↑↑↑↑↑
                                                    comment, affects everyone

→ First escape may have been fine, but if there's no re-escape on reuse, it explodes.

Prepared Statements — How They Defend at the Wire Level

// Safe
const sql = "SELECT * FROM users WHERE id = ?";
db.query(sql, [userInput]);

→ At the wire protocol, this splits into 2 messages:

1. Prepare:
   Server <- "PREPARE stmt FROM 'SELECT * FROM users WHERE id = ?'"
   Server -> "Statement ID: 42, expects 1 parameter"

2. Execute:
   Server <- "EXECUTE 42 WITH params: [user_input_as_bytes]"
   Server -> result rows

Key points:
- The SQL string in step 1 has no user input — parsed in advance
- Step 2 sends parameters in a separate binary slot
- The DB NEVER parses those bytes as SQL → they're values

→ Whatever the user input (1 OR 1=1, ', DROP TABLE, ...), it's treated
   as a value. SQL injection is structurally impossible.

ORMs Aren't Fully Safe Either

// Sequelize (Node.js) — safe
User.findOne({where: {id: userInput}});  // ✓ parameterized

// Sequelize — dangerous
const sql = "SELECT * FROM users WHERE id = " + userInput;
sequelize.query(sql);  // ❌ raw query, direct injection risk

// Active Record (Rails) — safe
User.where(name: params[:name])  // ✓

// Active Record — dangerous
User.where("name = '#{params[:name]}'")  // ❌ string interp
User.find_by_sql("SELECT * WHERE id = #{params[:id]}")  // ❌

→ ORM raw/native SQL options are escape hatches — manual check required.

JS template literals (`SELECT * WHERE id = ${id}`) are the same trap.

NoSQL Injection — Same Idea, Different Syntax

MongoDB:
  db.users.find({username: req.body.username, password: req.body.password});

Attack:
  POST {username: "admin", password: {"$ne": null}}
  → password = "not null" matches any non-null value → login bypassed

Fixes:
- Validate input types (string only, reject objects)
- Typed queries via ORM/ODM (Mongoose schemas)
- Libraries like mongo-sanitize

Related Tools

  • SQL Formatter — inspect SQL structure (how the parser interprets it)

Common Pitfalls

  • Hand-rolled escape — quote escaping alone doesn't stop UNION, comments, multi-statement. Only prepared statements are a structural fix.
  • "It's an integer, no escape needed" — if the input is actually a string, it explodes. Always parameterize.
  • Raw queries with an ORM — see above. ORM trust doesn't transit to every query.
  • Ignoring second-order — only escaping at first touch. Anywhere user data flows into SQL — parameterize.
  • Over-privileged DB user — if the app user has DROP TABLE rights, SQL injection is catastrophic. Principle of least privilege (separate read vs write users).

Wrap-up

SQL injection has been known since 1998 yet appears in new systems every year — string concatenation is just too natural. The defense is singular: every user value through a prepared-statement parameter.

Practical: stick to your ORM's default API (avoid raw query), don't build SQL with template literals, minimize DB user privileges, never leak SQL error messages to users (info leak).

← Back to guides