SQL injection in the ORM era: where it still lives
Parameters bind values, not identifiers, and every ORM has escape hatches. Where SQLi survives: sorting, reports, blind and second-order variants.
- AUTHOR
- Karol Rapacz / CEO of Breachroad · OSCP · PNPT
- PUBLISHED
- 25 July 2026
- READING TIME
- 17 min read
- TOPIC
- Penetration Testing and AppSec
SQL injection was supposed to disappear once object-relational mappers and parameterised queries became universal. It did not disappear — it moved house. Today you will almost never find it in basic entity operations, because those go through a safe API. You will find it wherever somebody had to build a query the API did not support: dynamic sorting, report filters, multi-criteria search, exports, admin panels, batch jobs and stored procedures written a decade ago.
Understanding why requires one distinction that determines everything else.
Parameters bind values, not structure
A parameterised query works because the database receives the query text and the data separately. The engine parses the structure once and substitutes values into marked positions without interpreting them as syntax. That is effective and, in practice, unbreakable.
The limitation is that a parameter can only represent a value. It cannot be a table name, a column name, a sort direction, a comparison operator or a clause fragment. When an application must build a query where the variable part is exactly that, parameterisation does not apply — and that is precisely where developers reach for string concatenation.
The practical conclusion: every dynamic sort and filter is a vulnerability candidate until you see, in the code, a map translating user input into a constant defined by the application. Not a list of forbidden characters — a map. The input created_at should translate into a literal column name present in the source, and anything outside the map should end in a rejected request.
Where the ORM leaks
An object-relational mapper is not a security boundary but a convenience, and every one of them ships documented escape hatches. That is where modern SQLi lives.
Methods that execute raw queries are the obvious candidate, but not the only one. More dangerous are raw SQL fragments inserted into the middle of a safe query — a condition in a filter, an expression in a sort clause, a fragment in a join. They look innocent because safe API code surrounds them, so review skips over them easily.
Object-layer query languages form a separate category. Injection into an ORM query language is less spectacular than the classic form but still allows reading data outside the intended scope, particularly by manipulating conditions.
Pattern matching deserves a mention too: a value passed to a matching operator is a parameter, so it does not lead to SQL injection, but wildcard characters inside it change the query’s result. Without handling them, a user can force a full table scan or surface records they should not see in search results. That is not SQLi, but it is often confused with it and also needs handling.
Similarly, access paths into semi-structured data: expressions addressing elements inside a JSON column are often built by concatenation, because the driver provides no parameter for them.
Second-order variants
The hardest variant to find does not manifest where the data enters. The value is stored correctly, through a parameterised query, and does nothing wrong for a while. Later another component reads it — a report generator, a nightly job, a stored procedure, a migration, an integration — and builds a query from it by concatenation.
This variant sails past scanners, because a scanner observes the response to a request while the effect appears an hour later in a different system. The only way to find it is by tracing the data lifecycle: where is this value read later, and by what code.
For the same reason stored procedures are not a safeguard. A procedure that internally builds a query by concatenation is vulnerable in exactly the same way, and it often runs with higher privileges than the application account, so the impact is larger.
Blind variants
The absence of an error message and of data in the response does not mean the absence of a vulnerability. Three variants work without any echo.
In the boolean variant, the attacker infers from whether the application’s response looks like the true or the false case — a difference in result count or message text is enough.
In the time-based variant, inference relies on response timing, forced by a delay operation on the database side. It works even when the application always returns the same error page.
In the out-of-band variant the database performs a network operation — a name resolution or a request — and observing that traffic provides confirmation. This is sometimes the only detection route for queries executed asynchronously.
For defenders the conclusion is concrete: silence in the response is not proof of safety, and a query timeout plus blocking outbound traffic from the database server removes two of the three channels.
Impact beyond data theft
Data theft is the most cited outcome, but not the only one and not always the worst.
Authorization bypass: injecting into a filter condition can return other tenants’ records, which in a multi-tenant application is equivalent to breaking isolation.
Data modification: if the database account holds write rights where it does not need them, a vulnerability in a read query can change system state.
Infrastructure escalation: with excessive account privileges or enabled engine extensions, reaching the database server’s filesystem becomes possible, and from there further. This depends on the specific engine and configuration, but it is a real scenario in environments that never restricted privileges.
Denial of service: forcing a high-complexity query can saturate a database faster than any volumetric attack.
Layered defence
Parameterisation without exceptions. Including “trusted” data coming from another system, from configuration or from the database. The exception made for an internal value is exactly where the second-order variant is born.
Allowlists for identifiers. Column names, table names, sort direction and comparison operators come from a dictionary in the code. User input is a key into that dictionary, never its value.
Safe query construction. If the application genuinely needs complex dynamic SQL, build a layer that accepts a filter structure and generates the parameterised query itself — instead of letting every developer concatenate strings in a controller. Such a layer is testable and goes through one review instead of thirty.
Least privilege for the database account. A separate account per service, only the operations needed, no schema modification rights, no access to system views and metadata catalogues, no administrative privileges. That does not prevent injection but drastically reduces impact — the difference between reading one table and reading the entire instance.
Limits. Query execution timeout, a row cap at the data access layer, a response size limit. They constrain both timing variants and bulk extraction.
Error messages. Database error details must not reach responses. They should reach logs, where they are valuable — more on that shortly.
Static analysis and review. A rule that flags concatenation or interpolation in query context has an excellent hit-to-false-positive ratio. Enable it and treat every hit as requiring justification.
A web application firewall is a delay, not a fix. Edge rules raise the cost of automated scanning and can be useful as a detection signal, but they do not change the fact that the application assembles a query from data.
Testing methodology
Testing starts with an inventory of entry points, and the list is wider than form fields: query parameters, request bodies, headers, cookies, filenames, data from other systems and values arriving through imports. Every place where data reaches a query is a candidate.
The second step is code review, where available. Locating raw SQL takes minutes and gives certainty that black-box testing cannot. In full-knowledge engagements this is always the best use of time.
The third step is observing behavioural differences as input changes: differences in result counts, message content, status codes and timing. Proceed methodically, recording each “input — observation” pair, because in blind variants that is the only route to reproducibility.
The fourth step is confirmation with minimal evidence. To demonstrate the vulnerability it is enough to show that a boolean condition in the query can be altered — there is no need to dump table contents. Bulk extraction from a client’s system is unnecessary, legally risky and occasionally destructive.
The fifth step, essential in production: some techniques can modify or corrupt data. Confirmatory tests belong in an environment with a restorable copy, and in production you limit yourself to observation without side effects — with that agreed in writing in the engagement scope.
Detection
The best detection signal is SQL syntax errors in application logs. A correctly functioning application never produces them, so every occurrence means either a code defect or an injection attempt. This is a near-zero-noise rule and yet it is rarely implemented.
The second signal is query execution time. A query taking many times longer than usual on an endpoint with stable characteristics points at a timing variant.
The third is returned row count per endpoint. An endpoint that usually returns a dozen records and suddenly returns tens of thousands deserves an alert regardless of cause.
The fourth is access to system views and metadata catalogues by the application account. In a properly configured environment the account has no such rights, but recording the attempts is valuable.
The fifth is outbound traffic from the database server, if the architecture does not expect any.
Debt in legacy code
In a ten-year-old codebase with hundreds of queries, rewriting everything at once is not realistic. A sensible order looks like this: first queries reachable without authentication, then queries executed under a broadly privileged account, then those on administrative paths, and the rest last.
In parallel, cut the impact before fixing the cause: restrict the database account’s privileges, introduce row and time limits, silence errors in responses and enable the detection described above. These changes are cheap, require no logic work, and turn a potential critical incident into a serious one — which is a real difference.
The same bug class in other interpreters
It is worth noting that SQL is merely the best-known instance of a more general pattern: user data reaches an interpreter that distinguishes syntax from values. The same flaw appears wherever any query language exists.
In document databases the classic case is passing a structure into a query instead of a scalar value. If the application layer accepts an object from the request and inserts it directly as a condition, the user can substitute a comparison operator for a value and change the query’s meaning — turning a password equality check into an always-true condition, for example. The defence is type enforcement: a field that should be a string must be a string, not a structure.
In LDAP directories, filter injection widens the search scope and bypasses restricting conditions. Because directories often drive authentication decisions, the impact is analogous to an authorization bypass.
In queries over XML documents, manipulating a path expression allows reading nodes outside the intended scope. This is a relative of the XML parser problem family and appears in the same integrations.
In all these cases the same rule applies as with SQL: pass values through the mechanism the library provides, and build structure from elements defined in code. If a library offers no parameterisation, that is an argument against the library, not an invitation to concatenate strings.
Checklist
All values reach queries as parameters, with no exceptions for internal data. Identifiers and sort directions come from a dictionary in the code. There is no concatenation in the data access layer, and exceptions are justified and reviewed. Stored procedures do not build queries by concatenation. The database account holds least privilege and no access to system catalogues. Query timeouts and row limits are enforced. Database errors never reach responses but do reach logs. The database server has no outbound traffic. Static analysis flags concatenation in queries. The data lifecycle has been analysed for second-order variants.
Conclusion
SQL injection is not a solved problem, only a relocated one. The data access layer in a typical application is safe today, but the exceptions to it never went away — they moved into sorting, reports, batch jobs and legacy code nobody wants to touch.
The practical takeaway has two parts. First, look where parameterisation cannot help by definition: identifiers and query structure. Second, design so that impact stays bounded whether or not an exception survived somewhere — least privilege on the database account and row limits cost one afternoon, and they decide whether an incident covers one table or the entire instance.
Primary sources: OWASP — SQL Injection Prevention Cheat Sheet, CWE-89: SQL Injection, CWE-564: SQL Injection: Hibernate.


