Skip to main content

Free 30-min security demo  — We'll scan your real code and show live findings, no commitment Book Now

Offensive360
Vulnerability Research

2nd Order SQL Injection: Examples, Detection & Prevention

2nd order SQL injection (stored SQLi): how the deferred payload fires in a later query, why most scanners miss it, code examples in Java, Python & PHP, and SAST detection.

Offensive360 Security Research Team — min read
2nd order sql injection second order sql injection stored sql injection SQL injection SAST CWE-89 OWASP web security database security second-order SQLi 2nd order SQLi

2nd order SQL injection (also written as second-order SQL injection, stored SQL injection, or persistent SQL injection) is a vulnerability where the attacker’s payload is stored safely in the database, then fired in a completely different query later — bypassing the security checks that protect the initial write.

Understanding the deferred execution pattern is the key to detecting and preventing it. This guide walks through how 2nd order SQL injection works mechanically, why most scanners fail to catch it, and exactly how to find and fix it in your codebase.


How 2nd Order SQL Injection Differs from Classic SQLi

Classic SQL injection fires immediately: the attacker submits a payload in a request, the server embeds it in a SQL query on the same request, and the database executes the malicious SQL right then.

2nd order SQL injection is deferred:

StepClassic SQLi2nd Order SQLi
1Attacker submits payloadAttacker submits payload
2App builds SQL with payloadApp stores payload safely in DB
3DB executes malicious SQL— (nothing yet)
4Later request triggers a new query
5App retrieves stored value, builds new SQL
6DB executes malicious SQL

The gap between steps 2 and 5 can be seconds, hours, or weeks. The payload sits dormant in the database until the application performs an operation that retrieves it and uses it unsafely in a new query.


The Deferred Payload Pattern

Here is the minimal example of the 2nd order SQLi pattern:

Request A: POST /register
  username = "admin'--"
  → App escapes it → stored as: admin'-- in users.username

--- some time passes ---

Request B: POST /change-password
  → App retrieves username from DB: "admin'--"
  → App builds: UPDATE users SET password='x' WHERE username='admin'--'
  → The -- comments out the WHERE clause
  → ALL users' passwords are updated

Two separate requests. Two separate developers may have written the code. The person who wrote the password change function trusted that data retrieved from their own database was safe — a reasonable but incorrect assumption.


Why Most Scanners Miss 2nd Order SQLi

DAST scanners

Dynamic scanners send a payload and immediately inspect the response for SQL error messages, time delays (blind SQLi), or output differences. In 2nd order SQLi, the payload triggers on a different request than the one that stored it. The scanner sees no immediate SQL error on Request A, so it marks the input as safe.

Even if the scanner later sends Request B (triggering the vulnerability), it has no memory of the payload it injected in Request A, so it doesn’t correlate the two.

Simple SAST (pattern-matching) scanners

Basic static analysis tools look for patterns like "SELECT ... WHERE " + variable. They find this at the initial input point (if present) but do not trace data through the database. When the query uses a value retrieved from the database rather than directly from HTTP input, pattern-matching scanners mark it as “trusted database data” and skip it.

What’s needed: inter-procedural taint analysis

The only static analysis approach that reliably detects 2nd order SQLi is inter-procedural taint analysis with database modeling:

  1. Mark HTTP input as “tainted”
  2. When tainted data is written to the database, mark the DB as holding tainted values
  3. When data is read from the DB, mark the retrieved variable as tainted
  4. Trace the tainted variable through the second code path to its SQL query sink
  5. Report a finding if the tainted data reaches the sink without parameterization

This requires the SAST engine to model the database as a taint-propagating intermediary — a capability only present in enterprise-grade tools like Checkmarx, Fortify, and Offensive360.


Real Code Examples

Example 1: PHP — Username in Profile Update

<?php
// ─── Stage 1: Registration (appears safe) ────────────────────────────────
// Attacker registers username: admin'--
$username = mysqli_real_escape_string($conn, $_POST['username']);
$stmt = $conn->prepare("INSERT INTO users (username, password_hash) VALUES (?, ?)");
$stmt->bind_param("ss", $username, $password_hash);
$stmt->execute();
// Stored: admin'-- in the database. No injection here.

// ─── Stage 2: Password Change (vulnerable) ───────────────────────────────
// Different file, different developer, different HTTP request
$result = $conn->query("SELECT username FROM users WHERE id = " . (int)$_SESSION['user_id']);
$row = $result->fetch_assoc();
$username = $row['username'];  // ← Retrieved from DB: "admin'--"

