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

Second-Order SQL Injection: OWASP Definition & Checkmarx Detection

Second-order SQL injection explained: OWASP's definition, why most scanners miss it, how Checkmarx traces it across code paths, plus code-level fixes in Java, Python, PHP & C#.

Offensive360 Security Research Team — min read
second order sql injection what is second order sql injection OWASP Checkmarx SQL injection second-order SQLi stored sql injection 2nd order sql injection second order sql injection owasp

Second-order SQL injection is a variant of SQL injection where the attacker’s payload is not executed immediately upon submission — instead, it is stored in the application’s database and triggered later when the stored data is retrieved and used in a subsequent SQL query. This deferred execution pattern makes second-order SQLi one of the hardest vulnerability classes to detect, both manually and with automated tools.

This article covers the OWASP definition of second-order SQL injection, how it differs from classic (first-order) SQLi, why standard scanners miss it, how Checkmarx approaches detection, and the correct code-level prevention.


OWASP Definition of Second-Order SQL Injection

According to OWASP, second-order SQL injection (also called stored SQL injection or persistent SQL injection) occurs when:

“User-supplied data is stored by the application for later use. At a later point in time, the stored data is incorporated into a SQL statement in an unsafe manner.”

The OWASP Testing Guide (WSTG-INPV-05) classifies second-order SQLi separately from first-order injection precisely because the input validation at the point of storage may appear correct — the injection only manifests when the stored value is later used to construct a dynamic SQL query.

Key distinction from OWASP: The vulnerability is in the retrieval and re-use of stored data, not in the initial storage. An application can safely store an attacker-controlled string and still be vulnerable to second-order SQLi if it later uses that string in an unsafe SQL query.


How Second-Order SQL Injection Works: Step-by-Step

Stage 1: Payload Storage

The attacker submits a malicious payload through a form or API endpoint. The application may sanitize or escape the input before storing it — but this sanitization only prevents first-order injection at the storage query:

-- Attacker registers username: admin'--
-- Application escapes it for the INSERT:
INSERT INTO users (username, password_hash)
VALUES ('admin''--', '$2b$12$hashed_password');
-- No injection here — correctly escaped

The escaped value admin'-- is stored as a literal string in the database. No injection has occurred yet.

Stage 2: Payload Retrieval Without Re-Escaping

Later, the application retrieves the stored username and uses it in a new SQL query — typically in a different code path, possibly written by a different developer who trusts that the data is already “safe” because it came from the database:

-- Later: user requests a password change
-- Application retrieves username from session/DB and builds a new query:

-- VULNERABLE: Trusts that DB-stored data is safe
username = db.query("SELECT username FROM users WHERE id = ?", [user_id])

-- Inserts it unsafely into password update query:
sql = "UPDATE users SET password_hash = '" + new_hash + "' WHERE username = '" + username + "'"
-- Executes: UPDATE users SET password_hash = '...' WHERE username = 'admin'--'
-- The -- comment terminates the query, updating ALL users' passwords

The injected comment -- causes the WHERE clause to be commented out, updating every user’s password in the table — a complete authentication bypass.


Why Standard Scanners Miss Second-Order SQLi

Traditional dynamic analysis tools (DAST scanners) send a payload and immediately observe the response. If the application doesn’t execute the payload on the same request/response cycle, the scanner sees no injection — even though the payload is sitting in the database waiting to fire.

Static analysis tools (SAST) must trace data flow across multiple code paths, often across different HTTP handlers, to detect second-order injection. This requires:

  1. Tracking that user input was stored in the database (a “source”)
  2. Tracking that the stored value is later retrieved (a new “source” that is tainted)
  3. Following that retrieved value into a SQL query construction sink
  4. Determining that no parameterization occurs at the second sink

Most simple pattern-matching SAST tools fail at step 2 — they don’t model the database as a taint propagation intermediary.


How Checkmarx Detects Second-Order SQL Injection

