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.
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.
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.
# 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.
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.
| Approach | What it observes at the sink | Result |
|---|---|---|
| SAST taint tracking | Value at sink comes from a DB read, not directly from a request param | Taint cleared at DB boundary; sink appears safe |
| First-order DAST | Phase 1 write: 200, no error. Phase 2 read not linked to Phase 1. | No immediate behavioral change; probe not connected |
| Second-order DAST | Phase 1 write + Phase 2 trigger combined; boolean differential across stored values | Injection confirmed at the deferred sink |
| Manual code review | Reads the read-then-concatenate path if reviewer traces all callers of the stored field | Inconsistent; 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.
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.
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.