Skip to main content

Free 30-min security demo Book Now

Offensive360 Offensive360
Vulnerability Research

2nd Order SQL Injection: Definition, Examples & How to Fix It

2nd order SQL injection stores a payload in the DB and fires it in a later query — bypassing DAST scanners. Clear definition, real attack examples, and parameterized query fixes.

Offensive360 Security Research Team — min read
2nd order sql injection second order sql injection second-order SQLi stored sql injection SQL injection OWASP CWE-89 web application security SAST parameterized queries 2nd order sql injection example second order sql injection fix

2nd order SQL injection — also called second-order SQL injection or stored SQL injection — is a variant of SQL injection where the malicious payload is not executed immediately. Instead, it is stored in the application’s database during one request and triggered in a later, separate request when the stored data is reused inside a new SQL query.

This deferred execution makes 2nd order SQL injection significantly harder to detect than classic (first-order) SQL injection, and it routinely bypasses DAST scanners that only look for immediate execution responses.


How 2nd Order SQL Injection Differs from Classic SQL Injection

In classic (first-order) SQL injection, the payload is injected and executed in the same HTTP request:

GET /search?q=' OR '1'='1 HTTP/1.1
→ SELECT * FROM products WHERE name = '' OR '1'='1'
→ Returns all products immediately

The scanner sends a payload, observes an anomalous response, and flags it as SQL injection. Detection is straightforward.

In 2nd order SQL injection, the attack spans two separate requests:

Request 1 — Store the payload:

POST /register HTTP/1.1
username=admin'--&password=Test1234!
→ INSERT INTO users (username, password_hash) VALUES ('admin''--', '...')
→ No injection fires here — the value is safely escaped for the INSERT

Request 2 — Trigger the payload:

POST /account/change-password HTTP/1.1
new_password=attacker_controlled
→ Application retrieves stored username from DB: 'admin'--
→ Builds: UPDATE users SET password='...' WHERE username='admin'--'
→ The -- comments out the WHERE clause — ALL users' passwords updated

The scanner that fired the probe in Request 1 saw no anomalous response. The vulnerability doesn’t fire until Request 2 — in a completely different endpoint and code path.


Why DAST Scanners Miss 2nd Order SQL Injection

Dynamic Application Security Testing (DAST) tools work by sending payloads and observing responses within the same request cycle. If the response to the injection request looks normal, the scanner marks that input as safe.

With 2nd order SQL injection:

  1. Injection point (e.g., registration form): The payload is stored. The response is a normal 201 Created. The scanner sees no error, no time delay, no data leak. It moves on.
  2. Trigger point (e.g., password change, profile update, report generation): The stored payload fires. But the scanner is not correlating these two requests — it doesn’t know the trigger exists.

The only reliable automated detection method for 2nd order SQL injection is SAST (Static Application Security Testing) with interprocedural taint analysis — tracing the flow of user-supplied data through storage, retrieval, and re-use in a new query, all within the source code.


Real-World 2nd Order SQL Injection Examples

Example 1: Username in Password Change (PHP)

This is the most commonly cited 2nd order SQL injection pattern:

// ── Stage 1: Registration (safe storage) ──────────────────────────────────
// Attacker registers with username: admin'--

$username = mysqli_real_escape_string($conn, $_POST['username']);
// $username is now: admin\'--  (escaped for the INSERT)

mysqli_query($conn,
    "INSERT INTO users (username, password_hash) VALUES ('$username', '$hash')"
);
// Stored in DB as the literal string: admin'--

// ── Stage 2: Password change (unsafe retrieval) ───────────────────────────
// Later, the user logs in and requests a password change.

$result = mysqli_query($conn,
    "SELECT username FROM users WHERE id = " . intval($_SESSION['user_id'])
);
$row = mysqli_fetch_assoc($result);
$username = $row['username'];
// $username is now: admin'-- (retrieved RAW from DB, no escaping)

// VULNERABLE: the "trusted" DB value is placed into a new query without parameterization
$sql = "UPDATE users SET password_hash = '$new_hash' WHERE username = '$username'";
// Becomes: UPDATE users SET password_hash = '...' WHERE username = 'admin'--'
// The -- comments out the WHERE clause → ALL users get the new password
mysqli_query($conn, $sql);

