Engineering · Aug 29, 2026 · 7 min read

Second-order SQL injection: confirming a deferred execution sink

First-order injection fires the moment input reaches a query. Second-order stores clean input now and injects it into an unsanitized query later - confirming it requires a two-phase probe that spans both requests.

Second-order SQL injection stores user input in a way that looks safe - escape function applied, no immediate error - then retrieves that stored value and passes it unparameterized into a later query. The first request never fires a payload; the second request does, against data the application itself wrote. A scanner that probes every input and checks the immediate response never sees the injection fire.

01The deferred injection window

First-order injection fires in the same request-response cycle: send the payload, read the response, observe the result. Second-order breaks that model. The storage write applies whatever escaping the application uses - sometimes SQL escaping, sometimes none at all on a value deemed already safe. When a second code path reads that stored value and concatenates it into a new query without parameterization, the payload executes in a context the first escaping step never reached.

Phase 1 - write: POST /profile {display_name: probe' AND 62615533=62615533 AND 'x'='x} ──► escape and store 200 OK - no immediate error Phase 2 - read: SELECT * FROM users WHERE display_name = 'stored value concatenated here' (no re-parameterization at the read sink) True predicate: 62615533=62615533 ──► WHERE evaluates true count=1 returned False predicate: 62615533=99999999 ──► WHERE evaluates false count=0 returned differential across the two = injection confirmed
The payload survives sanitization intact and is re-injected into a later query that reads it without parameterization. The first request shows no behavioral change; only the second reveals the broken boundary.

02The two-phase confirming probe

Confirming second-order injection requires two separate requests linked by a measurable behavioral difference. The probe avoids any payload that modifies rows or leaks real data; instead it uses a boolean arithmetic oracle. Phase one stores a crafted value in a profile or settings field. Phase two triggers the code path that reads that value into a query - a search endpoint, a report generator, or an account-modification flow. The boolean flip between two distinct stored values is the confirming artifact.

second-order-probe.httphttp
# Phase 1a: store a true-predicate payload in a profile field
POST /api/account/profile
Content-Type: application/json

{"display_name": "probe' AND 62615533=62615533 AND 'x'='x"}
HTTP/1.1 200 OK   # stored; escaping applied at write time

# Phase 2a: trigger the code path that reads the stored value into a query
GET /api/reports/by-name
HTTP/1.1 200 OK  {"count": 1}  # true predicate - row returned

# Phase 1b: replace with a false predicate
POST /api/account/profile
{"display_name": "probe' AND 62615533=99999999 AND 'x'='x"}

# Phase 2b: same read-back trigger
GET /api/reports/by-name
HTTP/1.1 200 OK  {"count": 0}  # false predicate - zero rows: injection confirmed

The arithmetic nonce 62615533=62615533 (7919 x 7907) is always true and produces no side effects. Its false counterpart 62615533=99999999 always evaluates to false and collapses the result to zero rows. The differential - count=1 versus count=0 across those two phase-2 responses - is the confirming artifact. No real data is read or modified beyond the row the probe itself wrote.

03Why SAST and first-order DAST both miss this class

Static taint analysis finds sinks where user input concatenates into a query. In second-order injection the sink receives a value that came from the database, not directly from a request parameter. The taint chain runs: HTTP input - sanitize - write to DB - read from DB - concatenate into query. Most taint trackers do not propagate taint across the database-read boundary, so the sink appears to receive a trusted internal value and no finding is raised.

ApproachWhat it observes at the sinkResult
SAST taint trackingValue at sink comes from a DB read, not directly from a request paramTaint cleared at DB boundary; sink appears safe
First-order DASTPhase 1 write: 200, no error. Phase 2 read not linked to Phase 1.No immediate behavioral change; probe not connected
Second-order DASTPhase 1 write + Phase 2 trigger combined; boolean differential across stored valuesInjection confirmed at the deferred sink
Manual code reviewReads the read-then-concatenate path if reviewer traces all callers of the stored fieldInconsistent; silent when callers span services or files

Common stored-field attack surfaces include: display names and usernames used in admin or reporting queries; saved search terms fed back into a dynamic query builder; template or role names referenced in a permission-check query; and any profile field that can be written by a lower-privilege user and later read by a higher-privilege query context. Each of those surfaces is a candidate for the two-phase probe.

Proven - HighSecond-order SQL injection - display_name - /api/reports/by-name
Phase 1: POST /api/account/profile {"display_name": "probe' AND 62615533=62615533 AND 'x'='x"} ──► 200 OK (stored; no error) Phase 2: GET /api/reports/by-name ──► {"count": 1} (true predicate: row returned) Phase 1b: display_name = probe' AND 62615533=99999999 AND 'x'='x Phase 2b: GET /api/reports/by-name ──► {"count": 0} (false predicate: injection confirmed)
Oracle: boolean differential (true vs false arithmetic predicate) across two stored values. No rows read or modified beyond the probe row. Rated high - injection confirmed; /api/reports/by-name is admin-scoped here. Escalates to critical if the deferred sink is reachable from a lower-privilege context.

04Severity and remediation

Severity follows the privilege context of the deferred sink, not the storage endpoint. A display name stored by any authenticated user that feeds into an admin-only query is rated high - confirmed SQL injection with limited direct escalation. If the deferred sink returns or modifies rows any authenticated user can trigger, the finding escalates to critical. The rating is determined by what the confirmed injection can reach, not by the privilege of the path used to store the payload.

4
requests per probe (write + trigger x 2)
0
rows modified outside the probe row
0
external calls or DNS lookups during probe

The fix is parameterized queries at the read-and-concatenate sink, not at the write step. Sanitizing input on storage does not protect the code path that later reads the stored value and uses it in a query without parameterization. ORM raw-query helpers, dynamic column filter builders and format-string query constructors each need the same parameterization discipline whether the value arrived from a live request or from a database row. The escaping that made the stored value look safe is exactly what leads a later developer to skip re-sanitizing - which is the root of the class.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →