SQL Injection in ORM-Backed Rails and Django Apps
ORMs eliminate obvious SQL injection but leave structural vulnerabilities developers routinely miss.

In November 2025, Django patched CVE-2025-64459, a SQL injection flaw rated CVSS 9.8, sitting inside some of the most ordinary methods in the ORM: filter(), exclude(), get(), and the Q() class. That single fact cuts against the assumption most teams build their security posture on: that choosing an ORM like Django or Rails ActiveRecord takes SQL injection off the table. Both frameworks do eliminate the most obvious injection paths: an attacker can no longer close a quote in a concatenated string and append their own clause. What they leave behind is a narrower, less obvious class of structural vulnerability, and developers miss it systematically because the ORM's reputation for safety does their threat modeling for them.
The core promise of an ORM is straightforward: values get parameterized. A username, a search term, an ID pulled from a query string, all of it gets bound as a placeholder, so the string itself can never be interpreted as a SQL command. That promise holds for the common case, and it's the reason ORMs earned their safety reputation. But a query is more than its values. The ORM still has to assemble the structure around them: which columns to join, how conditions combine, what aliases get used. That structural layer is built differently than the value layer, and it is not parameterized the same way.
When user input reaches that structural layer instead of the value layer, the ORM's protections don't apply there. Injection becomes possible through the framework's own documented API, not through some bypass of it. Indusface's analysis of the recent Django CVEs states the distinction directly: "Most people think 'ORM = safe from SQL injection.' That is generally true for values: Django's ORM parameterizes inputs so values cannot turn into SQL. This vulnerability is different: it lets an attacker influence the structure of the query, the pieces the ORM combines to build SQL rather than only the parameter values." The rest of this piece maps exactly where that structural layer gets exposed in Django and Rails, how attackers reach it in production, and what closes the gap.
How Django's ORM assembles queries
Django's ORM builds SQL in two distinct layers, and only one of them gets the safety treatment developers assume covers everything. Calling filter(username=request.GET['u']) makes Django emit a parameterized placeholder, so the value from the request never touches the SQL string directly, while calling annotate(**user_dict) does something different. The dictionary's keys become column aliases in the generated SQL. Aliases are identifiers, and identifiers don't get parameterized.
That distinction is the root of the pattern. Several QuerySet methods, including annotate(), alias(), aggregate(), and extra(), accept keyword arguments whose names flow directly into the generated SQL as alias identifiers. If those names originate from user-controlled input, rather than from a fixed set written by the developer, an attacker gains control over part of the query's structure. Developers name this the **kwargs expansion pattern, and it appears repeatedly in Django's CVE history.
CVE-2025-59681 affects QuerySet.annotate(), alias(), aggregate(), and extra() specifically on MySQL and MariaDB. Dictionary keys get used directly as column aliases, and on those two backends, the alias-handling syntax permits injection if the keys aren't sanitized first. PostgreSQL and SQLite are not affected by this particular issue. The fix shipped in Django 5.2.7, 5.1.13, and 4.2.25.
A related but distinct pathway runs through the connector keyword. QuerySet.filter(), exclude(), get(), and the Q() class all accept a _connector argument that controls how conditions combine in the query tree, AND versus OR. When a crafted dictionary gets expanded with ** and its _connector value comes from user input, an attacker can control how clauses join together and inject arbitrary SQL into the final statement. That's CVE-2025-64459, the CVSS 9.8 flaw mentioned above, affecting Django 4.2 before 4.2.26, 5.1 before 5.1.14, and 5.2 before 5.2.8, patched in the November 5, 2025 security release.
The pattern repeats in features developers would consider unrelated. CVE-2025-57833, disclosed in September 2025, hit the FilteredRelation component through the same structural mechanism: a specially crafted dictionary passed to QuerySet.annotate() or QuerySet.alias(). It was patched in Django 5.2.6, 5.1.12, and 4.2.24. A year earlier, CVE-2024-42005 hit QuerySet.values() and values_list() on models containing a JSONField, where maliciously crafted JSON object keys passed as arguments enabled injection. Four separate CVEs, four separate entry points, one consistent mechanism: structural arguments trusted as developer-controlled turn out to be reachable by attackers.
In production, the exposure concentrates wherever rich query parameters reach a QuerySet. That includes customer and partner portals that let users search or filter their own data, public REST or GraphQL APIs that translate query parameters into filter() calls, and long-lived services that are still pinned to older Django branches. None of these are edge cases or unusual engineering choices. They're the ordinary shape of a search box, a filterable list view, a reporting endpoint, the patterns the framework's own documentation walks developers toward building.
Where Rails ActiveRecord makes the same class of mistake
ActiveRecord's standard finder methods and association lookups use bind parameters, giving Rails the same value-versus-structure split that protects Django in the common case. Rails runs into trouble through a different set of escape hatches: raw SQL fragments and string interpolation passed into query methods that were never designed to sanitize structural input.
String interpolation inside a where clause, written as where("column = '#{params[:val]}'"), bypasses parameterization entirely, and so do find_by_sql, execute, and connection.execute. All four are documented, standard parts of ActiveRecord, and developers reach for them specifically when a query grows too complex for the safe API to feel workable. The order() and group() methods carry a related, longstanding risk: both have historically accepted raw strings without sanitization, and passing user input directly into either one is a well-known injection vector. It's a misuse pattern the framework simply permits, without needing a CVE to describe it.
The failure mode in Rails tends to follow a predictable arc. A developer reads that ActiveRecord protects against SQL injection, trusts that reputation broadly, and then hits a query that the safe, parameterized API can't express cleanly, a dynamic sort order, a conditional join, a reporting filter with ten optional parameters. At that point, string interpolation looks like the path of least resistance, and the framework doesn't stop them from taking it. The mechanism differs from Django's kwargs expansion, but the underlying mistake is identical: trusting the ORM's value-layer protections to cover a structural decision they were never built to cover.
Attack patterns that reach production and the exploitation bar
The vulnerable patterns in both frameworks live inside ordinary QuerySet operations on the endpoints every data-driven application has: search, filtering, reporting. That overlap between "common feature" and "exploitable pathway" is what keeps the attack surface large and the exploitation complexity low.
The most frequent entry point is a filter endpoint that accepts flexible parameters, search boxes, list views with sorting and filtering, reporting APIs, internal admin tools, and passes those parameters straight into filter(request.GET.dict()) or an equivalent call without stripping out structural keys first. An attacker targeting CVE-2025-59681 or CVE-2025-64459 doesn't need a sophisticated chain. The path is to call an endpoint that accepts dynamic filter parameters and inject keys or values that manipulate connectors or internal arguments; any endpoint that blindly forwards query parameters into filter(…) or Q(**…) is exposed. CVE-2025-64459 carries a description of "automatable" with "total technical impact," meaning once the path is known, it can be scripted, and it doesn't require privileged access or chain-building to reach.
What the attacker gets out of a successful exploit depends on the database user's privileges. At minimum, data the query was never meant to return becomes readable. With broader privileges, modification, deletion, or full schema enumeration become possible. Services that compound the risk tend to share a profile: long-lived, "frozen" Django deployments pinned to an older branch that was never upgraded, combining public exposure with a known, unpatched vulnerability in a single target.
Standard scanning tools struggle to catch any of this, and the reason is structural. DAST tools work from the outside, sending crafted inputs and watching for anomalies in the response: error messages, timing shifts, unexpected data. A structural ORM injection can produce a semantically valid, attacker-shaped query, so you can still get back a normal HTTP 200 with ordinary-looking JSON. The injection lives in the data, not the status code, so there's no outside signal for a scanner to catch. Finding it requires dataflow analysis through the application's own query-construction logic, which is a different kind of test entirely, and one the next section returns to.
The core problem, an ORM's safety reputation masking a distinct class of structural vulnerability, is exactly the gap penetration testing needs to expose. Platforms like Trace combine AI agents with human expert review to find exploitable vulnerabilities in code, so they can catch this class of miss, where the framework's own API becomes the attack vector because developers trust it.
What safe ORM usage requires
Closing the structural injection gap starts with a discipline most teams never formalize: treating query-construction inputs as something to validate explicitly before they reach the ORM, rather than assuming the ORM's value-parameterization already covers them. It doesn't, and it was never designed to.
The core discipline is separating two categories of input that look identical in code but behave completely differently under the hood. User-controlled values, the ones that end up bound as parameters, are handled safely by the ORM by default. User-influenced query structure, the column names, alias names, connector keywords, and other structural arguments, is not. If an allowlist check doesn't stand between the request and the query, you should never let user input determine any of those structural pieces. In practice, that means checking every key in that dictionary against a hard-coded set of permitted field names before calling filter(**user_dict), and rejecting anything outside that set. If dynamic filter dictionaries are genuinely unavoidable in a given codebase, structural keywords like _connector and _negated, along with any other Django ORM meta-keys, need to be stripped explicitly before the dictionary gets expanded. Indusface's guidance on this point is direct: "Validate User Input. Avoid directly using user-supplied data in query filters. Always sanitize and validate before including them in filter() or Q() objects."
Rails teams have a parallel version of the same discipline. The parameterized form of where, written as where("column =?", value) or where(column: value), should be the consistent default, and any use of string interpolation inside a query method deserves treatment as a code-review red flag requiring explicit justification before it merges, not a routine pattern.
Two supporting controls matter even when the primary discipline holds. Database accounts the application runs under should carry the least privilege the application needs to function, so that even a successful injection is bounded by what that account can read or modify; tightening those permissions limits the blast radius of a failure elsewhere. And staying on a supported, patched Django branch, current as of the November 5, 2025 security release that fixed CVE-2025-64459, is the baseline, not a substitute for the validation work above. Enabling Django's query logging in development is a low-cost habit worth adopting: it shows, in the logs, the actual SQL generated by a given code path, so a developer can catch structural anomalies before that code ever reaches production.
What all of this amounts to is a set of code-review and design disciplines, not a tool that runs once and reports back. That raises the real question: how does a team verify these disciplines are being followed?
Why a pen test must find these vulnerabilities
Automated scanners test what's observable from outside the application. Structural ORM injection happens inside query construction and often produces a response that looks completely normal, so that kind of testing can't see it, and you need a whitebox, code-level approach instead.
DAST tools operate by sending crafted inputs and watching for anomalies: error messages, timing differences, unexpected data in the response. A structural injection that returns a semantically valid, attacker-shaped result set can produce a clean 200 status with normal-looking JSON, generating no signal for the tool to flag. Because the vulnerable pathways, kwargs expansion, _connector injection, FilteredRelation abuse, live inside the framework's documented APIs rather than in obvious raw SQL strings, they require whitebox access to the application's source to map reliably. Finding whether user input reaches a structural argument in a QuerySet method, and whether an allowlist check sits between the two, means reading the code path directly.
A thorough pen test of an ORM-backed application needs to include source code review of the query-construction logic itself, explicit tracing of user-controlled inputs through to the ORM method calls they reach, review of every **kwargs expansion pattern present in the model layer, and confirmation that each finding can be demonstrated with a working exploit. Greybox testing, where a tester works with partial knowledge like credentials or API schemas, helps you reach more of the application's surface area, but it still falls short of a full code-level trace through query construction. Penetration testing platforms that integrate directly with source code repositories can follow how user input moves from an endpoint into QuerySet construction, so they surface structural injection points that static analysis on its own tends to miss.
Rails applications carry the identical requirement for a different reason. ActiveRecord's parameterized mechanisms protect most value-level input by default, but developers reach for escape hatches and string-interpolation patterns under deadline pressure, and no testing approach that only watches for obvious SQL strings in a request or response can see them. Finding them calls for comprehensive code review paired with dynamic exploitation testing that confirms a flagged pattern is actually reachable and actually exploitable, not just present in the codebase.


