Skip to content
Self-Signed
Go back

Tracking SQL Injection at Runtime, One Token at a Time

Some platforms let you write HTTP endpoints as small scripts. A request comes in, a script runs, and the script builds SQL. Most of these platforms offer parameter binding, but nothing forces anyone to use it—and developers given a string concatenation operator will, inevitably, concatenate strings. After a few years, there are thousands of scripts that glue request values into query text with + and template strings.

Reading all of them is not realistic. Grepping for concatenation finds every script that builds SQL dynamically, most of them harmless, because the concatenated value is a constant, a validated enum or a number that the script parsed itself. What you actually want to know is narrower: where does attacker-controlled text reach a query, and when it does, does it change the query’s structure?

This post describes a runtime answer to that question: request-scoped taint tracking inside the script engine, plus a lexical check right before each query runs. It only records findings and never blocks. It is cheap enough to leave on in production.

The criterion: stay inside one token

Su and Wassermann gave a clean definition of injection in The Essence of Command Injection Attacks in Web Applications (POPL 2006): an injection happens when input changes the syntactic structure of the query, rather than only filling in a leaf of it.

A full parse of the query isn’t needed to enforce a useful version of that. It is enough to look at tokens. Every tainted span must stay inside one token, and that token has to be one of two things:

  • The inside of a closed string literal. '...' with the taint strictly between the quotes.
  • A single standalone value, such as a number or a word (an identifier or keyword). A signed number like -1 counts as one value even though the lexer produces two tokens.

Anything else is a finding:

Query text (tainted part in [...])Verdict
WHERE name = '[alice]'fine: inside a closed string
WHERE id = [42]fine: one number
WHERE id = [-1]fine: one signed number
ORDER BY [created_at]fine: one word
WHERE name = '[o'brien]'quote: the taint closes the string
WHERE id = [1 OR 1=1]multi-token
WHERE id = [1; DROP TABLE t]semicolon
WHERE id = [1 --]comment
ORDER BY a [,]syntax: a lone operator or punctuation token

The o'brien row is worth a second look. It is not an attack; people named O’Brien have simply been breaking naive SQL for decades. But that is the point: a structural check finds the injectable code path on benign input, as long as the input happens to contain the right character.

Step 1: mark sources by identity

When a request arrives, every string in its parameters, path variables, headers and decoded body is registered as tainted. A container is walked recursively, and each string leaf is registered on its own, under a source name such as body.filter.name or query.id.

The registry is an IdentityHashMap<String, Range[]> that lives for exactly one request, on the request’s thread. Keying by object identity instead of by content matters: if a script contains the literal "admin" and a request also sends "admin", only the request’s string object is tainted. Matching by content would taint every constant that happens to equal user input.

There is one caveat with identity on the JVM. If the JSON parser interns string values, a request value and a constant could end up being the same object. Parsers commonly intern field names, not values, but check yours.

Step 2: propagate through the script

The engine is an interpreter, so propagation hooks go where strings are produced:

  • Concatenation. For a + b, the result keeps a’s ranges at their offsets and b’s ranges shifted by len(result) - len(b). Computing the shift from the end is deliberate: the left side may be a number or an object whose string form you’d otherwise have to recompute.
  • Template strings. The template builder records the offset where each interpolated value lands and attaches the shifted ranges to the result.
  • Method calls. The engine doesn’t know what an arbitrary method does, so it uses two generic rules:
    • If the result is a substring of a tainted operand, or the same string in a different case, project the ranges by position. That covers trim, substring and toLowerCase.
    • Otherwise, search the result for the original text of each tainted fragment, and taint wherever a fragment appears. That covers format, join, and the untouched parts of replace.

Fragment search is a heuristic, and it errs toward over-tainting. A one-byte tainted value such as "1" will match every 1 in the result. Because the system only records findings, a false positive costs a line in a log. A false negative would cost much more, so erring on the side of false positives is the right trade-off.

