Second-order SQL injection (also called stored SQL injection or persistent SQL injection) is one of the most dangerous and underdetected vulnerability classes in web applications. Unlike classic SQL injection where the payload is executed immediately, second-order SQLi involves a two-stage attack: the malicious payload is stored in the database first, and executed later when it is retrieved and used in a SQL query.
This time delay between injection and execution makes it invisible to most automated scanners and extremely difficult to detect through standard testing.
How Second-Order SQL Injection Works
In a standard SQL injection, the attacker submits a payload in a request parameter, and it is immediately processed by a SQL query:
-- Classic SQLi (immediate execution)
SELECT * FROM users WHERE username = 'admin' OR '1'='1'
In second-order SQL injection, the attack is split across two operations:
Stage 1 — Storage (injection point):
POST /register HTTP/1.1
username=admin'--&password=anything
The application stores admin'-- in the database, treating it as safe data because it was properly escaped during insertion.
Stage 2 — Execution (trigger point): Later, the stored username is retrieved and embedded into another SQL query — this time without sanitization:
-- The stored value is used in a new query context
UPDATE users SET password = 'newpass' WHERE username = 'admin'--'
The -- comments out the rest of the query. The attacker has effectively changed a different user’s password.
Why Standard Scanners Miss Second-Order SQLi
Most automated SAST and DAST tools look for immediate query execution responses — error messages, timing differences, or changes in output. Second-order SQLi produces none of these signals at the injection stage.
The payload travels through a “trusted” data path:
- Input is received and sanitized → stored safely
- Application trusts its own database data
- Data is retrieved and embedded into a new query — this step is never sanitized
- The vulnerability fires in a completely different request/code path
This cross-request execution chain breaks the input-to-query tracing that most tools rely on.
Real-World Exploitation Scenarios
Scenario 1: Username-Based Privilege Escalation
An attacker registers a username of admin'--. When an admin later resets this user’s password, the system runs:
UPDATE accounts SET password='$newpass' WHERE username='admin'--'
The -- terminates the WHERE clause, updating the actual admin account’s password instead.
Scenario 2: Profile Update Attack
A user stores a malicious value in their profile (e.g., city field: '; DROP TABLE orders;--). When the application later uses this value in a reporting query:
SELECT * FROM orders WHERE city = ''; DROP TABLE orders;--'
Scenario 3: Password Change Bypass
-- Application logic: "find user by their stored email and update password"
UPDATE users SET password='[new]' WHERE email='[email protected]' UNION SELECT...--'
Code Examples: Vulnerable vs Secure
Vulnerable PHP (Second-Order SQLi)
// Registration — data is escaped on input (appears safe)
$username = mysqli_real_escape_string($conn, $_POST['username']);
$sql = "INSERT INTO users (username, password) VALUES ('$username', '$hash')";
mysqli_query($conn, $sql);
// Password change — retrieves from DB and TRUSTS it without escaping
$result = mysqli_query($conn, "SELECT username FROM users WHERE id=$id");
$row = mysqli_fetch_assoc($result);
$trusted_username = $row['username']; // ← This is the stored malicious value
// VULNERABLE: uses "trusted" DB data in a new query without parameterization
$sql = "UPDATE users SET password='$newhash' WHERE username='$trusted_username'";
mysqli_query($conn, $sql);
Secure PHP (Parameterized Queries Everywhere)
// Registration — parameterized
$stmt = $conn->prepare("INSERT INTO users (username, password) VALUES (?, ?)");
$stmt->bind_param("ss", $username, $hash);
$stmt->execute();
// Password change — parameterized even when data comes from the DB
$stmt = $conn->prepare("SELECT username FROM users WHERE id = ?");
$stmt->bind_param("i", $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
// SECURE: parameterized query, DB data treated as untrusted
$stmt = $conn->prepare("UPDATE users SET password = ? WHERE username = ?");
$stmt->bind_param("ss", $newhash, $row['username']);
$stmt->execute();
Vulnerable Python (SQLAlchemy raw query)
# Retrieves username from DB and uses it in new raw query
username = db.execute("SELECT username FROM users WHERE id = %s", (user_id,)).scalar()
# VULNERABLE: DB data embedded directly into new SQL
db.execute(f"UPDATE logs SET last_action = 'login' WHERE username = '{username}'")
Secure Python
username = db.execute("SELECT username FROM users WHERE id = %s", (user_id,)).scalar()
# SECURE: always parameterize, even with DB-sourced data
db.execute("UPDATE logs SET last_action = 'login' WHERE username = %s", (username,))
Detection and Prevention
1. Parameterized Queries (Prepared Statements) — Everywhere
The only reliable fix. Use parameterized queries for every SQL statement, including those where input comes from your own database:
// Java — JDBC
PreparedStatement stmt = conn.prepareStatement(
"UPDATE users SET password = ? WHERE username = ?"
);
stmt.setString(1, newPassword);
stmt.setString(2, storedUsername); // Even DB data gets parameterized
stmt.executeUpdate();
2. ORM Usage
Modern ORMs (Hibernate, Django ORM, ActiveRecord, Prisma) parameterize by default. Avoid raw SQL queries unless absolutely necessary.
3. Defense-in-Depth: Input Validation at Storage
Validate input at the point of storage, not just at the point of use:
- Reject usernames containing SQL metacharacters (
',",;,--,/*) - Apply strict allowlists on fields that will be reused in queries
- Use application-level encoding consistently
4. Code Review Focus Areas
When reviewing code for second-order SQLi, look for:
- Data retrieved from the database being used in subsequent SQL queries
- Functions that trust “internal” data sources without parameterization
- Multi-step operations: registration → login, user creation → profile retrieval, etc.
5. Database User Permissions (Least Privilege)
Limit the database user’s permissions so that even if exploitation occurs, the blast radius is contained:
-- Application DB user should NOT have DROP, ALTER, or GRANT
GRANT SELECT, INSERT, UPDATE ON app_db.* TO 'appuser'@'localhost';
OWASP Classification
Second-order SQL injection falls under OWASP A03:2021 — Injection and maps to:
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command
- CWE-20: Improper Input Validation
The OWASP Testing Guide covers second-order injection specifically in OTG-INPVAL-005.
How Static Analysis (SAST) Detects Second-Order SQLi
Detecting second-order SQL injection requires cross-function taint tracking — the ability to trace a data value from where it enters the application, through storage, retrieval, and into a new query execution context.
This is significantly harder than detecting standard SQLi because the taint analysis must:
- Track that data entered from user input
- Follow it through a write operation to the database
- Track the read operation that retrieves it later
- Identify that the retrieved value flows into a new SQL query without sanitization
Offensive360’s SAST engine performs deep inter-procedural taint analysis across all supported languages, tracking data across:
- Database read/write operations
- Session and cache storage
- File system operations
This makes it one of the few SAST tools that can reliably detect second-order SQL injection without requiring runtime execution.
Summary
| Classic SQL Injection | Second-Order SQL Injection | |
|---|---|---|
| Execution timing | Immediate | Delayed (stored, then triggered) |
| Detection difficulty | Medium | High |
| Scanner visibility | Usually detectable | Often missed |
| Root cause | Unsanitized input in SQL query | Trusted DB data in SQL query |
| Fix | Parameterized queries | Parameterized queries everywhere |
The key takeaway: never trust data just because it came from your own database. The database is a data sink and source, not a sanitization layer. Every value that enters a SQL query must be parameterized, regardless of origin.
Scan your codebase for second-order SQL injection and other injection vulnerabilities with Offensive360 SAST. Full taint analysis across 60+ languages.