Lesson 1 of 11 · 7 min read
Injection Vulnerabilities
In this lesson
TL;DR: Injection is untrusted data crossing into an interpreter and being parsed as code. It covers SQL, operating-system commands, template engines, LDAP, XPath and more. The fix that works is structural: give the interpreter code and data through separate channels, so data can never change the code's shape. Parameterised queries do that for SQL; argument arrays without a shell do it for commands; sandboxed or logic-less templates do it for rendering. Escaping and blocklists are weaker fallbacks. Language models are an interpreter with no separate data channel, which is why prompt injection cannot be fixed the same way.
Before you read
Pick an answer before you read further. The lesson below explains it, and guessing first, right or wrong, helps the explanation stay with you.
1A developer escapes single quotes in user input before building a SQL string. Why is this weaker than a parameterised query?
One bug, many interpreters
An interpreter takes text and acts on it: a database runs SQL, a shell runs commands, a template engine evaluates expressions, a browser runs HTML and JavaScript. Injection happens when an application builds that text by combining its own code with data from somewhere less trusted, and the combination lets the data change what the code does.
| Interpreter | Injection | Typical cause |
|---|---|---|
| SQL database | SQL injection | String concatenation into queries |
| Operating system shell | Command injection | Passing a built string to a shell |
| Template engine | Server-side template injection | Rendering user input as a template, not into one |
| Browser | Cross-site scripting | Inserting data into HTML without encoding |
| LDAP, XPath, NoSQL | Query injection | Building queries from strings or objects from input |
| Language model | Prompt injection | Untrusted text in the same context as instructions |
The shape repeats because the cause repeats: one channel carries both code and data, and the boundary between them is decided by parsing.
How common it still is
The OWASP Top 10 2025 places Injection at A05, mapping 37 weaknesses to it with 62,445 CVEs and over 1.4 million recorded occurrences. Cross-site scripting dominates the count (more than 30,000 CVEs, high frequency and usually lower impact) while SQL injection has more than 14,000 (lower frequency, high impact). Injection dropped from first place years ago because frameworks made the safe path the default; it stays in the list because developers keep stepping off that path.
The structural fix: separate channels
The strongest fix is to stop combining code and data in one string.
SQL. A parameterised query sends the statement with placeholders, then the values separately. The database compiles the statement before seeing the values, so no value can add a clause.
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users (name TEXT, email TEXT)")
db.execute("INSERT INTO users VALUES ('alice', '[email protected]')")
db.execute("INSERT INTO users VALUES ('bob', '[email protected]')")
name = "alice' OR '1'='1" # attacker-controlled
# Vulnerable: the input becomes part of the SQL text.
unsafe = f"SELECT email FROM users WHERE name = '{name}'"
print(db.execute(unsafe).fetchall()) # every row: both emails
# Safe: the statement and the value travel separately.
safe = "SELECT email FROM users WHERE name = ?"
print(db.execute(safe, (name,)).fetchall()) # => []
Commands. Run programs with an argument list and no shell, so metacharacters like ;, | and $( ) are just characters.
import subprocess
filename = "report.pdf; rm -rf ~" # attacker-controlled
# Safe: no shell parses the string; the whole value is one argument.
subprocess.run(["file", "--", filename], check=False)
The -- ends option parsing, so a value starting with - cannot become a flag. Better still, avoid running external programs on user input when a library call can do the job.
Templates. Render user input as data into a fixed template; never compile user input as a template. Use auto-escaping engines and sandboxed modes for anything user-authored.
The forms that survive parameterised queries
Teams that "use an ORM" or "always parameterise" still ship injection. The survivors share a pattern: somewhere, a string is built after all.
- Second-order injection. Data is stored safely, then later read back and concatenated into a query by code that assumes "it came from our own database". The injection fires in a different service, hours later, often with more privileges.
- ORM escape hatches. Raw-query methods, string-built filter fragments and dynamic
ORDER BYclauses bypass the ORM's parameterisation. Search a codebase for them; they are where the remaining SQL injection lives. - Identifiers. Table and column names cannot be bound as parameters, so a user-chosen sort column must be mapped through an allowlist in code.
- Stored procedures that build SQL. A procedure is only safe if its body uses parameters; one that concatenates its arguments into dynamic SQL with
EXECorEXECUTEis a SQL injection with an extra hop. - NoSQL operator injection. A document database that accepts a JSON query lets an attacker replace a value with an operator object, such as a "not equal to empty" condition in a password field, changing the meaning of the query without any special characters. Validate types: if a field should be a string, reject objects.
Expression and lookup injection
Some interpreters hide inside libraries you would not call interpreters. Two incidents made the point at enormous scale:
- Log4Shell (CVE-2021-44228, December 2021). Log4j 2 versions from 2.0-beta9 to 2.15.0 evaluated
${...}lookup expressions found in logged data, including JNDI lookups that fetched and loaded remote code. Any user-controlled string that reached a log line, such as a user agent or a username, reached an interpreter. - Apache Struts (CVE-2017-5638, March 2017). The Jakarta multipart parser's error handling evaluated an OGNL expression supplied in a request header, giving remote code execution; it was the entry point in the Equifax breach that year.
Both are injection in the classic sense: untrusted text reached an engine that treats some text as code. The defence is the same as for SQL: find the interpreter, keep data out of its code channel (disable lookups, never evaluate expressions built from input) and patch the library.
Signup form
parameterised insert
Users table
stored verbatim
Nightly report job
string-built query
Reporting database
job's wider role
Where it went wrong
Trusted because it is ours
- Data from our own table was concatenated
Fix
- Parameters in the job too
- Read-only role on one schema
The first write is safe
Parameters store the payload as plain data. Nothing executes at signup.
Fallbacks, and why they are fallbacks
- Allowlisting input (only digits for an ID, only known column names for sorting) is strong where the set of valid values is small. Use it for identifiers that cannot be parameterised, such as table or column names.
- Escaping is correct only for a specific interpreter, context and character set. It is the right tool for output encoding in HTML, and the wrong first choice for SQL.
- Blocklists of dangerous strings fail against encodings, alternative syntax and inputs the author did not anticipate.
Least privilege as the second layer
Assume one injection will slip through. A database account limited to the tables and operations the application needs turns a full compromise into a small one, as the least privilege lesson shows.
The database's own defaults help if you keep them. SQL Server ships with xp_cmdshell, which runs operating-system commands, disabled on new installations; PostgreSQL's COPY ... PROGRAM needs superuser rights or the pg_execute_server_program role. An application account that holds neither cannot turn a SQL injection into command execution. Row-level security, available in PostgreSQL since 9.5, adds a fail-closed tenant filter inside the database, so a query that forgot its WHERE tenant_id = ? still returns only the current tenant's rows. Blocking outbound network access from database hosts closes the remaining exfiltration and payload-fetching paths.
The bridge to prompt injection
Developer's code
Query or instructions
SQL engine
Parses code first
Behaviour
Also in the picture
Untrusted data
- Form field or document
Language model
- One token stream
SQL: separate channels
With a parameterised query the statement is parsed before the values arrive. Data cannot change the code's shape.
SQL injection was solved, in principle, by an API change. Prompt injection has no equivalent API, because a language model reads its instructions and its data in the same way. That is the single most important connection between classic application security and AI security, and it is why AI defences focus on what a manipulated model can reach rather than on stopping the manipulation.
Worked example: a second-order bug, with the blast radius
A user signs up with a display name containing a quote and a SQL fragment. The signup code uses parameters, so the name is stored exactly as typed. That night, a reporting job builds SELECT ... WHERE owner = '<display name>' by string concatenation, because "the data came from our own database". The fragment closes the string and appends its own clause.
Three facts decide how bad this is:
| Question | This system | Better |
|---|---|---|
| What role does the job run as? | A role with read access to every schema and write access to the reporting tables | A read-only role limited to the reporting schema |
| Can that role run commands? | It owns the database, so COPY ... PROGRAM is available | No superuser, no pg_execute_server_program |
| Can the database host reach the internet? | Yes | No outbound access |
With the "better" column, the same injection can read reporting data and nothing else. The code fix is parameters in the job; the design fix is the column on the right.
Misconceptions interviewers listen for
- "Data from our own database is trusted." Its origin was a user; trust is about where data came from first.
- "We use an ORM, so no SQL injection." Raw queries, string-built filters and dynamic ordering bypass it.
- "Escaping equals parameterising." Escaping is interpreter- and charset-specific; parameters separate the channels.
- "Stored procedures are safe." Not when they build dynamic SQL from their arguments.
- "NoSQL has no injection." Operator objects change query meaning without any quote characters.
What if · interview follow-ups
Hold your answer as the constraints change
You are reviewing a multi-tenant SaaS backend that uses an ORM, a document database for activity feeds, Log4j for logging and an LLM feature that answers questions about the user's data. The interviewer moves through the interpreters one at a time.
Start here
What if the ORM is used everywhere except a reporting endpoint that calls a raw query with an f-string?
Where do the remaining SQL injections live in ORM codebases?
That raw query is the injection point, and it is typical: the ORM protects the paths that use it, not the escape hatches. Rewrite it with bound parameters, map any user-chosen identifiers through an allowlist, and add a static-analysis rule that flags raw-query calls with string formatting so new ones fail review.
Carry it forwardParameterisation protects the code paths that use it; audit the escape hatches.
Weak answerDeclaring the codebase safe because an ORM is in use.
Constraint changes
What if the document database query takes a filter object built directly from the request's JSON body?
What if a field expected to be a string arrives as an object?
An attacker can send an operator object instead of a value, for example a not-equal condition on a password or tenant field, and change what the query matches without any special characters. Validate types and shapes before building the query, build the filter from known fields in code, and never pass request objects through as query objects.
Carry it forwardIn structured query languages, injection is about types and operators, not quotes.
Weak answerSaying NoSQL databases are immune to injection.
Constraint changes
What if usernames are written to logs by a logging library that evaluates lookup expressions?
Is the logger an interpreter?
It is: that is the Log4Shell class, where text in a log line was evaluated and could fetch and load remote code. Upgrade the library, disable message lookups, and treat every component that evaluates expressions (loggers, template engines, expression languages) as an interpreter that must never receive untrusted text as code. Egress blocking from servers limits what a lookup could fetch.
Carry it forwardFind every interpreter in the stack, including the ones hidden in libraries.
Weak answerTreating logging as a write-only, harmless sink.
Constraint changes
What if the LLM feature generates SQL to answer the user's question?
Who influences the model's text, and how much can SQL text do?
The model's output is shaped by the user and by any data it reads, so generated SQL is untrusted code. Prefer letting the model choose from allowlisted, parameterised queries and fill in values. If free-form SQL is unavoidable, run it read-only, under a role that row-level security limits to the user's tenant, against a replica, with statement timeouts and no access to other schemas.
Carry it forwardA model writing code for an interpreter is untrusted input to that interpreter; constrain it like any other.
Weak answerAsking the model to write safe SQL as the control.
Constraint changes
What if an injection still gets through somewhere?
What limits the damage once the query runs?
The database role and environment decide. A role limited to the tables it needs, no superuser or command-execution rights, row-level security as a tenant backstop, and no outbound network from the database host turn a breach of everything into a read of a little. Alert on query errors and unusual statement shapes from the application role.
Carry it forwardDesign the database account as if injection will happen, because one eventually will.
Weak answerHaving no answer beyond 'we parameterise'.
Now defend it
As the defender, how would you detect injection attempts and regressions?
Fail the build on string-formatted raw queries and on evaluation of untrusted input; log database errors and syntax failures from application roles and alert on spikes; monitor for query shapes the application never issues; and watch outbound connections from database and application hosts, which injection-to-execution chains need.
How this shows up in interviews
"What do SQL injection and command injection have in common?" tests whether you see the class rather than memorise instances. Strong answers name the interpreter and the boundary, explain why parameterisation is structural while escaping is contextual, and connect the class to prompt injection with the right caveat. In code review rounds, expect a snippet that builds a query or command from input; fix it with parameters or argument arrays, then mention least privilege.
Say it in 30 seconds: "Injection is untrusted data reaching an interpreter in the same channel as code, so the interpreter parses some of it as instructions. The structural fix is separate channels: parameterised queries, argument arrays without a shell, data rendered into fixed templates. Allowlists help for identifiers; escaping and blocklists are fallbacks. Prompt injection is the same class in an interpreter with no separate data channel, so there the defence shifts to limiting what the model can do."
Check yourself
Say or write your answer first, then reveal. Explaining it from memory, in your own words, is the skill an interviewer is checking.
1A reporting page lets users choose a sort column, which is inserted into ORDER BY. Placeholders do not work for column names. What do you do?
2Why is 'escape the model's output before putting it in a SQL query' the wrong fix when an LLM generates query parameters?
3What makes second-order SQL injection hard to catch in testing?
4Why was Log4Shell an injection vulnerability?