Some places are blind to all of this, and it’s worth listing them:

  • code that runs on another thread (async blocks, thread pools) doesn’t see the request’s scope;
  • concatenation done inside Java code, such as a StringBuilder or a fluent SQL builder, bypasses the interpreter’s hooks;
  • a method that rewrites content, such as escaping, can lose the parts whose original text no longer appears in the result.

Step 3: check at the sink

Right before a query executes, the engine looks up the taint ranges of the final SQL string and runs the check:

  1. Merge overlapping and adjacent spans. Two values that are each fine on their own can escape together. 1 is one number and -1 is one signed number, but 1 + -1 is 1-1: three tokens.
  2. Tokenize the query with a lexer, not a parser.
  3. For each merged span, collect the non-whitespace tokens it touches, and apply the rules from the table above.

A lexer is enough because the criterion only needs token boundaries. It also has to work on partial or broken SQL, which is exactly what injected queries often are, and a lexer copes with that much better than a parser.

The lexer does have to know the database’s real lexical rules, because an attacker will use whichever rule you forgot. For PostgreSQL that means at least the following:

  • '...' with '' as the escaped quote;
  • E'...', where backslash escapes are active, so \' doesn’t end the string;
  • B'...', X'...', N'...' and U&'...' prefixes;
  • dollar quoting: $tag$ ... $tag$;
  • "quoted identifiers";
  • -- comments and /* */ comments, which nest in PostgreSQL;
  • $1 and ? parameter markers.

An unclosed string or comment runs to the end of the input and is flagged as unclosed: taint inside an unclosed string is a quote finding, and taint inside an unclosed comment is a comment finding.

Each finding records the source names of the spans, the reason, the tainted fragment and some surrounding context. Roughly:

reason=QUOTE  source=body.filter.name
...WHERE customer_name = '[o'brien]' AND status = 1...

Making it safe to leave on

The hooks run on every string concatenation in every request, so they have to be harmless:

  • Never throw. Every hook catches its own errors. On failure it drops the taint scope for the current request, logs once, and lets the request carry on untracked.
  • Bounded memory. The registry caps how many strings it holds and how many characters they add up to. That stops loops like sql = sql + piece from registering every intermediate string. When the per-string range list grows too long, adjacent ranges merge pairwise. Merging loses precision but never drops a tainted byte.
  • Near-zero cost when idle. If the scope is empty, each hook pays a single ThreadLocal read and returns.
  • Record, don’t block. Blocking would turn every false positive into an outage, and nobody would keep it enabled.

What it doesn’t catch

Token confinement only covers syntactic injection. It misses several things:

  • Second-order injection. A value stored yesterday and read back from the database today is no longer tainted.
  • Semantic abuse inside one token. ORDER BY [password_hash] is a single word and passes the check, but the caller has still chosen a column it shouldn’t. Identifiers in sort and column positions need an allowlist, not a taint check.
  • Pattern injection. % and _ inside a LIKE string stay inside the literal, but they still change what the query matches.
  • Anything evaluated after the check. If a downstream layer re-interprets the SQL text (such as expanding template placeholders), the check must run after that expansion, or the taint metadata must be preserved across it.

Using the findings

Benign production traffic rarely contains the characters that break a token. So the findings log works in three ways:

  • A tripwire. A real injection attempt shows up immediately, with the endpoint and the parameter name.
  • A coverage tool. In staging, send each endpoint the same request with a ' appended to every string field. Any endpoint that produces a finding is an injection point with a confirmed source and sink. You get that without reading the script.
  • A worklist. Sort findings by endpoint, fix them by switching to bound parameters, and watch them disappear from the log.

The mechanism is small: a few hundred lines for the taint registry and its propagation, and a few hundred more for the lexer and the check. In return, “thousands of scripts we can’t audit” becomes “a list of the ones that actually let input escape”.



Previous Post
The QUIC Initial Is Not Encrypted (For You)
Next Post
A QUIC Client Fingerprint Is More Than Its ClientHello