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 Attack Patterns

5 second-order SQL injection examples with full exploit chains: username escalation, password bypass, and UNION-based extraction in PHP, Python & Java.

Offensive360 Security Research Team — min read
second order sql injection second order sql injection example 2nd order sql injection stored sql injection second order sqli sql injection web security OWASP CWE-89 SAST application security

Second-order SQL injection (also called stored SQL injection) is fundamentally different from classic SQL injection. The payload doesn’t execute when it enters the application — it executes later, when the stored data is retrieved and used in a new SQL query context. This two-stage timing is exactly why it evades most automated scanners and manual code reviews.

This post walks through five concrete second-order SQL injection examples with full exploit chains, the exact vulnerable code patterns, and the secure fixes. These are the patterns you need to understand to find and fix second-order SQLi in real codebases — and to verify that your SAST tool can actually detect them.


Why Second-Order SQL Injection Is Different

Before the examples, the key mental model:

Classic SQL injection: User input → immediately into SQL query → exploitable in the same request.

Second-order SQL injection: User input → stored in database → later retrieved → used in a different SQL query → exploitable in a request that arrives seconds, days, or months later.

The “stored” phase creates a false sense of safety. Developers see input sanitized on the way in and assume data from their own database is safe. It isn’t — it was sanitized at insertion but not at retrieval.


Example 1: Username-Based Password Change Hijack

This is the classic second-order SQLi example and the one most commonly cited in OWASP documentation.

The Setup

The application allows users to register with any username and later change their password through a “Change Password” feature that looks up the account by the stored username.

Stage 1 — Inject the Payload at Registration

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

username=admin'--&password=anything123

The registration handler escapes the input before inserting it:

// Vulnerable PHP registration handler
$username = mysqli_real_escape_string($conn, $_POST['username']);
$password = password_hash($_POST['password'], PASSWORD_DEFAULT);
$sql = "INSERT INTO users (username, password) VALUES ('$username', '$password')";
mysqli_query($conn, $sql);
// Stores: admin'-- safely in the DB — no injection at this stage

At this point, the database contains the username admin'--. No injection occurred yet. The developer’s escaping worked correctly here.

Stage 2 — Trigger the Injection During Password Change

Later, the attacker uses the “Change Password” function. The application retrieves the stored username and uses it in a new SQL query without re-escaping:

// Vulnerable password change handler
$user_id = $_SESSION['user_id'];

// Fetch the stored username — application trusts its own DB
$result = mysqli_query($conn, "SELECT username FROM users WHERE id = $user_id");
$row = mysqli_fetch_assoc($result);
$stored_username = $row['username']; // Contains: admin'--

$new_password_hash = password_hash($_POST['new_password'], PASSWORD_DEFAULT);

// VULNERABLE: stored DB data used without parameterization
$sql = "UPDATE users SET password = '$new_password_hash' WHERE username = '$stored_username'";
mysqli_query($conn, $sql);
// Executes: UPDATE users SET password = '...' WHERE username = 'admin'--'
// The -- comments out the trailing quote, and the WHERE clause matches username = 'admin'

What executes:

UPDATE users SET password = 'attacker_chosen_hash' WHERE username = 'admin'--'

The -- makes the rest of the query a comment. The WHERE clause is effectively WHERE username = 'admin', changing the actual admin account’s password to one the attacker controls.

The Fix

Parameterize every SQL statement, including those using data retrieved from your own database:

// SECURE: parameterized queries everywhere
$stmt = $conn->prepare("SELECT username FROM users WHERE id = ?");
$stmt->bind_param("i", $user_id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stored_username = $row['username'];

// SECURE: DB-sourced data treated as untrusted parameter
$stmt = $conn->prepare("UPDATE users SET password = ? WHERE username = ?");
$stmt->bind_param("ss", $new_password_hash, $stored_username);
$stmt->execute();

Example 2: Profile Field Injection Leading to Data Exfiltration

The Setup

An e-commerce application stores a user’s saved city in their profile. An admin reporting page later uses the stored city to generate sales reports.

Stage 1 — Inject into the Profile City Field

POST /api/profile/update HTTP/1.1
Authorization: Bearer eyJ...
Content-Type: application/json

{
  "city": "London' UNION SELECT username, password, email, NULL FROM users--",
  "country": "UK"
}

The update handler escapes the city value and stores it safely:

# Python / SQLAlchemy — vulnerable profile update
from sqlalchemy import text

city = escape_sql(request.json['city'])  # Escaping applied
db.execute(
    text("UPDATE profiles SET city = :city WHERE user_id = :uid"),
    {"city": city, "uid": current_user.id}
)
# Stores the UNION payload as a string — no injection here

Stage 2 — Trigger via the Admin Report

The admin report page retrieves all stored cities and uses them to generate a per-city sales query:

# VULNERABLE: admin report generator trusts DB data
cities = db.execute(text("SELECT DISTINCT city FROM profiles")).fetchall()

for city_row in cities:
    city = city_row[0]  # Retrieved from DB — contains the UNION payload
    
    # VULNERABLE: DB data embedded into a new query without parameterization
    query = f"SELECT COUNT(*) FROM orders WHERE city = '{city}'"
    result = db.execute(text(query)).fetchall()
    report_data[city] = result

When the loop reaches the attacker’s stored city value, it executes:

SELECT COUNT(*) FROM orders WHERE city = 'London' 
UNION SELECT username, password, email, NULL FROM users--'

The UNION injects the users table credentials into the admin report — a full data exfiltration from a trusted internal operation.

The Fix

# SECURE: always use parameters, even with DB-sourced data
cities = db.execute(text("SELECT DISTINCT city FROM profiles")).fetchall()

for city_row in cities:
    city = city_row[0]
    
    # SECURE: parameterized query prevents UNION injection
    result = db.execute(
        text("SELECT COUNT(*) FROM orders WHERE city = :city"),
        {"city": city}
    ).fetchall()
    report_data[city] = result

Example 3: JWT Subject Claim Injection in Java

The Setup

A Java Spring application stores a user’s email address in the database at registration. A later “account settings” flow issues a JWT whose sub (subject) claim is populated from the stored email, then uses the sub claim in a SQL query to fetch account details.

Stage 1 — Register with a Malicious Email

POST /api/auth/register HTTP/1.1
Content-Type: application/json

{
  "email": "[email protected]' OR '1'='1",
  "password": "SecurePass123!"
}

Registration stores the email with proper parameterization:

// Vulnerable Java registration — stores safely
String email = request.getEmail();
jdbcTemplate.update(
    "INSERT INTO users (email, password_hash) VALUES (?, ?)",
    email, hashPassword(request.getPassword())
);
// Stored: [email protected]' OR '1'='1 — safe in DB

Stage 2 — JWT Claims Populated from DB, Then Used Unsafely

Later, when a settings endpoint fetches the user’s account details, it reads the stored email, puts it in the JWT sub, and then uses that JWT claim in a raw SQL query:

// VULNERABLE: JWT sub populated from stored email, then used in raw query
String storedEmail = jdbcTemplate.queryForObject(
    "SELECT email FROM users WHERE id = ?",
    String.class, userId
);

// JWT issued with sub = stored email (contains SQL payload)
String token = Jwts.builder()
    .setSubject(storedEmail)
    .signWith(secretKey)
    .compact();

// ... In another endpoint ...

// Claims extracted from JWT
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
String emailFromJwt = claims.getSubject(); // Contains: [email protected]' OR '1'='1

// VULNERABLE: JWT claim used in raw SQL without parameterization
String query = "SELECT * FROM account_details WHERE email = '" + emailFromJwt + "'";
AccountDetails details = jdbcTemplate.queryForObject(query, AccountDetails.class);

The stored payload travels: database → JWT claim → SQL query. This chain crosses three different data representations, making it nearly impossible for a tool to detect without full interprocedural taint analysis.

The Fix

// SECURE: parameterize regardless of the data source
String emailFromJwt = claims.getSubject();

AccountDetails details = jdbcTemplate.queryForObject(
    "SELECT * FROM account_details WHERE email = ?",
    new BeanPropertyRowMapper<>(AccountDetails.class),
    emailFromJwt  // Parameterized — SQL injection impossible
);

Example 4: Stored Procedure Injection via Trusted Database Data

The Setup

An application uses stored procedures to process orders. The process_order stored procedure constructs dynamic SQL internally using the product name stored in the database — which came from user input at product listing time.

Stage 1 — Inject via Product Name at Listing Time

An attacker with a seller account lists a product with a malicious name:

POST /api/products HTTP/1.1
Authorization: Bearer seller-token

{
  "name": "Widget'; EXEC xp_cmdshell('whoami')--",
  "price": 9.99,
  "stock": 100
}

The product name is stored safely (parameterized insert). It sits in the database table as Widget'; EXEC xp_cmdshell('whoami')--.

Stage 2 — Triggered by Order Processing Stored Procedure

When a customer orders the product, the order processing stored procedure retrieves the product name and constructs a dynamic SQL audit log entry:

-- Vulnerable MSSQL stored procedure
CREATE PROCEDURE process_order
    @order_id INT
AS
BEGIN
    DECLARE @product_name NVARCHAR(255)
    DECLARE @audit_sql NVARCHAR(1000)
    
    -- Fetches stored product name from DB
    SELECT @product_name = p.name
    FROM orders o
    JOIN products p ON o.product_id = p.id
    WHERE o.id = @order_id
    
    -- VULNERABLE: constructs dynamic SQL with DB-sourced data
    SET @audit_sql = 'INSERT INTO audit_log VALUES (GETDATE(), ''' + @product_name + ''')'
    EXEC(@audit_sql)
    -- Executes: INSERT INTO audit_log VALUES (GETDATE(), 'Widget'; EXEC xp_cmdshell('whoami')--')
END

When any customer orders the product, xp_cmdshell executes — running OS commands on the database server.

The Fix

-- SECURE: use sp_executesql with parameters for all dynamic SQL
CREATE PROCEDURE process_order
    @order_id INT
AS
BEGIN
    DECLARE @product_name NVARCHAR(255)
    
    SELECT @product_name = p.name
    FROM orders o
    JOIN products p ON o.product_id = p.id
    WHERE o.id = @order_id
    
    -- SECURE: sp_executesql with parameterized dynamic SQL
    DECLARE @audit_sql NVARCHAR(1000) = N'INSERT INTO audit_log VALUES (GETDATE(), @pname)'
    EXEC sp_executesql @audit_sql, N'@pname NVARCHAR(255)', @pname = @product_name
END

Example 5: Node.js Session Data Injection

The Setup

A Node.js/Express application stores a user’s display name in the session after login. A search logging feature later uses the stored display name in a raw query to log which users performed searches.

Stage 1 — Register with a Malicious Display Name

// Registration — stores display name safely with parameterized insert
const displayName = req.body.displayName;
await db.query(
  'INSERT INTO users (display_name, email, password) VALUES ($1, $2, $3)',
  [displayName, email, hashedPassword]
);
// Stores: '; DELETE FROM search_logs; -- as display_name

Stage 2 — Session Data Flows to Vulnerable Query

After login, the display name is loaded from the database and stored in the session:

// Login handler — stores DB data in session
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);
req.session.displayName = user.rows[0].display_name; // Contains malicious payload

// ... In the search endpoint ...

// VULNERABLE: session data (originally from DB) used in raw query
app.post('/api/search', async (req, res) => {
  const query = req.body.query;
  const displayName = req.session.displayName; // Contains: '; DELETE FROM search_logs; --
  
  // VULNERABLE: session-sourced (originally DB-sourced) data in raw SQL
  const logSql = `INSERT INTO search_logs (user_name, query) VALUES ('${displayName}', '${query}')`;
  await db.query(logSql);
  
  // ... perform search ...
});

When the attacker performs any search, the query becomes:

INSERT INTO search_logs (user_name, query) VALUES (''; DELETE FROM search_logs; --', 'test')

The search_logs table is deleted.

The Fix

// SECURE: parameterize session-sourced data
app.post('/api/search', async (req, res) => {
  const query = req.body.query;
  const displayName = req.session.displayName;
  
  // SECURE: parameterized — no injection possible regardless of stored value
  await db.query(
    'INSERT INTO search_logs (user_name, query) VALUES ($1, $2)',
    [displayName, query]
  );
});

Common Patterns That Enable Second-Order SQLi

Looking across all five examples, the enabling conditions are consistent:

1. Trusting “own” database data. The most common mental model mistake: “This data came from our database, so it’s safe.” The database is a storage layer, not a sanitization layer. Data stored safely can still be dangerous when later used in dynamic SQL.

2. Sanitizing at input but not at use. Developers often escape or validate user input at the point of entry (registration, form submission) but don’t apply the same discipline when constructing queries from retrieved data.

3. Multi-step workflows. Second-order SQLi almost always spans two separate code paths: one that writes data (registration, profile update, product listing) and one that reads and uses it (password change, report generation, order processing). These code paths are often written by different developers at different times, making the connection easy to miss.

4. Dynamic SQL construction. String concatenation or string interpolation in SQL queries is the root cause. Parameterized queries prevent second-order SQLi at the use site — regardless of what the data contains.


How SAST Tools Detect Second-Order SQL Injection

Standard pattern-matching SAST tools cannot detect second-order SQL injection. The injection point and the dangerous sink are in separate functions, files, or even services. No single-method analysis catches the connection.

Detection requires interprocedural taint analysis with database boundary tracking:

  1. The taint engine marks data as tainted at the point of user input (HTTP request parameters, form fields)
  2. It follows the taint through the write operation to the database — tracking that tainted data was persisted
  3. It follows the read operation that retrieves the data — propagating taint to the retrieved value
  4. It traces the tainted retrieved value into any subsequent SQL query construction
  5. If the tainted value reaches a SQL sink without parameterization, it reports a second-order injection finding

This is why second-order SQL injection is used as a benchmark to distinguish genuine taint-analysis SAST tools from pattern matchers. A tool that cannot track taint through a database read/write boundary will always miss second-order SQLi — regardless of what the vendor claims.

Offensive360’s SAST engine tracks taint through database boundaries, session storage, file system operations, and cross-service calls, which is how it detects the patterns in all five examples above.


Testing Your Application for Second-Order SQLi

Manual testing approach:

Step 1: Identify all points where user input is stored (registration forms, profile fields, comment sections, product listings, configuration settings).

Step 2: Submit SQL metacharacters in each field: ', '', '; --, ' OR '1'='1, '; WAITFOR DELAY '0:0:5'--. Store these values.

Step 3: Identify all operations that retrieve the stored data and use it in processing (password changes, report generation, export functions, admin dashboards, email templates).

Step 4: Trigger each retrieval operation after the malicious values are stored. Watch for:

  • Unexpected query results
  • Time delays (for time-based blind detection)
  • Error messages revealing SQL syntax
  • Changes to data that shouldn’t change

Step 5: If any retrieval operation shows signs of injection, trace the code path from where the data was stored to where it was used in a SQL query.


Frequently Asked Questions

What is a second-order SQL injection example in real-world applications?

The most common real-world pattern is the username-based password change attack (Example 1). A user registers with a username like admin'--, then uses the “change password” feature. The application trusts the stored username when constructing the UPDATE query and inadvertently changes the admin’s password. This has been found in production e-commerce platforms, banking applications, and content management systems.

Can OWASP ZAP or Burp Suite detect second-order SQL injection?

Not automatically. DAST tools like Burp Suite and ZAP send payloads in one request and look for injection signals in the same response. Second-order SQLi produces no signal in the injection request — the effect only appears in a later, different request. Manual testing with Burp Suite’s Repeater (storing payloads manually, then triggering retrieval operations) can detect it, but automated DAST scanning typically misses second-order SQLi entirely.

Is second-order SQL injection the same as stored SQL injection?

Yes — “stored SQL injection,” “persistent SQL injection,” and “second-order SQL injection” all describe the same pattern. The OWASP classification and CWE-89 use “second-order SQL injection” as the formal name.

How is second-order SQLi different from what Checkmarx or Fortify detect?

Commercial SAST tools like Checkmarx and Fortify detect standard SQL injection reliably. Second-order SQLi detection requires tracking taint through database I/O boundaries, which some tools handle better than others. When evaluating any SAST tool, use the test case in Example 1 (where the taint path is: $_POST → DB insert → DB select → SQL UPDATE) and verify the tool reports a finding. If it doesn’t, it won’t catch second-order SQLi in your production code either.


Offensive360 SAST detects second-order SQL injection through full interprocedural taint analysis including database boundary tracking. Run a one-time scan for $500 to check your codebase for second-order injection and 100+ other vulnerability classes.

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.