// Developer trusts $username because "it came from our database"
$sql = "UPDATE users SET password_hash = '$new_hash' WHERE username = '$username'";
//      ↑ VULNERABLE: admin'-- terminates the WHERE clause
//      Executes: UPDATE users SET password_hash = '...' WHERE username = 'admin'--'
//      Updates ALL rows, not just this user
$conn->query($sql);

Fix: Parameterize the second query too.

// FIXED
$stmt = $conn->prepare("UPDATE users SET password_hash = ? WHERE username = ?");
$stmt->bind_param("ss", $new_hash, $username); // DB-sourced data still gets parameterized
$stmt->execute();

Example 2: Python — Email in Password Reset

import sqlite3

# ─── Stage 1: Registration ────────────────────────────────────────────────
# Attacker registers with email: x' OR '1'='1
cursor.execute(
    "INSERT INTO users (email, display_name) VALUES (?, ?)",
    (email, display_name)  # Parameterized — safe storage
)

# ─── Stage 2: Password Reset ──────────────────────────────────────────────
# Triggered by a password reset link click (different request entirely)
reset_token = request.args.get('token')
email = get_email_from_token(reset_token)  # Returns tainted email from DB

# Developer assumes email from DB is trusted
cursor.execute(
    "SELECT id FROM users WHERE email = '" + email + "'"
    #                                      ^^^^^^^^^^^
    #                                      VULNERABLE: x' OR '1'='1 → returns all users
)
user_row = cursor.fetchone()  # Returns first row (likely an admin account)
reset_password_for_user(user_row['id'], new_password)
# Attacker has now reset an arbitrary account's password

Fix:

# FIXED — always parameterize, regardless of data source
cursor.execute("SELECT id FROM users WHERE email = ?", (email,))

// Stage 1: User registration — display name stored safely
String displayName = request.getParameter("displayName");
PreparedStatement insert = conn.prepareStatement(
    "INSERT INTO users (display_name, email) VALUES (?, ?)"
);
insert.setString(1, displayName);
insert.setString(2, email);
insert.execute();

// Stage 2: Admin search feature (different servlet)
// Attacker's stored display name: ' UNION SELECT username, password_hash FROM admins--
ResultSet userResult = conn.createStatement().executeQuery(
    "SELECT id FROM users WHERE id = " + userId
);
String displayName = userResult.getString("display_name"); // Tainted from DB

// Vulnerable query construction trusting DB-sourced value
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
    "SELECT * FROM audit_log WHERE actor_name = '" + displayName + "'"
    //                                              ^^^^^^^^^^^^^^^^
    //  UNION SELECT query appended — attacker reads from admins table
);

Fix:

// FIXED — PreparedStatement for the second query
PreparedStatement auditQuery = conn.prepareStatement(
    "SELECT * FROM audit_log WHERE actor_name = ?"
);
auditQuery.setString(1, displayName); // displayName treated as untrusted even from DB
ResultSet rs = auditQuery.executeQuery();

Example 4: SQL Server — Stored Procedure Vulnerability

Dynamic SQL inside stored procedures is a particularly common 2nd order SQLi location, because developers assume stored procedures are inherently safe:

-- VULNERABLE stored procedure
CREATE PROCEDURE sp_GetUserAuditLog
    @UserId INT
