Research / Root cause
Why input validation is the wrong mental model for injection
SQL injection, path traversal, and template-injection code execution look like three different bugs. They share one root cause, and it is not a missing filter.
Ask an engineer how to stop SQL injection and most will say "validate and sanitize input." That answer is why injection is still everywhere. It frames the problem as dirty data that needs cleaning, when the real problem is structural: untrusted data is being handed to an interpreter as if it were code.
We find the same shape in three different places, with three different interpreters: a database query, a filesystem path, a template engine. In each, the fix that lasts is not a better filter. It is keeping the data out of the code channel entirely.
The system assumption
Injection lives wherever a program builds a command in some language by pasting strings together, and part of the string came from the user. The language can be SQL, a file path, a shell line, an HTML template, an expression evaluated by a framework. The program assembles a sentence in that language and hands it to an interpreter. The interpreter, correctly, does what the whole sentence says.
The assumption that fails is that the program can keep the user's part "just data" by inspecting it first. But once data and code share a single string, the boundary between them is decided by the interpreter's grammar, not by the program's intent. A quote, a slash, a delimiter, and the user's data becomes part of the instruction. Validation tries to guess, in advance, every character sequence the interpreter might read as code. The interpreter always knows its own grammar better than the guesser does.
Where the assumption breaks
Three sinks, one error.
SQL: a parameter concatenated into a query
An endpoint builds a query by inserting a request parameter directly into the SQL string. Because the value lands inside the query text, a crafted value can close the intended clause and append its own, turning a filtered read into a full table dump. In one assessment a single injectable parameter was enough to read an entire user table through a UNION. The application was not missing a validation step. It was missing the wall between the query and the value.
Files: a filename concatenated into a path
A download endpoint takes a filename and joins it to a base directory to locate the file. Because the value lands inside the path, traversal sequences walk out of the intended directory and read arbitrary files. Same shape: user data placed into a language (filesystem paths) where certain sequences mean "go up a level," interpreted faithfully.
Templates: user data reaching an evaluator
A template or expression engine evaluates a string, and user-influenced input reaches that string. Now the "data" is executed as code by the engine, which is the most severe form because the interpreter is a general one. We have confirmed remote code execution of exactly this kind, where a crafted value handed to a server-side template handler ran on the server. No amount of quoting the value helps; the value was never meant to reach an evaluator at all.
Reconstructing it
The SQL case, in synthetic form, shows why "filter the bad characters" is a losing game.
# Vulnerable: the value is concatenated into the query text. query = "SELECT name, email FROM users WHERE status = '" + status + "'" # Benign call: # status = "active" # -> ... WHERE status = 'active' # Hostile call: # status = "x' UNION SELECT card_number, cvv FROM payments --" # -> ... WHERE status = 'x' UNION SELECT card_number, cvv # FROM payments --' # The value did not "contain bad input". It became SQL, # because it was placed in the SQL channel.
Now try to defend this by filtering. Block the single quote and the attacker uses a numeric context, or a different encoding, or a comment style you did not list. Block UNION and they nest it, case-vary it, or use a stacked query. Every filter is a denylist of things you thought of, tested by an interpreter that knows every construct you did not.
Why the obvious defenses didn't solve it
Input validation. Validation is genuinely useful for what it is: rejecting inputs that are the wrong type, length, or shape at the edge of the system. It is a fine early filter. It is a terrible last line of defense against injection, because a value can be perfectly valid for the field and still be dangerous in the query. An attacker's email address is a valid email. A traversal path can be a valid string. Validation checks whether the input is plausible, not whether it is safe when concatenated into another language.
Escaping by hand. Manual escaping tries to neutralize the special characters of the target language. It fails in two ways. You have to escape correctly for the exact interpreter, dialect, and context every single time, and one missed spot is the whole bug. And escaping does not apply where the value should never have been code at all, as in the template case.
A web application firewall. Signature-based blocking at the perimeter raises the effort and catches noisy automated attempts. It is a speed bump, not a boundary. It sees strings, not your query, and payloads that evade signatures are a well-worn craft. Treating a WAF as the fix leaves the actual sink untouched.
Root cause
The root cause is that data and code were carried in the same channel. The interpreter then had no way to tell which parts were the developer's instructions and which were the user's input, so it treated all of it as instructions. Filtering attacks the symptom (particular dangerous strings) while leaving the cause (a shared channel) in place. The durable fix removes the shared channel: the code is fixed and separate, the data travels beside it in a slot the interpreter treats as a value and never as syntax.
That single idea has a specific form for each sink:
- SQL: parameterized queries and prepared statements. The query text is fixed; parameters are bound and can never change the query's structure.
- Files: resolve the requested name against an allowlist or a lookup, canonicalize the result, and confirm it stays within the intended directory. Do not build a path by concatenation.
- Templates and evaluators: never pass user input into a template or expression that gets evaluated. Use context-aware output encoding for display, and keep dynamic evaluation away from untrusted data entirely.
How to test for this class of failure
- Find the sinks, not the payloads. Trace where request data reaches a query, a path, a command, or an evaluator. The sink is the bug's home; the payload is just proof.
- Probe with grammar, not a wordlist. A value that changes the interpreter's behavior (an extra row returned, a different file read, a timing shift on a boolean condition) confirms the channel is shared, regardless of which exact payload triggers it.
- Test contexts beyond quoted strings: numeric parameters, identifiers,
ORDER BYpositions, and anywhere a value might sit unquoted. - For file access, test canonicalization: traversal sequences, absolute paths, encoded separators, and symlink-style tricks that survive a naive prefix check.
- Do not accept a WAF's block as an all-clear. Confirm the sink is fixed, because a filtered attack today is an unfiltered one after the next bypass.
How to design the control correctly
- Separate code from data at every interpreter boundary. Parameterize queries, use safe path resolution, keep untrusted input out of evaluators. This is the whole game.
- Make the safe way the default way. Provide and require query builders and helpers that parameterize by construction, so a developer has to go out of their way to concatenate. Lint or block raw string-built queries in review.
- Apply least privilege behind the sink, so that if injection ever does occur, the database account, the file permissions, and the runtime can only reach a little. Defense in depth, layered under the real fix rather than in place of it.
- Keep validation, but demote it. Validate types and shapes at the edge because it is good hygiene and stops obvious junk. Just never rely on it to make an unparameterized sink safe.
What we took away from it
The reason "input validation" persists as the answer is that it feels proportionate: bad input, so clean the input. But injection is not fundamentally about the input. It is about the channel. Once you see SQL injection, path traversal, and template code execution as the same structural error (data placed where the interpreter reads code), the fix stops being a filter you tune forever and becomes a boundary you draw once.
It also reframes what "fixed" means. A finding in this class is not closed when a payload gets blocked. It is closed when the sink can no longer confuse data for code, at which point the entire family of payloads for that sink stops working at the same time. That is the difference between patching an attack and removing a vulnerability.
References
- OWASP Top 10 (2021): A03 Injection
- OWASP: SQL Injection Prevention Cheat Sheet
- CWE-89: SQL Injection
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)
- CWE-94: Improper Control of Generation of Code (Code Injection)
Internal evidence: Kahu Labs Research, anonymized authorized assessments (2024, 2025). Client, target and identifying details are withheld; see our research policy.