Injection

Why a concatenated query returns every row, why parameters work where escaping does not, and the identifiers placeholders cannot cover.

6 min read🛡️ Application Security

Injection is what happens when data becomes code. A value arrives as text, gets pasted into something a machine interprets, and a character inside it changes what that machine was told to do.

It has been at or near the top of the OWASP list since the list existed, not because the fix is hard — the fix is one line — but because the broken version works perfectly until someone types an apostrophe.

The same bug, three inputs

Here is the query everybody writes first, and the parameterised version beside it, against the same three-row table.

An ordinary name. Both correct:

plaintext
### ordinary input: ana
###   concatenated -> ana=100
###   parameterised -> ana=100

This is why the bug survives review. It works.

A name containing a quote. The concatenated version breaks open:

plaintext
### input containing a quote: ana' or '1'='1
###   concatenated -> ana=100 bo=5000 cy=250
###   parameterised -> (no rows)

Every row in the table, including another customer's balance. The quote closed the string the query author opened, and everything after it was read as SQL rather than as a name.

And an ordinary surname:

plaintext
### an ordinary name with an apostrophe: O'Reilly
###   concatenated -> SQLException: Syntax error in SQL statement
                      "select owner, balance from account where owner = 'O'Reilly[*]'"
###   parameterised -> (no rows)

This is the case worth dwelling on, because it reframes the whole subject. The same defect that lets an attacker read every row also breaks for a customer named O'Reilly. Injection is not a separate security concern bolted onto working code — it is a correctness bug about mixing data with syntax, which happens to also be exploitable.

Why parameters work, and escaping does not

The instinct after seeing the above is to escape the input: double the quotes, strip the apostrophes, reject odd characters. It is the wrong shape of fix, and the reason is structural.

Escaping means: build one string containing both instruction and data, then try to make the data harmless. You are still handing the database a single sentence and hoping it parses the way you intended. Every escaping bug in history is a case where some encoding, some character set, some nested context made the escaping insufficient.

A parameterised query does something different in kind. The SQL goes to the database with placeholders, is parsed into a plan, and only then are the values supplied — as values, in a separate channel, to a statement whose structure is already decided. There is no parsing step left for the input to influence. That is why the malicious string above returned (no rows): the database looked, in perfect good faith, for a customer literally named ana' or '1'='1.

It is also why parameterisation fixes O'Reilly for free. You never asked the question that apostrophes make ambiguous.

java
// the defect
"select owner, balance from account where owner = '" + owner + "'"
 
// the fix
PreparedStatement ps = c.prepareStatement(
    "select owner, balance from account where owner = ?");
ps.setString(1, owner);

The part parameters cannot reach

Placeholders stand in for values. They cannot stand in for a table name, a column name, or a sort direction — those are structure, and structure has to be decided before parsing.

So this is still a hole:

java
"select * from account order by " + sortField + " " + direction   // not fixable with ?

The answer is an allow-list, not sanitisation:

java
private static final Set<String> SORTABLE = new HashSet<>(asList("owner", "balance", "created_at"));
 
if (!SORTABLE.contains(sortField)) throw new BadRequestException("unknown sort field");

Map the user's input onto a value you wrote, and never build identifiers from what arrived. The pagination lesson made the same point for a different reason — this is the security half of it.

Where else the same shape appears

Once you see injection as data reaching a parser, the family becomes obvious, and several of these are the ones people forget:

  • JPQL and HQL. An ORM is not immunity. "from Account where owner = '" + owner + "'" is injectable; setParameter is the fix. Everything in the JPA course about @Query assumes named parameters for this reason.
  • Command injection. Runtime.exec("ping " + host). Use the array form, which does not go through a shell, and allow-list the input.
  • LDAP, XPath, NoSQL filters. Same shape, different parser, and the driver's parameter mechanism is again the fix.
  • Log injection. A newline in user input writes a fake log line. Everything the operator later reads is now partly attacker-written, which matters when somebody greps that file during an incident.
  • XML external entities (XXE). An uploaded XML document referencing a local file, parsed by a configured-by-default parser, reads that file. Disable external entities on every XML parser you construct.
  • Deserialisation. Handing untrusted bytes to a mechanism that constructs arbitrary objects is the most severe member of the family. Do not deserialise untrusted input into arbitrary types; use a schema-validated format, which is what the JSON lesson's advice about validating at the boundary is really protecting.

Finding it before someone else does

  • Grep for string concatenation next to select, insert, update, delete and where. Crude, fast, and it finds most of them.
  • Turn the warning on. Static analysis — SpotBugs with the security plugin, or your platform's scanner — detects tainted values reaching a query. Wire it into CI, where it runs on code nobody is reviewing carefully at five o'clock.
  • Test with the apostrophe. A single ' in every free-text field, as a habit, during ordinary manual testing. It is not a penetration test; it is the fastest way to discover that a field is concatenated, and it doubles as a real-data test for every customer named O'Brien.
Progress is saved on this device and to your account when signed in.