Injection Attacks - SQLi, Command & Template Injection, Parameterized Queries
Injection is string-built commands: SQL, a shell, a template, LDAP, or a NoSQL query. The attacker's bytes close your literal and open theirs. Bind values, pass argv, and allowlist identifiers. Escaping is the fallback you will get wrong.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Why can't you bind a column name in `ORDER BY`?
Answer
Placeholders stand for values, not identifiers, because the planner needs the identifiers to build the plan. Map user choices to a fixed allowlist of column names.
L2
What is second-order SQL injection?
Answer
A payload is stored safely, then later read back and concatenated into another query (often by an internal job). Defense: parameterize every query regardless of where the data came from.
L3
How do you detect blind SQL injection?
Answer
Boolean conditions that change the response, or time delays (`pg_sleep`, `WAITFOR DELAY`). In code review, look for any string-built SQL; in testing, use sqlmap or Burp against a staging environment.
L4
Is an ORM immune to injection?
Answer
No. Raw query methods, string-built filters, `extra()`/`whereRaw`, and dynamic sort fields are all injectable.
L5
How does NoSQL injection work in a Node/Mongo app?
Answer
A JSON body sends `{"password": {"$ne": null}}`, which the driver treats as an operator, so the check matches any password. Enforce that fields are strings (schema validation) and use operators only from code.
L6
What is server-side template injection and why is it severe?
Answer
User input is rendered as a template, so template syntax executes on the server and often leads to remote code execution. Pass user input as template *variables*, never as the template source, and sandbox any user-authored templates.
L7
You inherit a codebase with 400 string-built queries. What's your plan?
Answer
Add a taint-tracking lint rule to stop new ones, rank existing ones by exposure (public endpoints first), fix in batches with parameterization, add a least-privilege DB role, and keep a WAF rule as a temporary safety net.
Failure modes
Quote doubling as the only defense
Breaks in numeric contexts, odd charsets, and the next syntax the database adds.
ORM raw escape hatch
whereRaw, extra, and a user-controlled sort string put concatenation back.
Second-order payload
The insert was parameterized. The nightly job concatenates the stored value.
Argument injection
argv blocked the semicolon. A value starting with a dash is still a flag.
Misconceptions
Stored procedures are immune.
Only if they do not build dynamic SQL. EXEC of a concatenated string is the same bug inside the database.
An ORM cannot be injected.
Raw query helpers and dynamic identifiers are injectable. The default query builder is not a guarantee.
Hiding database errors stops blind injection.
Boolean differences and sleep functions do not need an error message.
Interviewer traps
Escaping single quotes on the whiteboard and stopping.
Say placeholders for values, an allowlist for identifiers, and a least-privilege login as the second layer.
Treating command injection as only a semicolon.
Show the shell string running a second command, then the argv array treating that semicolon as data, then a leading dash as a flag.
Design scenario
Same prompt for every reader.
Requirements
Email search must not become a boolean tautology. Sort must not become a CASE expression. The export must not run a second command. The nightly job must not reintroduce injection.
Traffic / scale
Search is the hot path. Export is rare and admin-only. The nightly job reads the whole table once.
Latency
Parameterized search should be able to reuse a query plan.
Consistency
A value stored today is still data when the job reads it tomorrow.
Availability
A rejected sort key is a 400. It does not throw a SQL error to the client.
Failure assumptions
- The filename contains a semicolon.
- The sort key contains a subquery.
Constraints
- The database login used by search cannot write.
- Export must not invoke a shell.
Prompt
A search API lets clients filter by email and sort by a column. An admin export shells out to a converter with the requested filename. A nightly job rebuilds a report from rows stored earlier.
API
Which query parameters are values, and which one is an identifier?
Data
How does the nightly job pass the stored email back into SQL?
Architecture
Where is the allowlist, where is execFile, and which database role serves search?
Values versus strings the interpreter will parse
Prefer
Bind values, allowlist identifiers, pass argv
The engine parses the command before it ever sees the untrusted bytes.
- Prepared statements cover values in every character set.
- Sort columns come from a map you wrote, not from the query string.
- execFile never asks a shell to read a semicolon.
Alternative
Replace quotes, or hide errors
Escaping guesses how this parser, this charset, and this context will read the string.
- Numeric contexts have no quotes to escape.
- A stored procedure that concatenates is still injectable.
- Blind timing payloads do not need an error page.
From a user string to a command
Name the interpreter first. The fix is the API that interpreter already has.
- 1
Name the interpreter
SQL, shell, Mongo query, Jinja, LDAP, or XPath. - 2
Bind values
Placeholders, query objects, or template variables. Never concatenate. - 3
Allowlist identifiers
Column, table, and ORDER BY names come from a fixed map. - 4
Skip the shell
Library calls or execFile with an argument array. Reject values that look like flags. - 5
Shrink the login
A read-only role cannot drop a table even if a query is still string-built.
Overview
Injection happens when your code builds a command for some interpreter (SQL engine, shell, LDAP, template engine, NoSQL query parser) by gluing strings together, and part of that string came from a user. The attacker's input closes your literal and opens their own syntax. The fix is structural: use the interpreter's API that takes code and data separately (bound parameters, argv arrays, query objects). Escaping is a fallback for places that genuinely can't bind, and identifiers like column names should come from an allowlist, never from escaping.
Path and field names on the request are API design. Window functions, CTEs, and set-based SQL are SQL analytics. Those queries still need parameters when any part of the string comes from a request.
Injection family at a glance
| Type | Interpreter | Classic payload | Primary fix | Common trap |
|---|---|---|---|---|
| SQL injection | SQL engine | ' OR '1'='1, UNION SELECT | Prepared statements / bound parameters | ORDER BY, LIMIT, table names can't be bound |
| Blind / time-based SQLi | SQL engine | CASE WHEN ... THEN pg_sleep(5) | Same | Errors hidden, so teams think they're safe |
| Second-order SQLi | SQL engine | Payload stored, executed later by a batch job | Parameterize every query, including internal ones | "This value came from our own DB" |
| OS command injection | /bin/sh | ; rm -rf, $(id), backticks | Don't shell out; use execFile/argv arrays | Argument injection like --output= |
| NoSQL injection | Mongo query parser | {"$ne": null} as a password | Enforce types (string, not object) | JSON bodies auto-parse into objects |
| Server-side template injection | Jinja, Twig, Freemarker | {{7*7}}, {{config}} | Never render user input as a template | "Custom email templates" features |
| LDAP / XPath injection | Directory / XML query | *)(uid=* | Library escaping functions, binding | Legacy SSO integrations |
Sequence
- 1
Attacker → App server
1. email = x' OR '1'='1
- 2
App server → Database
2a. concatenated SQL: WHERE email = 'x' OR '1'='1'
- 3
Database → App server
3a. every row matches
- 4
App server
2b. bound parameter: SQL text and value sent separately
- 5
App server → Database
2b. WHERE email = ? with value "x' OR '1'='1"
- 6
Database → App server
3b. zero rows, value compared as a plain string
Lesson map
Injection Attacks - SQLi, Command & Template Injection, Parameterized Queries
Injection is string-built commands: SQL, a shell, a template, LDAP, or a NoSQL query. The attacker's bytes close your literal and open theirs. Bind values, pass argv, and allowlist identifiers. Escaping is the fallback you will get wrong.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["Attacker"] s["App server"] d["Database"] a -->|1. email = x' OR '1'='1| s s -->|2a. concatenated SQL: WHERE email = 'x' OR '1'='1'| d d -->|3a. every row matches| s s -->|2b. WHERE email = ? with value x' OR '1'='1| d d -->|3b. zero rows, value compared as a plain string| s
SQL injection: concatenation vs parameters vs allowlists
# SQL injection: string-built query vs bound parameters vs identifier allowlist.
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE users (id INTEGER, email TEXT, is_admin INTEGER);
INSERT INTO users VALUES (1,'ana@x.io',1),(2,'bo@x.io',0),(3,'cy@x.io',0);
""")
attack = "nobody@x.io' OR '1'='1"
# BAD: user text is spliced into SQL, so the quote ends the literal and OR runs.
bad = f"SELECT id FROM users WHERE email = '{attack}'"
print("concat rows :", db.execute(bad).fetchall())
# GOOD: the driver sends SQL and value separately; the value is never parsed.
good = db.execute("SELECT id FROM users WHERE email = ?", (attack,)).fetchall()
print("parameterized :", good)
# Identifiers (column names, ORDER BY, table names) CANNOT be bound.
# Map user choices onto a fixed allowlist instead of escaping them.
SORTABLE = {"id": "id", "email": "email"}
def list_users(sort: str) -> list:
col = SORTABLE.get(sort)
if col is None:
raise ValueError(f"unsupported sort {sort!r}")
return db.execute(f"SELECT id FROM users ORDER BY {col} DESC").fetchall()
print("allowlisted sort :", list_users("email"))
try:
list_users("(CASE WHEN (SELECT is_admin FROM users WHERE id=1)=1 THEN id ELSE email END)")
except ValueError as e:
print("blind-SQLi sort :", "rejected ->", str(e)[:40], "...")Output:
concat rows : [(1,), (2,), (3,)]
parameterized : []
allowlisted sort : [(3,), (2,), (1,)]
blind-SQLi sort : rejected -> unsupported sort '(CASE WHEN (SELECT is_ ...Why bound parameters work: with a prepared statement the database parses the SQL text first, builds a plan with placeholders, and only then receives the values. There's no point in time where the value is parsed as SQL. Escaping, by contrast, tries to predict how the parser will read a string, and it breaks on character-set tricks, numeric contexts without quotes, and new syntax.
Command injection: shell strings vs argv
// Command injection: a shell string re-parses metacharacters; an argv array does not.
// No @types/node in the sandbox, so declare require; in a real project use
// `import { execFileSync, execSync } from "node:child_process"`.
declare function require(m: string): any;
const { execFileSync, execSync } = require("child_process");
const filename = "report.txt; echo INJECTED-COMMAND-RAN";
// BAD: the whole string goes to /bin/sh, so ';' starts a second command.
const viaShell = execSync(`echo processing ${filename}`, { encoding: "utf8" }) as string;
console.log("shell string :", JSON.stringify(viaShell.trim().split("\n")));
// GOOD: execFile passes argv directly to the program; ';' is just a character.
const viaArgv = execFileSync("echo", ["processing", filename], { encoding: "utf8" }) as string;
console.log("argv array :", JSON.stringify(viaArgv.trim().split("\n")));
// Still validate: argv blocks the shell, but a value starting with '-' can be
// read as an option (argument injection). Use '--' or an allowlist pattern.
const safeName = /^[\w.-]{1,64}$/;
for (const f of ["report.txt", "--output=/etc/passwd", filename]) {
const ok = safeName.test(f) && !f.startsWith("-");
console.log(`validate ${JSON.stringify(f).padEnd(42)} -> ${ok ? "ok" : "reject"}`);
}Output:
shell string : ["processing report.txt","INJECTED-COMMAND-RAN"]
argv array : ["processing report.txt; echo INJECTED-COMMAND-RAN"]
validate "report.txt" -> ok
validate "--output=/etc/passwd" -> reject
validate "report.txt; echo INJECTED-COMMAND-RAN" -> rejectThe best fix is usually not to shell out at all: use a library (image processing, archive, git bindings). When you must run a program, pass an argv array so no shell parses it, add -- before user arguments, and validate against an allowlist pattern.
What happens if you choose differently
| Choice | Outcome |
|---|---|
Manual escaping with replace("'", "''") | Works until a numeric context, a different charset, or backslash handling bites; every developer must remember it every time |
| Stored procedures | Safe only if they don't build dynamic SQL inside; EXEC('SELECT ... ' + @param) is still injectable |
| ORM everywhere | Great default, but raw-query escape hatches and dynamic sort/filter builders reintroduce the bug |
| WAF only | Blocks textbook payloads, misses blind and second-order variants |
| Least-privilege DB user | Doesn't prevent injection but turns "dump every table" into "read one schema"; always add it |
Pros and cons of defenses
| Defense | Pros | Cons |
|---|---|---|
| Prepared statements | Complete fix for values; often faster through plan caching | Can't bind identifiers; some drivers emulate client-side |
| Query builders / ORM | Safe by default, composable | Leaky abstractions, raw escape hatches |
| Allowlist mapping for identifiers | Simple, provably safe | Must be maintained as schema changes |
argv / execFile | No shell parsing at all | Program-level argument injection still possible |
| Least privilege + separate read/write users | Limits blast radius | Doesn't stop the injection itself |
| Static analysis (Semgrep, CodeQL taint rules) | Finds sinks fed by sources at scale | False positives; needs tuning |
Search the repo for string-built SQL and for exec, spawn with shell true, and template render of a request field. Rank hits by whether an anonymous caller can reach them. Parameterize the public ones first.
Interview Q&A
Why can't you bind a column name in `ORDER BY`?
Answer
Placeholders stand for values, not identifiers, because the planner needs the identifiers to build the plan. Map user choices to a fixed allowlist of column names.
What is second-order SQL injection?
Answer
A payload is stored safely, then later read back and concatenated into another query (often by an internal job). Defense: parameterize every query regardless of where the data came from.
How do you detect blind SQL injection?
Answer
Boolean conditions that change the response, or time delays (pg_sleep, WAITFOR DELAY). In code review, look for any string-built SQL; in testing, use sqlmap or Burp against a staging environment.
Is an ORM immune to injection?
Answer
No. Raw query methods, string-built filters, extra()/whereRaw, and dynamic sort fields are all injectable.
How does NoSQL injection work in a Node/Mongo app?
Answer
A JSON body sends {"password": {"$ne": null}}, which the driver treats as an operator, so the check matches any password. Enforce that fields are strings (schema validation) and use operators only from code.
What is server-side template injection and why is it severe?
Answer
User input is rendered as a template, so template syntax executes on the server and often leads to remote code execution. Pass user input as template variables, never as the template source, and sandbox any user-authored templates.
You inherit a codebase with 400 string-built queries. What's your plan?
Answer
Add a taint-tracking lint rule to stop new ones, rank existing ones by exposure (public endpoints first), fix in batches with parameterization, add a least-privilege DB role, and keep a WAF rule as a temporary safety net.