For second-order SQL injection, confirming the bug and exploiting it are the same action. There is no probe that tells you the vulnerability is real without also running the attacker's query against your data. That is not a gap in anyone's scanner. It is a property of the bug class, and it means an automated scanner claiming to detect second-order injection is doing one of two things: not really testing for it, or writing persistent payloads into your database and hoping.
This post is for people evaluating DAST tools and for the engineers who have to answer "why did we not catch this". By the end you will know exactly where the confirmation step fails, what a scanner can honestly report instead, and what actually finds these.
The shape of the bug
First-order injection is one request. Payload goes in, the query runs, the response comes back changed. Request and response are correlated, so a scanner can send a probe and read the answer.
Second-order injection splits that in two. The payload is stored through a path that is perfectly safe, usually a parameterised insert. It sits in the database as inert text. Later, a different code path reads it back out and builds a query by concatenation.
# Write path. Parameterised. Correct. Stores whatever you send.
db.execute(
"INSERT INTO users (username, email) VALUES (%s, %s)",
(username, email),
)
# Read path, in a different module, possibly a nightly job.
# The value came from the database, so it "is not user input".
row = db.execute(f"SELECT * FROM audit WHERE actor = '{user.username}'")
The mental model that produces this is "data from the database is trusted". It is the most common taint-tracking mistake there is, because the persistence layer looks like a boundary and is not one. Storage is a delay, not a sanitiser.
Why the scanner cannot confirm it
A dynamic scanner confirms injection by observing a difference: an error, a timing change, a boolean flip, an out-of-band callback. Every one of those signals is produced by the database executing the payload.
In a second-order bug, the database executes the payload at the sink, not at the source. So to get any signal at all, the scanner must:
- Store the payload. It is now persistent, in your data, surviving the scan.
- Cause the sink to run. That may be another user's page load, an admin report, or a scheduled job.
- Observe the effect of a query it did not write, against rows it does not own.
Step one is the problem. The payload does not go away when the scan ends. A stored ' OR 1=1 -- is harmless enough; a stored payload shaped to produce a signal is a query you have durably planted in your own system, waiting for whoever next triggers that code path. If the sink is a report an administrator opens, you have armed a trap for the administrator.
Step three is worse. The scanner cannot scope the effect to itself. A time-based probe at the sink blocks a shared connection. A boolean probe changes what somebody else's page renders. A destructive payload, which is what you need for an unambiguous confirmation on some sinks, does exactly what it says.
There is no version of this where the tool learns the bug is real and nothing happened.
The correlation problem, separately
Even setting damage aside, the mechanics do not work. A scanner correlates a request with its response. Here the response lives somewhere else:
- A different endpoint.
- A different user's session.
- A batch job that runs at 02:00.
- An export that nobody opens for a week.
So a scanner would have to store a payload, then crawl the entire application looking for the delayed effect, then attribute it back to one of the hundreds of fields it filled. In practice this is why "second-order support" in a product usually means the crawler fills fields and revisits pages, which finds stored XSS reliably and stored SQL injection close to never.
What a scanner can honestly do
There is a useful thing here, and it is not a finding. It is a lead.
Seed canaries, not payloads. Write a unique, syntactically inert token into every writable field: sgcanary7f3a91. It is not a payload. It cannot execute. Then crawl and record every place that token surfaces. You now have a map of which inputs reach which outputs, across the persistence boundary. That map is the hard part of the analysis and it costs nothing.
Report the reachability, and say so. "Input from field X is read back at sink Y" is honest, actionable, and not a vulnerability claim. It is the input to a code review, not a substitute for one.
Use out-of-band detection where the sink allows it. An OAST callback payload confirms execution without a destructive effect, which narrows the damage from "unknown" to "one DNS lookup". It still requires the payload to be stored and executed, so it does not escape the core problem, but it is the least bad confirmation that exists.
Anything beyond that is the tool choosing to exploit your application on your behalf. That can be the right choice. It should be a choice you made, not a checkbox.
What actually finds these
Static analysis, because the problem is a static one. The whole bug is visible in the source: a query built by concatenation from a value read out of the database. You do not need to run anything.
- Taint analysis with a database-aware source model. Treat every read from persistent storage as a taint source, not a sanitiser. Most SAST tools will do this if you configure it; most default configurations do not, because it is noisy. The noise is the point.
- Grep, seriously, as a first pass. Find every string-interpolated query on a read path. In most codebases that is a short list, and the second-order bugs are all in it.
- Review at the sink, not the source. The write path will look fine, because it is fine. Audit the queries, not the forms.
- Integration tests with canary fixtures. Seed the canary token in your test data and assert no query ever contains it unparameterised. This catches regressions cheaply and runs in CI.
The concession
There is a setting where dynamic second-order testing is entirely reasonable: a disposable environment, seeded with synthetic data, restored from a known-good snapshot afterwards, with no shared tenancy and no real users.
Do it there and none of the objections above apply. But notice what changed. The scanner did not get safer. The environment did. That distinction is the one to hold on to when a vendor says they support second-order detection, because the correct follow-up question is not "how" but "where do you expect me to run this".
If the answer is a throwaway environment with restore, believe them and use it. If the answer is your production application, they have told you something about their product that they did not mean to.
The implication
The bug classes that automation handles well are the ones where observation is cheap and reversible. Second-order injection is neither, and no amount of tooling changes that, because the constraint is not engineering effort, it is that reading the answer requires running the query.
So the defence moves upstream. Stop treating the database as a trust boundary, parameterise on the read path as strictly as the write path, and put the check where it is free: in the code, before it ships.