Second-Order SQL Injection Testing: Finding Stored SQLi Vulnerabilities
First-order SQL injection is the version everyone knows: you put a malicious string in a parameter, the application builds a query with it, the query breaks, and you get data back (or cause an error, or observe a time delay). Input goes straight to query — the vulnerability is immediate and usually detectable with a single request.
Second-order SQL injection is different. The malicious payload is stored safely by the application during the first request — it's escaped, parameterized, or otherwise handled correctly. But later, when the application retrieves and reuses that stored value in a different query context, it forgets that the data is untrusted. The same string that was safely stored is now concatenated directly into a second query, and the injection triggers.
This is what makes second-order SQLi hard to find. Automated scanners that send a payload and immediately check the response will miss it entirely. You have to understand data flow: where values go in, and where they come back out into query construction.
How Second-Order SQLi Works
The classic example is username registration followed by password change:
- Registration: Attacker registers username
admin'--. The registration code uses parameterized queries or escaping, so the username is stored in the database correctly as the literal stringadmin'--. - Password change: The attacker logs in and changes their password. The password change code retrieves the current username from the session (or database), then builds a query like:
UPDATE users SET password='newpass' WHERE username='admin'--'— using string concatenation instead of parameterization. - Injection fires: The
--comments out the trailing quote and any other conditions. TheWHERE username='admin'clause matches the admin account, changing its password instead of the attacker's account.
-- Step 1: Registration query (safe — uses parameterization)
INSERT INTO users (username, password) VALUES (?, ?)
-- Parameters: ("admin'--", "attacker_password")
-- Stored value in DB: admin'--
-- Step 2: Password change query (vulnerable — string concatenation)
username = get_current_username() -- returns: admin'--
query = "UPDATE users SET password='" + new_password + "' WHERE username='" + username + "'"
-- Executed: UPDATE users SET password='hack' WHERE username='admin'--'
-- Result: admin account's password changed
Common Storage Vectors
Any application field that gets stored and later reused in query construction is a potential second-order injection point. The highest-risk fields:
| Storage point | Common reuse contexts | Why it's dangerous |
|---|---|---|
| Username / display name | Profile update, password change, audit logs, admin search | Used in WHERE clauses without re-parameterization |
| Email address | Password reset, user lookup, invite queries | Often concatenated in forgot-password flows |
| User bio / description | Admin user management, report generation | Admin queries built from stored user content |
| Product/item names | Inventory queries, order processing, reporting | Item names used in backend report queries |
| File / document names | Download handlers, file management queries | Filename passed to database query for permission check |
| Search history / saved searches | Re-executed saved search queries | Saved search terms reused directly in future queries |
| HTTP headers (User-Agent, Referer) | Analytics queries, audit log search | Headers stored in DB then queried in admin dashboards |
Testing Methodology
Step 1: Map data flows
Before testing, build a data flow map. For each input that gets stored, trace where that stored value is later used:
- Which fields are stored in the database? (Username, email, bio, preferences, etc.)
- Which application functions retrieve and operate on these stored values?
- Do any of these retrieval paths build subsequent database queries using the retrieved values?
- Is the retrieved value treated as trusted (re-used without parameterization) or untrusted (re-parameterized)?
Code review is the most reliable approach to mapping this. Look for patterns where a retrieved database value is used to build a subsequent query:
# Python — vulnerable pattern
def change_password(session_user_id, new_password):
username = db.execute("SELECT username FROM users WHERE id = ?", [session_user_id]).fetchone()[0]
# VULNERABLE: username from database used in string concatenation
db.execute("UPDATE users SET password = '" + hash(new_password) + "' WHERE username = '" + username + "'")
# PHP — vulnerable pattern
function updateProfile($userId, $newBio) {
$user = $db->query("SELECT username FROM users WHERE id = $userId")->fetch();
$username = $user['username'];
// VULNERABLE: username concatenated directly
$db->query("UPDATE profiles SET bio = '$newBio' WHERE username = '$username'");
}
Step 2: Register payloads in all storage vectors
For each input field that gets stored, register multiple accounts or records using SQL injection payloads as values. Use payloads that are safe at storage time but will break query syntax when reused:
# Registration payloads (safe at INSERT time, dangerous at SELECT/UPDATE time)
# Basic single-quote breakout
admin'--
admin'/*
admin' OR '1'='1
# Comment-based payload for UPDATE WHERE bypass
victim_user'--
victim_user'/*comment*/
# Union-based extraction (if the second query has output)
' UNION SELECT null,username,password FROM users--
# Stacked query (MSSQL/PostgreSQL)
'; INSERT INTO admin_users (username,password) VALUES ('hack','hack')--
# Time-based (to confirm injection without output)
'; WAITFOR DELAY '0:0:5'--
'; SELECT pg_sleep(5)--
# For email fields (must remain valid email format to pass validation)
admin'--@example.com
test'+SLEEP(5)+'@example.com
Step 3: Trigger the reuse paths
After storing the payloads, trigger every application function that might reuse that stored value in a query:
- Change password
- Update profile / bio / settings
- Password reset flow
- Admin user management — search for your username in admin panel
- Generate a report that includes your account
- Export data that includes your user record
- Any function that looks up users by username rather than by numeric ID
Watch for SQL errors in responses, unexpected data returned, or timing delays (if using time-based payloads). With admin'-- as a username, triggering a password change and then successfully logging in as admin with your new password confirms exploitation.
Step 4: Test with blind payloads
When there's no visible output (no error, no data returned), use time-based payloads to confirm second-order injection:
# Register with time-based username payload
# MySQL
Username: test'; SELECT IF(1=1,SLEEP(5),0)--
# PostgreSQL
Username: test'; SELECT pg_sleep(5)--
# MSSQL
Username: test'; WAITFOR DELAY '0:0:5'--
# Then trigger the vulnerable function (password change, profile update, etc.)
# If the response takes 5+ seconds longer than baseline, injection confirmed
The Email Address Vector
Email addresses are a particularly productive second-order vector because:
- They're used in many query lookups (forgot password, login, user search)
- Applications often escape them during registration but forget to re-parameterize when looking them up in forgot-password queries
- Many input validators accept single quotes in email local parts (RFC 5321 technically allows them)
# Register with SQL payload in email
Email: test'@example.com
Email: admin'--@example.com
# Then trigger password reset for this email
POST /forgot-password {"email": "admin'--@example.com"}
# Vulnerable backend code:
# email = get_from_db("SELECT email FROM users WHERE id = ?", [user_id])
# db.query("SELECT * FROM users WHERE email = '" + email + "'")
# -- becomes: SELECT * FROM users WHERE email = 'admin'--@example.com'
# -- equivalent: SELECT * FROM users WHERE email = 'admin'
Stored Procedure Vulnerabilities
Stored procedures are not automatically immune to second-order injection. A stored procedure that accepts a parameter, stores it, and then uses it in dynamic SQL within the procedure is just as vulnerable as application-level concatenation:
-- Vulnerable stored procedure (MSSQL)
CREATE PROCEDURE UpdateUserProfile
@userId INT,
@newBio NVARCHAR(500)
AS
BEGIN
-- Safe: parameterized insert
UPDATE profiles SET bio = @newBio WHERE user_id = @userId;
-- Vulnerable: retrieves username and uses it in dynamic SQL
DECLARE @username NVARCHAR(100);
SELECT @username = username FROM users WHERE id = @userId;
DECLARE @sql NVARCHAR(500);
SET @sql = 'UPDATE audit_log SET last_action = GETDATE() WHERE username = ''' + @username + '''';
EXEC sp_executesql @sql; -- SECOND-ORDER INJECTION HERE
END
Detection in Code Review
When reviewing code for second-order SQLi, search for these patterns:
# Python patterns to look for
# Pattern 1: value retrieved from DB then used in string concatenation
cursor.execute("SELECT username FROM users WHERE id = %s", [user_id])
username = cursor.fetchone()[0]
cursor.execute("SELECT * FROM orders WHERE buyer = '" + username + "'") # VULNERABLE
# Pattern 2: format strings using database-retrieved values
query = "UPDATE users SET login_time = NOW() WHERE email = '%s'" % email_from_db # VULNERABLE
# Pattern 3: ORM raw() or extra() with stored values
User.objects.raw(f"SELECT * FROM users WHERE dept = '{dept_from_db}'") # VULNERABLE
User.objects.extra(where=[f"username = '{username_from_db}'"]) # VULNERABLE
Grep across the codebase for dangerous patterns:
# Find Python string concatenation in SQL
grep -rn "execute.*+\|execute.*%" --include="*.py" . | grep -v "^Binary\|#"
# Find PHP string interpolation in SQL
grep -rn 'query.*\$[a-z]' --include="*.php" . | grep -v "^\s*//"
# Find Node.js template literals in queries
grep -rn 'query.*`.*\${' --include="*.js" --include="*.ts" .
Testing Checklist
| Test | Storage point | Trigger path |
|---|---|---|
| Username-based injection | Registration username field | Password change, profile update, admin search |
| Email-based injection | Registration email field | Password reset, user lookup, account update |
| Profile field injection | Bio, company name, address fields | Admin management view, reporting, export |
| Preference injection | Saved preferences, settings, themes | Any function that loads and applies stored preferences |
| HTTP header injection | User-Agent, Referer stored in logs | Admin log search, analytics query |
| Stored procedure flow | Any SP that stores then queries | Code review of SP body for dynamic SQL using stored values |
| Import/upload flows | CSV/JSON bulk import fields | Processing, validation, or query steps after import |
Remediation
Parameterize at every query, not just at input time: The root cause of second-order SQLi is treating data retrieved from a database as inherently trusted. It isn't. Data from a database must be treated as user-controlled input — because it was, at some point. Every database query must use parameterized statements or prepared queries, regardless of where the values came from.
# SAFE — parameterized even for values retrieved from DB
username = db.query("SELECT username FROM users WHERE id = ?", [user_id]).fetchone()[0]
db.query("UPDATE users SET password = ? WHERE username = ?", [hashed_password, username])
# UNSAFE — even though username came from the database
db.query("UPDATE users SET password = '" + hashed_password + "' WHERE username = '" + username + "'")
Use numeric IDs for internal lookups: Where possible, use opaque numeric IDs (not user-controlled usernames) for internal operations. A query like WHERE user_id = ? with a validated integer ID is structurally safe from second-order injection because the ID doesn't contain injection-capable characters.
Apply the principle of least privilege in stored procedures: Avoid dynamic SQL inside stored procedures. If dynamic SQL is unavoidable, use sp_executesql with typed parameters, not string concatenation.
Input validation cannot substitute for parameterization: Escaping and validation at input time doesn't prevent second-order injection — the payload is valid data that got stored correctly. The fix must be at query-construction time, not at storage time.
Ironimo traces data flows through your application to find second-order SQL injection — not just immediate injection in request parameters. By testing both the storage and reuse paths together, it catches the SQLi that single-request scanners miss entirely.
Start free scan