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: 5 Real Examples with Payloads

Step-by-step second-order SQL injection examples: user registration bypass, profile-field attacks, password reset chains, and payloads for PHP, Python, Java, and Node.js.

Offensive360 Security Research Team — min read
second order sql injection 2nd order sql injection second order sql injection example sql injection example stored sql injection second order injection sql injection payload second order sql injection checkmarx owasp injection CWE-89

Second-order SQL injection is harder to spot than classic SQLi because the payload and the execution happen in completely separate requests — sometimes days or weeks apart. Understanding it through concrete, worked examples is the fastest way to build reliable intuition for finding and fixing it.

This guide walks through five realistic second-order SQL injection scenarios with actual payloads, vulnerable code, and the specific fixes that eliminate each one.


What Makes Second-Order SQL Injection Different

In classic (first-order) SQL injection, the attacker sends a payload, the application immediately builds a SQL query, and the database executes the injected code in the same HTTP request.

In second-order injection, the attack is split:

  1. Stage 1 — Store: The attacker submits a payload. The application stores it in the database (possibly with proper escaping at write time).
  2. Stage 2 — Execute: Later, the application retrieves the stored payload and uses it in a different SQL query — often without re-sanitizing it, because the application trusts its own database.

The gap between stages is what defeats most scanners. The payload produces no error at insertion time. The database doesn’t execute it until the second query.


Example 1: Username Registration → Password Change

This is the textbook second-order SQLi example and the one most frequently cited in Checkmarx and other SAST tool documentation.

The Scenario

An application lets users register a username and later change their password. The password-change function retrieves the username from the database (trusting it) and embeds it directly into a SQL query.

Stage 1: Register with a Malicious Username

POST /register HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=admin'--&password=MyPassword123

The registration handler escapes the username before storing it:

// PHP — registration handler (appears safe)
$username = mysqli_real_escape_string($conn, $_POST['username']);
$hashed   = password_hash($_POST['password'], PASSWORD_BCRYPT);

$sql = "INSERT INTO users (username, password_hash)
        VALUES ('$username', '$hashed')";
mysqli_query($conn, $sql);
// Stores the string: admin'--
// No injection here — the INSERT is parameterized by escaping

The database now contains a row with username = admin'--.

Stage 2: Trigger the Injection via Password Change

The attacker logs in as admin'-- and requests a password change:

POST /change-password HTTP/1.1

new_password=hacked123

The change-password handler retrieves the username from the session (which was loaded from the database) and embeds it into a new query:

// PHP — change-password handler (VULNERABLE)
$user_id = $_SESSION['user_id'];

// Step 1: Retrieve stored username (treated as "safe" because it's from the DB)
$result   = mysqli_query($conn, "SELECT username FROM users WHERE id = $user_id");
$row      = mysqli_fetch_assoc($result);
$username = $row['username'];  // ← Contains: admin'--

// Step 2: Build a new query using the "trusted" database value (NO escaping)
$new_hash = password_hash($_POST['new_password'], PASSWORD_BCRYPT);
$sql      = "UPDATE users SET password_hash = '$new_hash'
             WHERE username = '$username'";
// Executed SQL becomes:
// UPDATE users SET password_hash = 'hashed_value'
// WHERE username = 'admin'--'
// The -- comments out the closing quote — this updates THE REAL admin account
mysqli_query($conn, $sql);

The -- comment makes the database engine read the query as:

UPDATE users SET password_hash = 'hacked123_hash' WHERE username = 'admin'

The attacker has just reset the real admin account’s password by registering a username that looked safe at storage time but fired on retrieval.

The Fix

Parameterize every query — including those where the data came from the database:

// SECURE — parameterized queries at both stages
// Stage 1: Registration
$stmt = $conn->prepare("INSERT INTO users (username, password_hash) VALUES (?, ?)");
$stmt->bind_param("ss", $_POST['username'], $hashed);
$stmt->execute();