Impact: The attacker can take over any account, including the administrator account, by changing its password.


Example 2: Email Address in Password Reset (Python)

# ── Stage 1: Registration ─────────────────────────────────────────────────
# Attacker registers: email = "[email protected]' OR '1'='1"

cursor.execute(
    "INSERT INTO users (email, name) VALUES (%s, %s)",
    (email, name)   # Parameterized — safely stored
)

# ── Stage 2: Password reset token validation ──────────────────────────────
# Reset link is clicked; application retrieves email from the reset token:

token_record = cursor.execute(
    "SELECT email FROM password_reset_tokens WHERE token = %s", (token,)
).fetchone()
email = token_record[0]   # Returns: [email protected]' OR '1'='1

# VULNERABLE: email from DB inserted into a new raw query
cursor.execute(
    "SELECT user_id FROM users WHERE email = '" + email + "'"
)
# Becomes: SELECT user_id FROM users WHERE email = '[email protected]' OR '1'='1'
# Returns all user IDs — attacker can reset any account

Example 3: City Field in Reporting Query (Java)

// ── Stage 1: Profile update ───────────────────────────────────────────────
// Attacker sets city = "'; DROP TABLE order_history;--"

PreparedStatement insert = conn.prepareStatement(
    "UPDATE user_profiles SET city = ? WHERE user_id = ?"
);
insert.setString(1, city);   // Parameterized — safely stored
insert.setInt(2, userId);
insert.execute();

// ── Stage 2: Monthly report generation (different service, different dev) ─
// An internal reporting job retrieves city and uses it in a dynamic report query:

Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
    "SELECT user_id FROM user_profiles WHERE city = '" + city + "'"
);
// Becomes: SELECT user_id FROM user_profiles WHERE city = ''; DROP TABLE order_history;--'
// Drops the order_history table on a database that allows multiple statements

This pattern is especially common in internal systems where a customer-facing API stores data with parameterized queries, but an internal reporting or analytics system later uses that “trusted” internal data with string concatenation.


Detection: How SAST Finds 2nd Order SQL Injection

Detecting 2nd order SQL injection with static analysis requires interprocedural taint analysis — the ability to trace data flow across function calls, class boundaries, and database read/write operations.

The analysis must model:

  1. Taint source: User-controlled input enters the application via an HTTP parameter, form field, or API request body
  2. Storage propagation: The tainted value is written to the database (INSERT, UPDATE, stored procedure call) — the database is treated as a taint propagator, not a sanitizer
  3. Retrieval taint: When the stored value is later retrieved via a SELECT query, the taint label is re-applied to the returned data
  4. Injection sink: The retrieved, tainted value reaches a SQL query construction point (string concatenation with execute(), query(), createStatement(), etc.) without parameterization

This four-step cross-context trace is what separates tools capable of detecting 2nd order SQL injection (Checkmarx, Fortify, Offensive360) from simpler pattern-matching tools (SonarQube Community, Semgrep, linters) that cannot model the database as a taint intermediate.

SAST Finding Example (Checkmarx)

Query:   SQL_Injection_Second_Order
Severity: High
CWE:     CWE-89 (Improper Neutralization of Special Elements in SQL Commands)

Path:
  UserController.java:47   req.getParameter("username")
    → stored via:
  UserService.java:83      INSERT INTO users (username, ...) VALUES (?, ...)
    ↓ (DB as propagator)
  PasswordService.java:31  SELECT username FROM users WHERE id = ?
    → used in:
  PasswordService.java:45  "UPDATE users SET password='" + username + "' ..."
                                                    ↑ INJECTION SINK

The data flow path spans three files, two service classes, and a database round-trip. A pattern-matching tool looking at PasswordService.java:45 in isolation would not see username as tainted — it came from a SELECT query result, which looks “internal” and therefore safe.


The Fix: Parameterized Queries Everywhere

The fix for 2nd order SQL injection is identical to the fix for first-order SQL injection: parameterized queries (prepared statements) at every database interaction — not just at input collection points.

The core principle: data retrieved from your own database is not automatically safe. The database is a data store, not a sanitizer. Any value that enters a SQL query must be parameterized, regardless of where it came from.