Checkmarx CxSAST and Checkmarx One both include dedicated queries for second-order SQL injection, listed as:

  • SQL_Injection_Second_Order (CxSAST)
  • Second_Order_SQL_Injection (Checkmarx One)

These queries work by modeling database storage and retrieval as taint propagation steps:

  1. Source identification: User-supplied input entering the application (HTTP parameters, form fields, headers)
  2. Storage sink modeling: Checkmarx identifies database write operations (INSERT, UPDATE, stored procedure calls) as taint propagators, not sanitizers — the taint passes through the database
  3. Retrieval source: Database read operations (SELECT, executeQuery) are treated as tainted sources if the written data was tainted
  4. Injection sink: SQL string concatenation or dynamic query construction using the retrieved data

Example Checkmarx Finding

A Checkmarx SQL_Injection_Second_Order result typically presents as:

Severity: High
CWE: CWE-89 (SQL Injection)
Query: SQL_Injection_Second_Order
Source File: UserRegistrationController.java, Line 45
  req.getParameter("username")  →  stored to DB

Sink File: PasswordUpdateService.java, Line 112
  username (from DB)  →  string.concat()  →  executeQuery()

The path shows the vulnerability spanning two files and two execution contexts — this cross-context tracing is what makes Checkmarx’s second-order detection valuable.

Checkmarx False Positives for Second-Order SQLi

Checkmarx can over-report second-order SQL injection when:

  • The retrieved value is passed through a parameterized query at the sink (correctly safe, but the scanner may not recognize the parameterization pattern)
  • The retrieved value is validated against an allowlist before use (not always modeled correctly)
  • ORM frameworks are used at the sink (Hibernate, Entity Framework, SQLAlchemy) — most modern ORMs parameterize by default

If you receive a Checkmarx SQL_Injection_Second_Order false positive, verify that the sink uses a parameterized query or ORM method, then document it as a false positive with the specific sanitizer used.


Real-World Second-Order SQLi Examples

Example 1: Username in Profile Update

// Stage 1: Registration — input is escaped for storage
$username = mysqli_real_escape_string($conn, $_POST['username']);
$sql = "INSERT INTO users (username, email) VALUES ('$username', '$email')";
// Attacker registers username: admin'--

// Stage 2: Profile update — trusts DB data, no re-escaping
$result = $conn->query("SELECT username FROM users WHERE id = " . $_SESSION['user_id']);
$row = $result->fetch_assoc();
$username = $row['username'];  // Contains: admin'--

// VULNERABLE: username from DB used unsafely in new query
$sql = "UPDATE profiles SET bio = '" . $bio . "' WHERE username = '" . $username . "'";
// Executes: UPDATE profiles SET bio = '...' WHERE username = 'admin'--'
// Bypasses WHERE clause, updates all profiles

Example 2: Email Address in Password Reset

# Stage 1: Registration
cursor.execute("INSERT INTO users (email, name) VALUES (%s, %s)", (email, name))
# Attacker registers with email: x' OR '1'='1

# Stage 2: Password reset — retrieves email from session token
email = get_email_from_reset_token(token)  # Returns tainted value from DB

# VULNERABLE: email used in new query without parameterization
cursor.execute("SELECT id FROM users WHERE email = '" + email + "'")
# Executes: SELECT id FROM users WHERE email = 'x' OR '1'='1'
# Returns all user IDs — attacker resets any account

Example 3: Stored Procedure with Second-Order Path