// Stage 2: Password change
$stmt = $conn->prepare("SELECT username FROM users WHERE id = ?");
$stmt->bind_param("i", $user_id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();

$stmt = $conn->prepare("UPDATE users SET password_hash = ? WHERE username = ?");
$stmt->bind_param("ss", $new_hash, $row['username']);
$stmt->execute();

Example 2: Profile Field → Reporting Query

This variant is common in applications with a search or reporting feature that re-uses user-supplied profile data.

The Scenario

A user updates their profile’s “company” field. The application later uses that company name in a reporting query for admin dashboards.

Stage 1: Store Malicious Company Name

PUT /profile HTTP/1.1
Content-Type: application/json

{"company": "Acme'; SELECT username, password_hash FROM users; --"}

The Python update handler sanitizes on write using parameterization (correctly):

# Python — profile update (safe at write time)
db.execute(
    "UPDATE profiles SET company = %s WHERE user_id = %s",
    (request.json['company'], current_user.id)
)

Stage 2: Trigger via Admin Report

An admin visits the orders-by-company report. The backend retrieves the company name from the database and uses it in a raw query:

# Python — admin reporting handler (VULNERABLE)
profile = db.execute(
    "SELECT company FROM profiles WHERE user_id = %s", (user_id,)
).fetchone()

company_name = profile['company']  # ← Malicious payload retrieved

# VULNERABLE: f-string interpolation into SQL
results = db.execute(
    f"SELECT order_id, total FROM orders WHERE company = '{company_name}'"
)
# Executed as:
# SELECT order_id, total FROM orders WHERE company = 'Acme';
# SELECT username, password_hash FROM users; --'

Depending on the database driver’s multi-statement support, this could dump the entire users table to the attacker.

The Fix

# SECURE — parameterize the reporting query too
results = db.execute(
    "SELECT order_id, total FROM orders WHERE company = %s",
    (company_name,)
)

The fix is one character change: replace the f-string with a parameterized placeholder. The principle is that company_name is untrusted regardless of where it came from.


Example 3: Email Address → Password Reset Chain

This pattern appears in password reset systems where the stored email is used to look up the account and generate a reset URL.

The Scenario

A registration form stores an email address. The “Forgot Password” flow retrieves the email and uses it in a query to look up the user account.

Stage 1: Register with Injected Email

POST /register HTTP/1.1

email=attacker%40example.com'%20OR%20'1'%3D'1&password=anything
# URL-decoded: [email protected]' OR '1'='1

The registration validates email format via regex (which passes — the regex is too permissive), then stores it with proper escaping.

Stage 2: Password Reset Trigger

A legitimate user (or the attacker) triggers password reset:

// Java — VULNERABLE password reset handler
String email = request.getParameter("email");
// Assume this is a clean value — but what if the stored email in the DB is the issue?

// The real problem: the handler RETRIEVES the stored email back from DB
// and uses it in another query
String storedEmail = jdbcTemplate.queryForObject(
    "SELECT email FROM users WHERE reset_token = ?",
    String.class, token
); // storedEmail may contain the injected value

// Then uses storedEmail unsafely in a second query:
String updateSql =
    "UPDATE users SET password = '" + newPassword + "' WHERE email = '" + storedEmail + "'";
jdbcTemplate.execute(updateSql);
// If storedEmail = [email protected]' OR '1'='1
// SQL becomes:
// UPDATE users SET password = 'newval' WHERE email = '[email protected]' OR '1'='1'
// This updates ALL user passwords

The Fix

// SECURE — PreparedStatement for all queries involving retrieved data
String updateSql = "UPDATE users SET password = ? WHERE email = ?";
jdbcTemplate.update(updateSql, newPassword, storedEmail);

Example 4: Display Name → Search Index Query (Node.js)

NoSQL databases are not immune. This example shows second-order injection in a Node.js application with a MySQL back end where a display name is used in a full-text search query.

Stage 1: Set Malicious Display Name

PATCH /settings HTTP/1.1
Content-Type: application/json

{"displayName": "John'; UPDATE users SET role='admin' WHERE id=1; --"}

The settings handler uses a parameterized query to store it safely.

When the application’s search feature runs a query to find content by author name:

// Node.js — VULNERABLE search handler
async function searchByAuthor(displayName) {
  // displayName retrieved from DB — treated as trusted
  const query = `SELECT * FROM posts WHERE author_name = '${displayName}'`;
  // ↑ Template literal = string interpolation = SQL injection
  return await db.query(query);
}

// Called as:
const profile = await db.query('SELECT display_name FROM users WHERE id = ?', [userId]);
const posts   = await searchByAuthor(profile[0].display_name);
// Executes: SELECT * FROM posts WHERE author_name = 'John';
//           UPDATE users SET role='admin' WHERE id=1; --'

The Fix

// SECURE — always use parameterized queries in Node.js/mysql2
async function searchByAuthor(displayName) {
  const [rows] = await db.query(
    'SELECT * FROM posts WHERE author_name = ?',
    [displayName]
  );
  return rows;
}

Example 5: Address Field → Invoice Generation (C# / ASP.NET)

This scenario is common in e-commerce and ERP applications where user address data is stored and later used in reporting or invoice generation queries.

Stage 1: Submit Malicious Address

POST /checkout/address HTTP/1.1
Content-Type: application/json

{
  "street": "123 Main St'; INSERT INTO admin_users VALUES('hacker','hacked'); --",
  "city": "Springfield",
  "zip": "12345"
}

The checkout handler stores this with EF Core (parameterized by default — no injection at storage).

Stage 2: Fire via Invoice Generation

A background job generates invoices using raw SQL:

// C# — VULNERABLE invoice generator
public async Task GenerateInvoice(int orderId)
{
    // Retrieve shipping address from database
    var order = await _context.Orders
        .Where(o => o.Id == orderId)
        .Select(o => new { o.ShippingStreet, o.CustomerEmail })
        .FirstAsync();

    // VULNERABLE: string interpolation into SQL for invoice lookup
    var invoiceData = await _context.Database.ExecuteSqlRawAsync(
        $"INSERT INTO invoices (order_id, address, email) " +
        $"VALUES ({orderId}, '{order.ShippingStreet}', '{order.CustomerEmail}')"
    );
    // Executes the attacker's INSERT INTO admin_users statement
}

The Fix

// SECURE — use FromSqlInterpolated (auto-parameterized) or LINQ
// Option 1: Use EF Core LINQ (always parameterized)
var invoice = new Invoice
{
    OrderId = orderId,
    Address = order.ShippingStreet,   // No SQL construction — EF handles it
    Email   = order.CustomerEmail,
};
_context.Invoices.Add(invoice);
await _context.SaveChangesAsync();

// Option 2: If raw SQL is required, use FromSqlInterpolated
// (NOT FromSqlRaw with concatenation)
await _context.Database.ExecuteSqlInterpolatedAsync(
    $"INSERT INTO invoices (order_id, address, email) VALUES ({orderId}, {order.ShippingStreet}, {order.CustomerEmail})"
);
// FormattableString is automatically parameterized by EF Core

Why SAST Tools Often Miss Second-Order SQLi

Most static analysis tools track taint from HTTP input sources (request parameters, headers, cookies) to SQL sinks (query construction). Second-order injection breaks this assumption:

  1. The HTTP input is written to the database — the taint “disappears” into a data store.
  2. A separate code path reads from the database — the retrieved value is typically not marked as tainted.
  3. The retrieved value flows into a SQL sink — but the SAST tool doesn’t know the value originated from user input.

Detecting second-order SQLi requires the SAST tool to:

  • Model database reads as taint sources (because the database may contain attacker-controlled data)
  • Track the provenance of data written to the database back to its original user-input source
  • Connect the write path and the read path across function boundaries

This is significantly more expensive computationally and requires deeper semantic understanding of how the application uses its data layer.

How Checkmarx Handles It

Checkmarx detects second-order SQL injection using a “stored taint” model — it tracks values that are stored to the database and marks database reads from those tables as potentially tainted. This is why the query “second order sql injection Checkmarx” is common: teams using Checkmarx frequently encounter second-order findings and need to understand what the tool is flagging.

The Checkmarx finding typically shows a data flow like:

Source: HttpRequest.getParameter() [line 42, UserController.java]
  → Stored: INSERT INTO users ... [line 78, UserRepository.java]
  → Retrieved: SELECT username FROM users ... [line 23, PasswordService.java]
  → Sink: executeQuery() [line 31, PasswordService.java]

This cross-function, cross-request taint chain is what distinguishes second-order SQLi detection from basic injection detection.


Detection Checklist for Second-Order SQL Injection

Use this list when reviewing code or triaging SAST findings:

Sources (where attacker data enters and gets stored):

  • Registration forms: username, email, display name, bio, address fields
  • Profile update endpoints
  • File upload handlers (filename stored in DB)
  • API keys, webhook URLs, or external URLs submitted by users

Dangerous retrieval patterns (where stored data is read back and re-used):

  • Login / authentication: username retrieved from session, used in subsequent queries
  • Password reset: email or token retrieved, used in update query
  • Admin panels: user data displayed using raw SQL for filtering or search
  • Report generation: user-supplied fields used in ORDER BY, WHERE, or GROUP BY clauses
  • Background jobs: batch processes that pull data from DB and run secondary queries

Vulnerable construction patterns (red flags in the code):

  • String concatenation with +, f-strings, .format(), or template literals inside SQL strings
  • ExecuteSqlRaw(), execute(), query(), or Statement.execute() with interpolated values
  • Any comment in the code like “this value comes from the DB, so it’s safe”

Prevention Rules

  1. Parameterize every SQL query — use prepared statements regardless of where the data came from (HTTP request or database read).
  2. Never trust database-sourced data — the database is a data store, not a sanitization layer.
  3. Use ORMs for standard CRUD — ORM query builders (Hibernate, Django ORM, ActiveRecord, EF Core LINQ) parameterize by default.
  4. Avoid raw SQL with retrieved data — if raw SQL is required for performance, use PreparedStatement, parameterized query, or the framework’s interpolated-string variant (e.g., FromSqlInterpolated not FromSqlRaw).
  5. Apply input validation at storage — reject or sanitize usernames, display names, and other fields that will be reused in queries. This is a defense-in-depth measure, not a replacement for parameterization.
  6. Add integration tests for re-use paths — write tests that store a ' or '-- in a field and verify the application handles retrieval correctly without query breakage.

Second-Order SQLi in OWASP and CWE

Second-order SQL injection falls under:

  • OWASP A03:2021 — Injection — explicitly includes stored injection patterns
  • CWE-89: Improper Neutralization of Special Elements used in an SQL Command
  • OWASP Testing Guide WSTG-INPV-05: Testing for SQL Injection (includes stored variants)

The fix is identical to classic SQLi: parameterized queries. The difference is that you must apply them at every SQL query execution point — not just where user input is first received.


Scanning for Second-Order SQL Injection

Second-order SQL injection is one of the hardest vulnerability classes to find through manual code review because the injection point and the execution point are often in entirely different files, classes, or even microservices.

Offensive360 SAST performs deep interprocedural taint analysis that tracks data from its original user-input source, through database write and read operations, into SQL sinks — detecting second-order injection chains that span multiple functions, classes, and files.

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.