AS
BEGIN
    DECLARE @Username NVARCHAR(100)

    -- Retrieve username from DB (could be attacker-controlled)
    SELECT @Username = username FROM Users WHERE id = @UserId

    -- VULNERABLE: dynamic SQL built with untrusted DB data
    DECLARE @Query NVARCHAR(500)
    SET @Query = N'SELECT * FROM AuditLog WHERE username = ''' + @Username + ''''
    EXEC sp_executesql @Query
END
-- If @Username = admin'-- (stored by attacker), returns all audit log rows

Fix:

-- FIXED — parameterize the dynamic SQL
CREATE PROCEDURE sp_GetUserAuditLog
    @UserId INT
AS
BEGIN
    DECLARE @Username NVARCHAR(100)
    SELECT @Username = username FROM Users WHERE id = @UserId

    -- Parameterized dynamic SQL
    DECLARE @Query NVARCHAR(500)
    SET @Query = N'SELECT * FROM AuditLog WHERE username = @uname'
    EXEC sp_executesql @Query, N'@uname NVARCHAR(100)', @uname = @Username
END

The Core Mental Model: The Database Is Not a Sanitizer

The root cause of every 2nd order SQLi vulnerability is a false assumption: data from our own database is safe to use in SQL queries.

This assumption is wrong for two reasons:

  1. Attackers control what goes into the database. Registration forms, profile update endpoints, comment sections, message fields — all of these write attacker-controlled data into your database. Whatever escaping happened at write time is irrelevant to what you do with that data at read time.

  2. Context changes between write and read. The escaping that made admin'-- safe for an INSERT query (a string literal) does not make it safe for a WHERE clause in a different query. SQL injection is context-dependent.

The fix is simple and absolute: parameterize every query that uses any database value, without exception. Data origin (HTTP request vs. database vs. session) is irrelevant — the only thing that matters is whether the value is inserted into the query as a parameter or as string concatenation.


How to Find 2nd Order SQLi in Your Codebase

Manual code review approach

Search for this pattern across your codebase:

  1. Find all places where data is read from the database: fetchone(), getString(), fetch_assoc(), executeQuery(), scalar(), .FirstOrDefault(), etc.
  2. For each retrieved value, trace where it’s used
  3. Flag any usage in string concatenation that feeds into a SQL query

Grep targets (adapt for your language):

# Python
grep -n "execute\s*(" **/*.py | grep -v "?" | grep -v "execute_values"

# Java — look for string concat near executeQuery
grep -n "executeQuery\|execute\|prepareStatement" **/*.java | grep '"'

# PHP
grep -n 'query\s*(\|execute\s*(' **/*.php | grep '\.'

SAST detection

Use a SAST tool capable of inter-procedural taint analysis. In Offensive360, 2nd order SQLi appears as a high-severity finding showing the full data flow:

[HIGH] Second-Order SQL Injection
  Source: UserRegistrationController.php:45 — $_POST['username']
  Storage: UserRegistrationController.php:52 — INSERT INTO users
  Retrieval: PasswordChangeController.php:23 — SELECT username FROM users
  Sink: PasswordChangeController.php:31 — $conn->query("UPDATE ... '$username'")
  CWE: CWE-89

The path spans two files and two controllers — exactly the kind of cross-context vulnerability that pattern-matching tools cannot detect.

Manual black-box testing

To test for 2nd order SQLi without source code access:

  1. Register an account with username: test'-- or test' OR '1'='1
  2. Log in with that account
  3. Trigger every operation that uses your account details: password change, profile update, search, activity logs, email notifications
  4. Watch for SQL errors, unexpected behavior, or data from other accounts appearing
  5. Also check any admin features that reference your account

Prevention Checklist

  • Parameterize every SQL query — no exceptions for database-sourced data
  • Use an ORM by default — Django ORM, Hibernate, ActiveRecord, Entity Framework all parameterize automatically; audit any .raw() or nativeQuery calls
  • Code review focus: any time data is retrieved from the database and used in a new query, a reviewer must verify parameterization at the second query
  • Run a SAST tool with taint analysis — tools must model database reads as taint sources; ask vendors specifically about their second-order SQLi detection before purchasing
  • Least-privilege database accounts — if exploitation occurs, the blast radius is limited if the DB user cannot DROP, ALTER, or access tables outside the application schema
  • Audit stored procedures for dynamic SQL construction using EXEC or sp_executesql with string concatenation

Mapping to Standards

StandardReference
CWECWE-89: Improper Neutralization of Special Elements used in an SQL Command
OWASP Top 10A03:2021 — Injection
OWASP Testing GuideWSTG-INPV-05: Testing for SQL Injection
CVSSTypically High (7.5–9.8 depending on impact)
PCI DSSRequirement 6.3.1 — Protect against known vulnerabilities

Summary

2nd order SQL injection exploits the false trust developers place in their own database data. The payload is stored safely, then triggered later in a completely separate query — making it invisible to most DAST scanners and simple SAST pattern matchers.

The prevention is the same as for first-order SQLi: parameterized queries everywhere — including queries where the values come from your own database, session, or cache. Never treat data as trusted simply because it originated from an internal source.

If you’re working through a Checkmarx SQL_Injection_Second_Order or Offensive360 “Second-Order SQL Injection” finding, the remediation path is always the same: find the second query (the sink in the tool’s data flow path) and convert it to use a prepared statement or parameterized ORM call.


Scan your codebase for second-order SQL injection and the full OWASP Top 10 with Offensive360 SAST. Deep inter-procedural taint analysis across 60+ languages — see results in under 10 minutes.

Offensive360 Security Research Team

Application Security Research

Find vulnerabilities before attackers do

Run Offensive360 SAST and DAST against your applications and get a full vulnerability report in minutes.