All articles Static analysis

SQL Injection in Modern Codebases: Why Detection Is Still Hard

Ariel Ben-David

SQL Injection Detection in Modern Codebases

When SQLAlchemy or Hibernate handles queries, the assumption is that parameterization happens automatically and SQL injection is no longer a meaningful concern. This assumption is approximately true for simple cases and increasingly wrong as codebases grow in complexity. Detecting SQL injection in a modern codebase requires understanding the full data flow path, and that path has more branches than it did in 2005.

Where ORMs Introduce False Safety

ORMs eliminate the most obvious injection vectors by default. When you call User.objects.filter(email=request.POST['email']) in Django, the ORM parameterizes the value correctly and no injection is possible at that call site. The safety is real and the coverage is broad for straightforward CRUD operations.

The breaks happen in specific patterns. The most common is raw query fallback. Developers reach for raw SQL when they need complex queries that the ORM's query builder does not express cleanly: dynamic column ordering, complex aggregations, database-specific functions. These fallbacks often look like this:

order_col = request.GET.get('sort', 'created_at')
results = db.execute(
    f"SELECT * FROM items ORDER BY {order_col} DESC"
)

The order_col value is user-supplied and interpolated directly into the query string. The ORM is not involved at this point. An attacker supplying sort=(SELECT+sleep(5)) has a timing-based blind injection channel.

Column name and table name parameters cannot be passed as bind parameters in most databases. Parameterized queries cover values; they do not cover identifiers. Any code path that constructs an ORDER BY, GROUP BY, or table name from user input must validate against an explicit allowlist. This is not a failure of the ORM; it is a fundamental constraint of the SQL protocol.

The Call Graph Problem

The harder detection challenge is tracing user input across service boundaries. In a modern microservice architecture, a value might arrive as a query parameter in one service, get stored in a shared cache, be read by a different service, and eventually reach a database query three hops later. The injection is not visible at the input point (no database call) or at the query point (no obvious user input). It is only visible if the analysis follows the value across all three hops.

Interprocedural analysis, analysis that tracks data flow across function call boundaries, handles single-service cases reasonably well. Cross-service analysis requires instrumentation at the boundary level, which most static analysis tools do not do by default. This is a genuine gap: a significant portion of injection vulnerabilities in microservice codebases are only detectable if the tool understands how data flows between services, not just within them.

One practical mitigation is treating inter-service inputs as untrusted at ingestion. Each service should validate and sanitize data it receives from other services as if it arrived directly from an external caller. This is a defense-in-depth pattern that limits blast radius even when static analysis misses a cross-service injection path.

Query Builder Chains

Query builder libraries like Knex.js (Node.js) or jOOQ (Java) sit between raw SQL and a full ORM. They provide a fluent API for constructing queries programmatically and often have injection-safe interfaces for most operations. The injection risk appears when developer code uses the library's string interpolation escape hatch for parts of the query that the builder API does not cover cleanly.

In Knex, the whereRaw method accepts raw SQL fragments. This is legitimate when used with parameterized bindings: knex.whereRaw('created_at > ?', [startDate]). It becomes an injection vector when user-supplied values are interpolated into the raw string: knex.whereRaw(`status = '${userInput}'`).

Static analysis tools need explicit knowledge of which methods in a given library are safe (parameterized) and which allow injection (raw string interpolation) to correctly classify these call sites. Tools without framework-specific models for query builders tend to either over-flag the safe parameterized usage or under-flag the unsafe raw interpolation.

Second-Order Injection

Second-order injection is the class of vulnerabilities where user input is stored in the database and later retrieved and used in a subsequent query without re-sanitization. The first write is safe; the second read-and-use is not. An example: a username is stored in the users table via a parameterized insert. Later, an administrative function constructs a query using the stored username as a component without parameterizing it, under the assumption that database-resident values are trustworthy.

This assumption fails when an attacker registers a username that contains SQL metacharacters: '; DROP TABLE sessions; --. The insert is safe. The subsequent dynamic query construction using the retrieved value is not. Detecting this requires the analysis to track the "taint" of a value through persistence: a value that was originally user-supplied retains its taint even after being stored and retrieved from the database.

Taint tracking through persistence layers is technically difficult and most tools approximate it rather than fully implementing it. When we encounter this class of vulnerability during analysis, we trace the original source of each persisted value and flag downstream uses that reconstruct query strings without parameterization.

Detection in Practice

For teams running static analysis on Python codebases with SQLAlchemy, the most actionable checklist is: scan for text() calls (SQLAlchemy's raw SQL constructor) and confirm each one uses bound parameters rather than string interpolation; scan for execute() calls with string arguments and trace the argument's origin; review any code that constructs ORDER BY or GROUP BY clauses from request parameters and verify it uses an allowlist rather than passing the value directly.

For JavaScript codebases using Knex, the equivalent is: scan for raw() and whereRaw() calls and verify binding usage; look for template literals or string concatenation in query construction outside of parameterized binding calls.

The finding categories that pure pattern-matching scanners miss are the cross-service flows and second-order cases. For these, the requirement is a tool that tracks value taint through persistence and across service entry points, not just within a single function or module. We are realistic about the coverage: full interprocedural cross-service taint analysis is expensive to implement and imperfect in practice. But raising the baseline by combining pattern-matching for obvious cases with deeper flow analysis for the common ORM-bypass patterns covers most of the exploitable surface area.

Tenzai

See findings and fixes together, not just findings.

Free plan available. Connect your first repository in under five minutes.

Start Free Trial

More from the Tenzai blog