Active Injection
The scanner sends a payload, reads the reply, and applies a rule that says whether the payload reached a query, a shell, a parser or a template. Six classes, six oracles, every payload read-only. An LLM pass afterwards drops the heuristic hits that were really just the app behaving normally.
Six oracles: request, response, decision
Each class has its own proof. The request is the payload, the response is what came back, and the decision is a rule plus a guard against the obvious false positive. Step through one class at a time.
1Error-based SQLi. Payloads such as ' OR '1'='1 and 1 UNION SELECT NULL-- go into the parameter. The response is searched for SQL error signatures from databases and ORMs: MySQL SQL syntax, PostgreSQL syntax error at or near, sqlite3's unrecognized token, Oracle ORA-, MSSQL, and the ORM error types OperationalError and ProgrammingError, so an injection surfaces even behind an ORM.
2Time-based blind SQLi, first measurement. The scanner times a safe value, then sends SLEEP(3) or pg_sleep(3). A response at least 2.5 s slower is a candidate. Nothing is reported yet.
3Re-measure independently. A second baseline and a second injected request are timed. The finding is reported only if the delay reproduces. A one-off GC pause, load spike or jitter fails the second pair and is dropped.
4Command injection. Shell metacharacters ;, | and backticks carry an echo of a canary string. The canary must appear in the body while the literal echo xss_cmd_test does not, so a page that reflects its input is not mistaken for a shell.
5NoSQL injection. A control request uses a sentinel value that cannot match anything. Then [$ne]=null (and $regex, $gt) goes in, as a query parameter and as a JSON body. Flag only if the operator response is 2xx, non-empty, and distinct from and larger than the control: the backend applied a client-supplied operator and the response inflated with real records.
6XXE as a request body. An external entity pointing at /etc/passwd is posted with an XML content type on endpoints that advertise XML or are POST. File-system content in the response is the proof.
7XXE as an uploaded file. The same payload is sent as xxe.xml and xxe.svg on upload-shaped paths, because an endpoint that accepts XML as a file rejects it as a body. This delivery is not gated on the recorded HTTP method: discovery records most endpoints as GET, and an upload endpoint is worth probing either way.
8SSTI. {{99991*7}} goes in (plus the same expression in other engines' syntax); if 699937 comes back, the template engine evaluated the input. An unchanged echo is not a finding.
Where the payloads go
Injection arrives through more than the query string. The scanner puts each payload into path segments, query parameters and JSON body fields, and ranks endpoints with the shared prioritizer so the likeliest sinks are tested first inside the time budget.
1Before the scanner runs, the shared prioritizer scores every endpoint for the INJECTION dimension. A file upload has no parameters, so parameter-shaped signals score it zero; because FILE_ACCESS counts toward INJECTION it ranks near the top anyway. On Juice Shop that moved /file-upload from 73rd of 77 to 6th, and the two XXE bugs behind it were found.
2Path parameters. A templated {id} segment is replaced with the payload, so REST-style paths are covered.
3Query parameters. On GET endpoints the payload goes into the query string.
4JSON body fields. On POST, PUT and PATCH endpoints the payload is the value of a body field.
5The file. For upload-shaped paths the payload is the uploaded file: XXE in xxe.xml and xxe.svg, and traversal filenames.
6Every payload is read-only. Quotes, sleeps, an echo, a comparison operator, a file read of /etc/passwd, an arithmetic expression. Nothing modifies data and nothing is exfiltrated.
Phase 9.4: the injection adjudicator
Size deltas and error strings misfire on benign behaviour: a redirect that renders the single-page-app shell, pagination, timestamps and dynamic content inflate a response just as a real leak would. Before triage, an LLM looks at each borderline finding with both sides of the comparison and may drop it. It can only drop.
1Phase 9.4 runs before triage. Only DAST findings whose title is in the adjudicated set qualify: NoSQL, SQL, Command, Template/SSTI and XXE injection. The Semgrep taint (SAST) findings and every non-injection finding bypass it untouched.
2For each candidate the LLM gets the payload, the BASELINE response the scanner captured with a safe value, and the INJECTED response. The scanner stores the baseline body on the finding for exactly this comparison.
3Both bodies are fenced as untrusted data. The target's output is data to analyse, never instructions. A hostile app that embeds "verdict": "benign" in its response cannot suppress its own finding, and any text that spoofs the fence markers is neutralised.
4Decision. Finding A: baseline and injected are the same SPA shell, so the size jump was a redirect, which is benign and dropped. Finding B: the SQL error appears only under the payload and not in the baseline, so it is genuine and kept. This separated real SQLi from the flaky vampi-secure false positives.
5Guard rails. It only ever removes. An LLM error or an unparseable reply keeps every finding in the batch, genuine uncertainty resolves to genuine, and with --llm none the phase is a strict no-op, so the deterministic benchmark floor is unchanged. It is a precision filter: a confident but wrong benign verdict can still drop a true positive.
Why it matters
Injection is #1 on the OWASP Top 10 for a reason. It is the most impactful vulnerability class:
- Read your entire database. SQL injection can dump every table, including user credentials and payment data.
- Execute system commands. Command injection gives full shell access to your server.
- Read server files. XXE can read
/etc/passwd, environment variables, AWS credentials. - Bypass authentication.
' OR '1'='1' --as a password bypasses login forms with SQL injection.
Real-world breaches
SQL injection led to the theft of 130 million credit and debit card numbers. One of the largest data breaches in history at the time.
LulzSec exploited SQL injection in Sony's websites, compromising over 1 million user accounts including plaintext passwords.
Vulnerable vs fixed
// BAD: String concatenation in SQL query
const filter = `status = '${userInput}'`
const result = await sql.unsafe(`SELECT * FROM tasks WHERE ${filter}`)
// BAD: Template literal with user input in SQL
const result = await sql`SELECT * FROM users WHERE name = '${name}'`
// BAD: Command execution with user input
const output = execSync(`convert ${uploadedFile} output.png`)// GOOD: Parameterized queries
const result = await sql`SELECT * FROM tasks WHERE status = ${userInput}`
// Note: tagged template literals in postgres.js are parameterized automatically
// GOOD: Use an ORM
const tasks = await db.select().from(tasks).where(eq(tasks.status, userInput))
// GOOD: Whitelist allowed values
const ALLOWED_STATUSES = ["pending", "in_progress", "done"]
if (!ALLOWED_STATUSES.includes(userInput)) throw new Error("Invalid status")Limits and deliberate restraint
| Aspect | Behaviour |
|---|---|
| Payload safety | Every payload is read-only. Sleeps, quotes, an echo of a canary, a comparison operator, a read of /etc/passwd, an arithmetic expression. No data is modified and nothing is exfiltrated. |
| Time-based proof | Two independent measurements must both show the delay. One slow response is never a finding. |
| Error-based proof | Error-type and syntax signatures only. Bare library names such as sqlalchemy or psycopg2 are not matched, so a benign IntegrityError: UNIQUE constraint failed is not SQLi. |
| NoSQL proof | Differential: the operator response must be authorized, non-empty, and distinct from and broader than an exact-match control. A parameter that merely echoes [$ne] cannot false-positive. |
| XXE delivery | Raw body on XML or POST endpoints; multipart xxe.xml / xxe.svg only on upload-shaped paths, so ordinary API endpoints are not sent files they never asked for. |
| Adjudication | Runs only with an LLM key. It removes candidates and never adds; --llm none emits the raw heuristic findings unchanged. |
| Bound | Time, via the cooperative budget, with endpoints taken in prioritizer order so the likeliest sinks are reached first. |