PHP — PDO Prepared Statements

// FIXED — parameterize the second query too
$stmt = $pdo->prepare("SELECT username FROM users WHERE id = :id");
$stmt->execute([':id' => $_SESSION['user_id']]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$username = $row['username'];  // Still treated as untrusted

$update = $pdo->prepare(
    "UPDATE users SET password_hash = :hash WHERE username = :username"
);
$update->execute([
    ':hash'     => $new_hash,
    ':username' => $username   // Parameterized — cannot break the query structure
]);

Python — DB-API parameterization

# FIXED
email = cursor.execute(
    "SELECT email FROM password_reset_tokens WHERE token = %s", (token,)
).fetchone()[0]

# Parameterized at the second query — email from DB treated as untrusted
cursor.execute(
    "SELECT user_id FROM users WHERE email = %s",
    (email,)   # Parameterized
)

Java — PreparedStatement

// FIXED — PreparedStatement at the trigger query
String username = getStoredUsername(userId);  // Returns potentially tainted string

PreparedStatement stmt = conn.prepareStatement(
    "UPDATE users SET password_hash = ? WHERE username = ?"
);
stmt.setString(1, newPasswordHash);
stmt.setString(2, username);   // DB-sourced value, still parameterized
stmt.execute();

C# — SqlCommand with Parameters

// FIXED
string username = GetUsernameFromDatabase(userId);  // From DB

using var cmd = new SqlCommand(
    "UPDATE Users SET PasswordHash = @hash WHERE Username = @username",
    connection
);
cmd.Parameters.AddWithValue("@hash", newPasswordHash);
cmd.Parameters.AddWithValue("@username", username);  // Parameterized
cmd.ExecuteNonQuery();

Defense in Depth: Additional Controls

Parameterized queries are the primary fix. These additional controls reduce risk further:

1. Use an ORM wherever possible. ORMs like Hibernate (Java), Entity Framework (C#), SQLAlchemy (Python), and ActiveRecord (Ruby) parameterize all queries by default. 2nd order injection requires explicit use of raw SQL — an ORM enforces parameterization by design.

2. Input validation at storage time. Validate and reject values with SQL metacharacters (', ", ;, --) at the point of storage. This is a defense-in-depth measure, not a replacement for parameterized queries. Proper output encoding at the query sink is always required.

3. Principle of least privilege for the database user. Even if injection occurs, a database user with only SELECT and INSERT privileges on specific tables cannot DROP TABLE or read sensitive tables it should not access.

4. Code review focus. When reviewing code for 2nd order injection, search specifically for patterns where values retrieved from the database are later used in query construction. Any place where a SELECT result is concatenated into another SQL string is a candidate.


OWASP Classification

2nd order SQL injection is classified under:

  • OWASP A03:2021 — Injection (the same top-level category as first-order SQL injection)
  • CWE-89: Improper Neutralization of Special Elements used in an SQL Command (‘SQL Injection’)
  • OWASP Testing Guide: WSTG-INPV-05 — Testing for SQL Injection (covers second-order specifically)

OWASP notes that second-order injection is particularly dangerous because it violates the assumption that data from an internal source (the database) is trusted. Many developers recognize the need to validate user input at entry points but do not apply the same rigor to data retrieved from their own systems.


Summary

PropertyFirst-Order SQL Injection2nd Order SQL Injection
Execution timingImmediate (same request)Deferred (later request)
DAST detectionUsually detectedUsually missed
SAST detectionAll taint-analysis SAST toolsOnly interprocedural taint analysis
Root causeUnsanitized user input in SQLTrusted DB data in SQL (no parameterization)
FixParameterized queries at inputParameterized queries everywhere
CWECWE-89CWE-89

The key rule for preventing 2nd order SQL injection: never concatenate data retrieved from the database into a SQL query string. Parameterize every query, treat every data source as untrusted, and use an ORM wherever your framework supports it.


Offensive360 SAST detects 2nd order SQL injection using deep interprocedural taint analysis across Java, C#, PHP, Python, JavaScript, Go, Ruby, and 50+ other languages. Run a one-time code scan for $500 to identify second-order injection and other injection vulnerabilities in your codebase — results within 48 hours.

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.