Skip to content
highCVSS 8.8CWE-89A03:2021 – Injection

Second-Order Injection

Input that is stored safely and then used unsafely somewhere else entirely. The payload and the vulnerable query live in different requests, different code paths, and often different applications.

Overview

Second-order injection breaks the assumption that data which has already been through the front door is trustworthy.

The sequence:

  1. You submit a payload. The application stores it correctly — parameterised, escaped, no injection at insert time.
  2. The database now holds the raw string, quotes and all. Escaping was a transport concern; it does not persist in the stored value.
  3. Later, different code reads that value back and concatenates it into a query, because it came from the database and is therefore assumed safe.
  4. The injection fires — in a different request, possibly a different service, possibly days later.

This is why input validation at the boundary is not a sufficient defence, and why second-order flaws survive audits that check every request handler in isolation.

The Canonical Example

A registration form stores a username. A password-change routine looks up the account by that username. The first is parameterised; the second is not.

Both halves of the bug

Pythonregister.pySecure
# This is correct. The payload is stored verbatim as data,
# and no injection occurs here.
cursor.execute(
    "INSERT INTO users (username, password) VALUES (%s, %s)",
    (username, hashed)
)

# username = "admin'-- -"
# The database now contains the literal string:  admin'-- -

Where Second-Order Flaws Live

The stored value and the vulnerable sink are usually far apart. Common pairings:

  • User profile fields written by the user, read by an admin dashboard that builds ad-hoc reporting queries.
  • Log tables populated by the application, then queried by an analytics job that concatenates filters.
  • Import pipelines. A CSV or JSON upload lands in a staging table; an ETL step reads it and builds dynamic SQL.
  • Message queues. A worker consumes a job payload that originated as user input several services ago.
  • Cached values. Data pulled from Redis or Memcached is even less likely to be treated as untrusted than data from the database.
  • Batch and cron jobs. Nightly reports that concatenate values collected during the day, with no request context and no monitoring.

The common thread is a trust boundary that the developer did not realise was a boundary.

Testing for It

Second-order flaws are hard to find because the response to your injecting request is completely normal. Standard scanners largely miss them.

Approach:

Seed everywhere, then walk the application. Register accounts, set profile fields, submit forms with payloads in every stored field. Then exercise every other feature and watch for anomalies. Errors will surface in unrelated places.

Use distinctive markers. Store values containing a unique token alongside the payload, so when an error appears you can trace which field caused it. Something like zz1'-- - rather than a bare quote.

Prefer out-of-band or time payloads. Since you cannot see the response of the vulnerable query, an out-of-band callback is the most reliable confirmation — it will fire whenever the sink runs, including hours later in a batch job.

Be patient. A payload stored today may not execute until the nightly report runs. Register a callback host and check it the next day.

Read the code if you can. Grep for concatenation into SQL and trace backwards to see whether any input reaches it from storage. This is far faster than black-box testing for this class.

Operational Care

Second-order testing leaves payloads in the client's database. That has consequences that first-order testing does not:

  • Stored payloads persist after the engagement. They will fire again whenever the sink runs, potentially long after you have finished.
  • Batch jobs may execute them unattended, at a time nobody is watching, with no obvious cause.
  • Backups capture them, so a restore reintroduces payloads you thought were removed.

Keep a record of every value you store and where. Include the list in your report, and clean up deliberately rather than assuming the client will find them. A payload designed to prove a point in a test window can cause a genuine incident three weeks later.

Prevention

Parameterise every query, without exception. The rule that makes second-order impossible is simply that no value is ever concatenated into SQL, regardless of origin. Any policy phrased as "validate user input" fails here, because at the sink the value is not user input any more.

Supporting practices:

  • Treat the database as an untrusted source in threat models. It holds whatever users put there.
  • Do not "pre-escape" values before storage. Storing admin\'-- - produces corrupt data and does not help the sink, which needs parameterisation regardless.
  • Audit batch jobs, reporting queries, and admin tooling specifically. They are written under less scrutiny than request handlers and read data that users control.
  • In code review, follow the data, not the request. Ask where each value goes, not just where it came from.