DedSec Security Research ·
Here is a hypothetical. A dynamic scan of a staging environment returns three findings, all titled "SQL injection", all on GET parameters. The first is on /orders?id=. The scanner sent 1', got an HTTP 500, and the body contained the word "syntax". The second is on /search?q=. A payload containing a five-second sleep took 5.6 seconds to come back. The third is on /reports?sort=. Two requests that the scanner called "true" and "false" came back with response bodies that differed, but only in the order of the rows.
The developer who picks up the first one calls it a clear injection, because a database error is the textbook sign. The second is triaged as critical, because blind time-based SQL injection is how real data gets stolen. The third is closed as noise, because the reporting module uses an ORM and the ORM parameterises everything. All three calls are probably wrong, and they are wrong for the same reason: nobody asked what the scanner actually observed, or what that observation can prove.
This post is for whoever is holding a SQL injection finding from a dynamic scan. It covers what each detection technique observes, what evidence each one produces and how each produces false positives, why the injection context changes both the test and the fix, why second-order injection does not show up in a request-and-response diff, how a scanner works out which database it is talking to, why some of these tests are unsafe against production, and what the fix actually is. It is the companion to the reflected XSS post, which makes the same argument for cross-site scripting. It builds on what a SAST false positive is in mechanism, out-of-band testing, DAST coverage and why "the scan passed" is not "the scan ran".
Sources: the OWASP SQL Injection Prevention Cheat Sheet; the OWASP Web Security Testing Guide's SQL injection test (WSTG-INJT-05); the sqlmap wiki's Techniques and Usage pages; CWE-89; the PostgreSQL documentation on delaying execution (section 9.9, Date/Time Functions); the MySQL 8.4 reference manual on SLEEP(), string literals and SQL modes; Microsoft's Transact-SQL documentation for WAITFOR and the + and || string concatenation operators; and the Django and SQLAlchemy documentation on raw SQL. All were retrieved on 30 October 2026. The PostgreSQL error messages and timings quoted below come from a throwaway PostgreSQL 16 container with no network access, run the same day. The opening scenario is hypothetical, and no number in this post measures any scanner, ours included.
CWE-89 defines the weakness as a product that "constructs all or part of an SQL command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended SQL command". The OWASP cheat sheet's version is shorter: SQL injection "occurs when untrusted data becomes part of a query's SQL syntax."
That is the claim a finding has to support: the input changed the structure of a query, not just its value. A dynamic scanner cannot see the query. It sees requests, responses, and the time between them. Every technique below is a way of inferring a structural change from those three things, and each makes the inference with a different strength.
The WSTG groups the attacks into three classes. In-band attacks get data back through the same channel the injection went in, such as the page. Out-of-band attacks get it through a different channel, such as a DNS lookup or an HTTP request from the database server. Inferential, or blind, attacks get no data back at all, and the tester "can reconstruct the information by sending particular requests and observing the resulting behavior of the DB Server". sqlmap names six SQL injection techniques and gives each a letter for its --technique option: B for boolean-based blind, E for error-based, U for UNION query-based, S for stacked queries, T for time-based blind and Q for inline queries. The default is all six, BEUSTQ. Out-of-band DNS exfiltration is a separate option, --dns-domain, covered further down.
The WSTG's first test is the one every scanner runs: add a single quote or a semicolon to the parameter and watch for an error. A quote that reaches SQL syntax unbalanced breaks the query, and a database error in the response is the classic sign.
The trouble is that many things other than a broken query produce a 500, or a page containing the word "error" or "syntax".
1' before any database is involved. Depending on the framework, the rejection is a 400, a 404 or a 422, or an unhandled exception that becomes a 500. Any of these can contain words like "invalid syntax" or "could not parse".id=abc fails the same way as id=1', the quote is not the cause.A DBMS error is stronger evidence, and it still needs reading. Here is what PostgreSQL 16 returned in our container for the quote probe against an integer column, in three different query shapes:
WHERE id=1', the result is unterminated quoted string at or near "'". The quote reached SQL syntax.WHERE id='1'', the result is again unterminated quoted string. The quote reached SQL syntax.invalid input syntax for type integer: "1'". The same message came back when the value was a correctly escaped string literal.The third message is a type error about a value. It shows that the input arrived as data and was rejected when the database tried to convert it to an integer. That is the parameterised case, and a scanner that matches on the words "syntax" or "error" will raise it as SQL injection. It does tell you that database error text is reaching the response, which is a real information-disclosure finding with its own severity. It is not an injection.
So the error probe is a lead, and confirming it takes a contrast. The WSTG asks testers to vary one parameter at a time "while all the others remain constant". Two pairs do most of the work:
x' breaks and x'' does not, the second quote has closed the string the first one opened. That means a quote reached SQL syntax and a doubled quote repaired it. A validator that rejects quotes would reject both.id=2-1 returns the same record as id=1, and a different record from id=2, something evaluated the expression. A strict integer parser rejects 2-1, and a lax one that reads the leading digits returns record 2. Our container's WHERE id=2-1 returned the row with id 1, as you would expect from SQL.Neither pair proves injection by itself; another expression evaluator could produce the same result. Together, and repeated with different values, they are much stronger evidence than a 500.
Error-based injection, as a technique, is different from error-as-signal. sqlmap describes it as injecting "a database-specific error message provoking statement" and then parsing the response "in search of DBMS error messages containing the injected pre-defined chain of characters and the subquery statement output within". The WSTG's Oracle example concatenates the result of a subquery into a function that fails, so the database's error message reports the current database user as an unknown host name.
The evidence is the computed value. If the response contains a string the scanner chose, joined to a result only the database could have produced, then the database executed an expression the scanner wrote. A marker like qzx + result + qzx cannot come from a generic error page, a WAF or a type validator. For confirmation, the computed value should be harmless and predictable, such as an arithmetic result or a version string, not a value read out of a table.
The technique has a precondition, and sqlmap states it plainly: it "works only when the web application has been configured to disclose back-end database management system error messages." Turning off verbose errors does not remove an injection. It removes this technique and moves the attacker on to the blind ones below.
Boolean-based detection asks a yes or no question through the query and reads the answer from the page. The WSTG's form for a numeric parameter is a pair: id=10 AND 1=2, which should return an empty or different page, and id=10 AND 1=1, which should return the original. For a string context the payload has to balance the quotes the application adds, as in x' AND '1'='1 and x' AND '1'='2.
The finding needs three responses, not two: the original request (the baseline), the true variant and the false variant. The evidence is that the true variant matches the baseline and the false variant does not. If you only have "the two injected responses differed", you have a much weaker claim.
Many pages change when the input changes, whether or not there is injection. Take a search box backed by full-text search. q=shoes AND 1=1 and q=shoes AND 1=2 are two different search strings, and they can return different results simply because one contains the token "2" and the other does not. Neither request touched SQL syntax, and the true/false pair still differed.
The control is to ask the same question in different words. Two different true expressions, such as AND 7=7 and AND 'b'='b', should give the same response as each other and as the baseline. Two different false expressions, such as AND 7=8 and AND 'b'='c', should give the same response as each other and differ from the baseline. If the response follows the truth of the expression and ignores its text, the expression was evaluated. If it follows the text, it was searched.
The second problem is that pages are not stable. Timestamps, CSRF tokens, session-bound nonces, rotating recommendations, request IDs in a footer and A/B test variants all make two requests for the same URL return different bytes. A naive byte comparison then calls everything different, which kills detection, or it uses a loose threshold and calls everything similar, which hides real differences.
The WSTG names the problem: "If the server returns two different pages as a result of two identical consecutive web requests, we will not be able to discriminate the true value from the false value". It describes the fix as filtering out "the code that changes between the two requests" to obtain a template. sqlmap's Usage page describes how its comparison works and where it fails. By default it compares each injected page with the original, and it "tries hard to identify" dynamic snippets, such as "a counter, a dynamic advertisement banner". When that fails, a tester can supply a string that appears only on true pages (--string), one that appears only on false pages (--not-string), a status code (--code), or ask it to compare only titles (--titles) or only the visible text (--text-only).
In practice, before trusting a boolean finding, request the baseline several times and diff those responses against each other. Whatever differs between identical requests is noise, and it has to be excluded from the true/false comparison. If the only difference between the true and false responses is in a region that also differs between two baseline requests, there is no finding.
It gives you an oracle: one bit per request. sqlmap's Techniques page describes using that bit to extract data "character by character" with a bisection algorithm, at "about seven HTTP requests" for a plain ASCII character. That is slow, but it is complete. Given enough requests, a boolean oracle reads anything the database account can read. A boolean-only finding is not a low-impact finding.
When the page does not change at all, the remaining signal is time. Inject a condition that makes the database wait, and measure.
Each database spells the wait differently, and the differences matter.
IF(1=1, SLEEP(3), 0) placed where an expression is accepted.1=(SELECT 1 FROM pg_sleep(3)) is one way to place it inside an expression. The documentation warns that the delay "will be at least as long as specified. It might be longer depending on factors such as server load."WAITFOR DELAY '0:0:3'. Because it cannot sit inside an expression, a time-based test on SQL Server usually ends the original statement and starts a new one, which makes it a stacked query (see below). Microsoft's documentation also says "The actual time delay might vary from the time specified" and "depends on the activity level of the server."Two of the three vendor documents say outright that the delay you ask for is not the delay you get. That is before counting the network, the application server, connection pool waits, garbage collection, a cold cache and whatever else the target is doing. A single response that took five seconds after a five-second sleep payload is consistent with injection. It is also consistent with a busy server.
IF(1=1, SLEEP(4), 0) should be slow and IF(1=2, SLEEP(4), 0) should be fast, with the same characters in roughly the same places. This rules out a WAF or validator that is slow on anything containing SLEEP.sqlmap also documents an option, --timeless, that replaces the sleep with the "timeless timing" technique from Van Goethem and others (USENIX Security 2020). It sends two requests over HTTP/2 multiplexed streams and reads which one finishes first, so network jitter affects both equally. The Usage page notes that this is only valid when the target "actually processes the two multiplexed streams concurrently", and that sqlmap calibrates first to check.
The same payload can sleep for much longer than requested, or not at all, depending on how the database evaluates it. We checked this in our PostgreSQL 16 container against a four-row table with no index:
WHERE id>0 AND pg_sleep(0.5) IS NULL took about 2.0 seconds. The sleep ran once for each of the four rows.WHERE id>0 AND 1=(SELECT 1 FROM pg_sleep(0.5)) took about 0.5 seconds. The subquery does not depend on the row, so it ran once.WHERE id=1 AND pg_sleep(0.5) IS NULL also took about 2.0 seconds. The id=1 condition did not stop the sleep from running on the other rows.WHERE 1=2 AND pg_sleep(0.5) IS NULL took well under a millisecond. The planner saw the constant false condition and never ran the sleep.On a table with a million rows and no index on the filtered column, a per-row sleep does not mean "slow response". It means a query that never finishes, a held connection, and a gateway timeout. A 504 at your reverse proxy's limit is not a delay of the requested length. It is a timeout, and a scanner that reads it as "slow, therefore true" has measured the proxy. The last result shows the opposite failure. Depending on how the condition is written, a constant false branch may never execute, so a test that relies on the database evaluating something it can fold away can fail to fire on a real injection.
UNION-based injection appends a second SELECT to the original query, and the application renders its rows as if they were its own. sqlmap says this works "when the web application page passes directly the output of the SELECT statement within a for loop, or similar". It also handles the partial case, where only the first row is displayed.
Two things have to match before a UNION works, and the attempts to match them are themselves evidence.
id=10 ORDER BY 10--. Another approach is to add NULLs to UNION SELECT NULL,NULL,... one at a time until the error stops. PostgreSQL's wording for a mismatch is each UNION query must have the same number of columns. sqlmap tries 1 to 10 columns by default and up to 50 at higher --level values.SELECT 1 UNION SELECT 'x' failed with invalid input syntax for type integer: "x". Once the count is right, a tester swaps one NULL at a time for a string to find a column that is both text-typed and rendered on the page.Some dialects add their own requirements. sqlmap's documentation gives Microsoft Access as an example of a database that needs a valid table in the FROM clause, which is what its --union-from option supplies. Oracle required a FROM clause on every SELECT before Oracle Database 23ai, and on older versions the one-row DUAL table is the usual way to satisfy it.
The evidence is the clearest any technique gives. A random marker string the scanner chose appears on the page, in a place where database rows are normally rendered. Nothing but the database could have put it there. This is also where confirmation shades into extraction. The same query that returns 'qzx7k' can return a column from another table. For confirmation, select constants and the version function, not table contents.
A stacked query ends the application's statement with a semicolon and adds a new one. The WSTG example is id=10; INSERT INTO users (…). sqlmap describes the technique as useful for running "SQL statements other than SELECT, like for instance, data definition or data manipulation statements". Depending on the DBMS and the session user's privileges, that can lead to reading and writing files on the database host and running operating system commands.
Whether stacking works at all depends on the client library, not the database alone. sqlmap's Usage page notes that "PHP does not support stacked queries when the back-end DBMS is MySQL, but it does support when the back-end DBMS is PostgreSQL." So a parameter can be injectable through B, E, U or T and not through S. On SQL Server, as noted above, the time-based test is often a stacked one, because WAITFOR is a statement.
For triage, stacked support changes impact, not existence. An injection that can only read through a SELECT is serious. One that can run INSERT, UPDATE, DROP or a database-specific command execution feature is a different order of severity, and the database account's privileges decide which of those actually work. A stacked-query confirmation should use a harmless second statement, such as a short sleep or a SELECT of a constant. It must never write.
sqlmap also lists inline queries (Q), which embed a subquery where the original query evaluates a nested SELECT, with the output returned in the page. The evidence is the same kind as for UNION: a value the scanner computed appears where data is rendered.
Some databases can be made to resolve a hostname or make an HTTP request. The WSTG's Oracle example concatenates the result of a subquery into a UTL_HTTP request to the tester's server, so the server's access log shows the database user as the request path. Where only DNS gets out, the data goes into a subdomain of a domain whose authoritative name server the tester controls. The lookup carries the data, and the resolver delivers it.
sqlmap's --dns-domain option implements DNS exfiltration, with conditions stated in its documentation. Among them, the tester must control a machine registered as the DNS server for the domain, sqlmap must run with the privileges needed to bind port 53, and "one normal (blind) technique" must already work, because the option's purpose is to "speed up the process of data retrieval". It does not discover the injection. It makes a blind injection you already have faster to exploit.
For detection, an out-of-band payload is strong evidence of the same kind as error-based: a unique hostname that existed only in one payload was looked up, so something executed that payload. The out-of-band post covers how callbacks are matched to requests, and why a DNS-only interaction is weaker evidence than an HTTP one. The query reaching your name server usually comes from a resolver, not from the database host. Two things specific to SQL:
The XSS post argued that output context decides which characters matter. SQL has the same idea. Where the input lands in the query decides which payloads work and, more importantly, which fix applies.
WHERE name = '{input}'. The quote is what matters. Payloads have to close it and either balance the trailing quote or comment it out, which is why x' AND '1'='1 has an unclosed quote at the end.WHERE id = {input}. No quote is needed, so a filter that strips or escapes quotes does nothing. 1 AND 1=2 is a complete payload. This is also the context where manual escaping fails most completely: there is nothing to escape.SELECT {column} FROM ... or FROM {table}. The input is a name, not a value.ORDER BY {column} {direction}, and similar.The fix for the first two is a bound parameter. For identifiers and keywords, parameterisation cannot help, and the OWASP cheat sheet says so directly: "Parameter markers represent data values, not table names, column names, or SQL keywords such as ASC and DESC." A placeholder in an ORDER BY clause binds a value, and ordering by a constant value sorts nothing. That is why sort parameters are where string-built SQL survives in otherwise parameterised code, including ORM code, where a sort field from the query string goes into a raw ordering expression.
The fix there is an allowlist mapping. The cheat sheet says to "map a requested sort direction to the literal ASC or DESC, rather than appending the supplied text", and the same goes for column names: look the request value up in a fixed table of allowed columns, use the application's own string for the one it maps to, and reject anything else. Quoting an identifier (PostgreSQL's format with %I, for example) protects the syntax, but as the cheat sheet notes, it "does not restrict which tables the application may select."
LIMIT and OFFSET are numbers, and most drivers can bind them. Where a layer will not, converting to an integer in application code before building the clause is enough, because an integer cannot carry SQL.
ORDER BY injection is what the third finding in the opening scenario looked like: a boolean result where only the row order differed. The test is a conditional sort. A sort value of (CASE WHEN 1=1 THEN name ELSE email END) sorts by name, and the same expression with 1=2 sorts by email. If the row order follows the truth of the condition, the expression was evaluated. In our container, against a three-row table, the 1=1 form returned alice, bob, carol and the 1=2 form returned bob, carol, alice. PostgreSQL requires both branches to have compatible types: a CASE choosing between a text column and a numeric one failed with CASE types numeric and text cannot be matched, which is an error-based signal in its own right. An error-based or time-based payload can also go inside the CASE. A scanner that compares only whether the response bodies "differ significantly" can rate this as weak, because the bytes are almost the same and only the order changed. For this context, the order is the signal.
The OWASP cheat sheet says its controls apply "regardless of whether data comes from an HTTP request, another service, or previously stored data". That last case is second-order injection. A value is stored safely, through a parameterised INSERT, and later read back and concatenated into a different query: a report, an export, an admin search, a scheduled job.
A dynamic scan that diffs each request against its own response cannot see this. The request that carries the payload succeeds normally, because it was stored correctly. The query that breaks runs on another request, or in a job with no HTTP response at all, perhaps hours later. sqlmap supports this case only when told where to look: its second-order options take the URL or request where results appear, and the Usage page describes the case as "where result(s) of an injected payload in one vulnerable page is shown (reflected) at the other". The WSTG's "hidden union" scenarios include a related shape, where the result of one query feeds a second, vulnerable one.
In practice, second-order injection is found by tracing data in source code, by an out-of-band payload that calls home whenever it runs, or by a tester who knows which page reads the stored field. Static taint analysis can follow a stored value only if it treats data read back from the database as a source, which not every rule set does by default. That is worth checking in whatever SAST you run.
Every technique above is dialect-specific, so a scanner identifies the DBMS early. It uses the evidence it already has.
SELECT 'book'+'case'; returns bookcase), and SQL Server 2025 and Azure SQL Database also accept ||. MySQL concatenates adjacent literals ("Quoted strings placed next to each other are concatenated to a single string") and has CONCAT(). In MySQL, || is a synonym for OR unless the PIPES_AS_CONCAT SQL mode is enabled. Our PostgreSQL container rejected two adjacent literals with a syntax error. A payload that returns the original record only when its value is split with one particular concatenation form narrows down the dialect without any error output.sqlmap fingerprints automatically after detection, and its --fingerprint switch performs "a lot more requests" to pin down the version. For triage, the fingerprint is a check on the finding. If the report says MySQL and the service runs on PostgreSQL, look again at what the scanner actually observed.
Several of these techniques change the system they are testing, and that matters most against production.
General practice that follows from this:
Parameterised queries. The OWASP cheat sheet's primary defence is to "Define the SQL structure separately from its data values, then bind those values through the database API." Bound values are treated "as data rather than SQL syntax". It adds a caveat that catches real code: "Concatenating untrusted values into the query before preparing it defeats this protection." A prepared statement built from a formatted string is a string-built query with extra steps.
ORM escape hatches. An ORM parameterises the queries it builds. It does not parameterise SQL you write yourself.
Allowlists for structure. Identifiers, sort directions and other structural choices come from a fixed mapping in code, as above.
Not escaping. The cheat sheet calls manual escaping "STRONGLY DISCOURAGED", because it "depends on the SQL dialect and configuration". Its example is MySQL's NO_BACKSLASH_ESCAPES mode, which turns off backslash escaping. In a numeric context, escaping has nothing to work on.
Least privilege limits damage. It is not a fix. The cheat sheet says it "limits the damage an attacker can cause through a database account; it does not prevent SQL injection". A read-only account still lets a boolean oracle read every table it can see. Still do it: no DBA rights for application accounts, a separate account per application, views to narrow what an account can read, and a database service that does not run as root or SYSTEM.
A WAF is a mitigation, not a fix. It matches payload shapes, not the bug. The WSTG has a whole section on signature evasion: whitespace and newline variants, inline comments, URL and hex encoding, CHAR() spelling, string concatenation and alternative ways of writing a true condition. Each of those is a payload shape a rule has to anticipate. A WAF also changes what your own scanner sees. A blocked probe returns a block page, the parameter looks clean, and the finding disappears from the report while the code is unchanged. The DAST coverage post covers how block pages pass for clean results. Use the WAF to buy time, and fix the query.
Parameterisation is not authorisation. The cheat sheet also notes that binding "does not decide whether the caller may access the matching account". A perfectly parameterised WHERE id = ? that takes any id the caller sends is a different bug, covered in the IDOR post.
A confirmed DAST SQL injection finding tells you that a reachable request changed a query's structure. It cannot tell you which line built the query. A SAST finding tells you that request data reaches a SQL sink in the code. It cannot tell you whether any deployed route sends it there, and it can be wrong in the ways the SAST false positive post describes, such as a translation function matched as a SQL sink because its name contains the right letters. Each is the other's best triage tool.
x' breaks, x'' does not) for strings, arithmetic (2-1 behaves like 1) for numbers, varying one parameter at a time.Back to the three hypothetical findings. The /orders?id= error turns out to be invalid input syntax for type integer, returned from a parameterised query through a debug error handler. It is reclassified as information disclosure, and the error handler is fixed. The /search?q= delay does not reproduce. Graded delays of 0, 2 and 4 seconds all come back in about the same time, and the control requests were slow at the same moment because a reindex job was running. It is closed, with the timings attached. The /reports?sort= finding is real. The sort field from the query string was passed into a raw ordering expression, and a CASE condition flips the row order. The fix is a mapping from the allowed sort keys to column names, with everything else rejected. The ORM was parameterising every value in that query. The sort field was not a value.
AppSecD's dynamic and API testing runs Nuclei, Dalfox, SQLMap, Commix, Katana, Arjun, FFuf and Interactsh for out-of-band detection, plus more than 150 native checks and API discovery across more than 50 web frameworks. Its static analysis includes dataflow taint analysis that traces source to sink across functions.
That is the whole claim. This post does not claim how AppSecD decides that a parameter is injectable, which sqlmap techniques, risk or level settings it uses, how it handles timing noise or dynamic content, what request rate or delay limits it applies, whether it attempts data extraction, whether its static rules treat stored data as a taint source for second-order injection, or how accurate its SQL injection detection is. The techniques, the safety practices and the checklist above are general practice. They apply to any scanner's output, ours included.