-- VULNERABLE stored procedure
CREATE PROCEDURE UpdateUserProfile(
    @user_id INT,
    @new_bio NVARCHAR(500)
)
AS BEGIN
    DECLARE @username NVARCHAR(100)

    -- Retrieve username from DB (tainted if attacker-controlled)
    SELECT @username = username FROM Users WHERE id = @user_id

    -- VULNERABLE: Dynamic SQL using retrieved value
    DECLARE @sql NVARCHAR(500)
    SET @sql = 'UPDATE Profiles SET bio = ''' + @new_bio + ''' WHERE username = ''' + @username + ''''
    EXEC sp_executesql @sql
END

Prevention: Parameterized Queries at Every Query

The complete and only reliable fix for second-order SQL injection is parameterized queries (prepared statements) at every database interaction — not just at the initial input point.

The critical rule: never trust data from the database any more than you trust data from the user. The database is not a sanitizer.

Java — PreparedStatement

// VULNERABLE
String username = rs.getString("username");
Statement stmt = conn.createStatement();
stmt.execute("UPDATE profiles SET bio = '" + bio + "' WHERE username = '" + username + "'");

// FIXED — PreparedStatement used at the second query
String username = rs.getString("username");
PreparedStatement stmt = conn.prepareStatement(
    "UPDATE profiles SET bio = ? WHERE username = ?"
);
stmt.setString(1, bio);
stmt.setString(2, username);  // username from DB is still treated as untrusted input
stmt.execute();

Python — Parameterized Query

# VULNERABLE
username = cursor.fetchone()[0]  # From DB
cursor.execute("UPDATE profiles SET bio = '" + bio + "' WHERE username = '" + username + "'")

# FIXED
username = cursor.fetchone()[0]
cursor.execute(
    "UPDATE profiles SET bio = %s WHERE username = %s",
    (bio, username)  # Parameterized — username from DB treated as input
)

PHP — PDO Prepared Statements

// VULNERABLE
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$username = $row['username'];
$db->query("UPDATE profiles SET bio = '$bio' WHERE username = '$username'");

// FIXED
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$username = $row['username'];
$update = $db->prepare("UPDATE profiles SET bio = :bio WHERE username = :username");
$update->execute([':bio' => $bio, ':username' => $username]);

C# — Parameterized Query

// VULNERABLE
string username = reader["username"].ToString();
string sql = $"UPDATE Profiles SET Bio = '{bio}' WHERE Username = '{username}'";
command.ExecuteNonQuery();

// FIXED
string username = reader["username"].ToString();
using var cmd = new SqlCommand("UPDATE Profiles SET Bio = @bio WHERE Username = @username", conn);
cmd.Parameters.AddWithValue("@bio", bio);
cmd.Parameters.AddWithValue("@username", username);  // From DB, still parameterized
cmd.ExecuteNonQuery();

Detection Strategy: Finding Second-Order SQLi in Your Codebase

Second-order SQL injection requires tracing data across multiple query boundaries. An effective detection strategy combines:

  1. SAST with inter-procedural taint analysis: Tools like Checkmarx, Fortify, and Offensive360 that model database storage as taint propagation will surface second-order paths that simpler tools miss.

  2. Code review focused on retrieval paths: Any place where data is read from the database and then used in a new query should be reviewed. Grep for patterns like execute("... or query("... that follow a database read.

  3. Manual testing with delayed payloads: Register an account or submit a form with a payload like test'--. Then trigger all actions that use that stored value in a new query and observe the behavior.

  4. ORM adoption: Using an ORM (Hibernate, Entity Framework, SQLAlchemy, ActiveRecord) by default parameterizes all queries — preventing both first- and second-order SQLi. Custom native queries within ORMs must still be parameterized.


Summary

Second-order SQL injection is the stored, persistent variant of SQL injection that fires in a later query — not at the point of input. OWASP documents it as a distinct injection class requiring specific testing methodology. Checkmarx detects it through inter-procedural taint analysis that models the database as a taint propagator. The fix is identical to first-order SQLi: parameterized queries at every database interaction, including queries that use data retrieved from your own database.

If you have a Checkmarx SQL_Injection_Second_Order finding, the remediation is to locate the sink query (the second query in Checkmarx’s data flow path) and convert it to use a prepared statement or parameterized ORM call.

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.