SQL InjectionSecond-Order

Second-Order SQL Injection: When Stored Input Detonates Later

Some payloads do nothing at the point of entry. They are stored safely, then read back into a second query that builds SQL by concatenation — and that is where they fire.

SelfSec Team4 min read
Part 5 of 8from theSQL Injectionseries
quarterly' OR '1'='1' -- -

At a glance

CWE-89
Interpreter
SQL database engine
Required condition
Stored attacker input is later concatenated into a new SQL statement.
Potential impact
Delayed query manipulation that bypasses checks focused only on request entry.
Primary defense
Parameterize every query at execution time, including values read from storage.

Safe practice: use benign proof only, in a disposable local lab or on a system you are explicitly authorized to test.

On this page

Most SQL injection is first-order: the payload arrives in a request and breaks the very query that handles that request. Second-order injection separates the two events. The malicious input is accepted, escaped correctly, and written to the database as inert data — the entry point is genuinely safe. The damage happens later, when some other feature reads that stored value back and concatenates it into a new query without re-escaping it.

This makes second-order injection easy to miss. The endpoint that accepts the payload passes every test you throw at it, and the endpoint that detonates it never touched user input directly. This post covers how the two halves connect, why "sanitize once at the boundary" fails, and the only fix that actually holds.

How it happens

The flaw is a split responsibility. A saved-report feature stores a label through a parameterized insert — textbook-correct — so the input is preserved verbatim, quotes and all:

$label = $_POST['label'];
$stmt = $db->prepare("INSERT INTO saved_reports (user_id, label) VALUES (?, ?)");
$stmt->execute([$userId, $label]);

Nothing is exploitable in that INSERT; the prepared statement keeps the value as data. The problem is a later report runner that trusts the stored label because it came from the database and builds a new query by concatenation:

$saved = load_saved_report($_GET['report_id']);
$rows = $db->query(
    "SELECT title, total FROM reports
     WHERE owner_id = " . $saved['user_id'] . "
     AND label = '" . $saved['label'] . "'");

The developer reasoned that label was already in the database, so it must be safe. But the value being concatenated is whatever the user stored earlier. Parameterization protected the first query only; it did not permanently transform the value.

A safe lab proof: a stored label widens a later query

A tester saves a report label designed to alter the later SELECT, using only seeded lab records:

quarterly' OR '1'='1' -- -

At creation this is stored faithfully as a literal label. It does nothing. Later the report runner assembles:

SELECT title, total FROM reports
WHERE owner_id = 42
AND label = 'quarterly' OR '1'='1' -- -'

The injected quote closes the string and the true condition widens the result. In a disposable lab containing obvious marker rows for two owners, seeing the other marker proves that stored data changed the later query. Production testing should stop before reading real cross-tenant records.

The dangerous query never read the current request body. It read its own database and treated storage as a trust boundary.

Why sanitize-at-entry fails

Second-order injection breaks the most common mental model of input handling: clean it once at the boundary and it stays clean. That model assumes data has two states, untrusted on the way in and trusted forever after. SQL safety does not work that way, because escaping is contextual — it neutralizes input for one specific query, not permanently.

  • Escaping is per-destination. Quotes escaped for an INSERT are stored as the original characters; the next query that interpolates them sees raw quotes again.
  • "From the database" is not "safe". The database faithfully returns exactly what was stored, including attacker-chosen syntax.
  • Double-escaping is its own bug. Trying to fix this by escaping on input and output corrupts legitimate data (every O'Brien becomes O\'Brien).

The lesson: trust cannot be a property a value carries around. Safety has to be applied at each query, every time.

The fix: parameterize every query, including reads of stored data

There is no shortcut and no "trusted source" exemption. Every query that incorporates a value — even one read straight from your own tables — must bind it as a parameter:

using var cmd = new SqlCommand(
    "SELECT title, total FROM reports WHERE owner_id = @owner AND label = @label",
    connection);
cmd.Parameters.Add("@owner", SqlDbType.Int).Value = saved.UserId;
cmd.Parameters.Add("@label", SqlDbType.NVarChar, 80).Value = saved.Label;

Bound this way, the stored string is matched as a literal report label — it never becomes syntax. Make parameterization the rule for all dynamic SQL regardless of where the data came from, prefer an ORM that does this by default, and enforce tenant or owner restrictions independently so one malformed filter cannot cross the authorization boundary.

How to find the two halves

  • Mark fields that users, imports or external systems can write, even when their write queries are parameterized.
  • Trace those values into scheduled jobs, admin screens, exports, notification templates and reporting code.
  • Review SQL built from database columns with the same suspicion as SQL built from request fields.
  • Exercise the full store-then-use workflow in a disposable dataset; testing only the entry endpoint cannot reveal the sink.
  • Keep an explicit correlation between the stored marker and the later response so ordinary data changes are not mistaken for injection.

How SelfSec finds it

SelfSec's SQL injection engine covers 19 database families and carries tracked markers through crawled workflows so later responses can be correlated with earlier stored inputs. When a reachable second-order sink exposes error, boolean, timing, UNION, stacked or out-of-band evidence, the finding includes the requests needed to reproduce that sequence. Core scan processing runs locally on 127.0.0.1.

References

SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.

Do both things about SQL Injection

SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.

The SQL Injection series

  1. 01OverviewSQL Injection, Explained: From Login Bypass to Full Data Extraction6 min
  2. 02Boolean-Based BlindBoolean-Based Blind SQL Injection: Extracting Data One Bit at a Time4 min
  3. 03Error-BasedError-Based SQL Injection: Turning Database Errors Into Data4 min
  4. 04Out-of-BandOut-of-Band SQL Injection: Confirming a Blind Flaw Through DNS5 min
  5. 05Second-OrderSecond-Order SQL Injection: When Stored Input Detonates LaterReading
  6. 06Stacked QueriesStacked Queries: Running Multiple Statements Through One Injection4 min
  7. 07Time-Based BlindTime-Based Blind SQL Injection: Reading a Database Through the Clock5 min
  8. 08UNION-BasedUNION-Based SQL Injection: Appending Your Own Result Set4 min

Related reading