Critical SQL injection (SQLi)
At a glance
- Formal class name
- SQL injection, commonly shortened to SQLi: a code-injection flaw in dynamically assembled database queries
- Classification
- OWASP Top 10 A05:2025 Injection, listed as A03:2021 in the previous edition, CWE-89
- Typically reachable from
- Any input that reaches query construction: public catalogue filter, sort and pagination parameters, authenticated search and reporting queries, JSON and API request bodies, headers and cookies that are logged or looked up, bulk import and export paths, and stored values re-read by admin reports or batch jobs
- Authentication required
- Often none, where the injectable parameter sits on a page reachable before login
- Who is affected
- Potentially every customer, order and staff record the application's database account can read, which is commonly all of them where one shared account is used, and is established per engagement by enumerating that account's privileges
- Why severity is Critical
- Rated Critical where unauthenticated bulk read of customer data and credentials is demonstrated, or where a write path or database-host code execution follows. Proven read-only extraction with no integrity impact is rated High
What it is
SQL injection, commonly shortened to SQLi, is a flaw where input from a request is concatenated into a database query instead of being passed as a parameter, so the database parses that input as SQL code and the attacker rewrites the query the application meant to run. It is the best known member of the injection class, catalogued as CWE-89 and sitting under A05:2025 Injection in the OWASP Top 10, where the 2021 edition listed it as A03. It survives in modern codebases for a specific reason: parameter placeholders bind values, not identifiers. A sort column, a table name in a reporting query, a dynamic WHERE clause assembled from a filter panel and a legacy stored procedure that concatenates internally are all places where a developer reaches for string building, and one of those places is usually left behind when the rest of the application is modernised.
Commercially this class matters because of how much it returns per request. An authorisation flaw usually leaks records one at a time and the attacker has to iterate. SQL injection on a single endpoint puts the attacker inside the data layer, where the customer table, the order table, the voucher table and the password hashes are all held in one schema behind one connection. That turns a catalogue page, something the business treats as public marketing surface, into the entry point for a notifiable personal-data breach, a forced reset of every customer password and a card-scheme conversation that the finance team has to lead. The engineering fix is usually small, and the cost of leaving it in place is not.
How the attack unfolds
Mechanism, not a recipe. Reproduction detail stays in client reports, because a page that hands a reader a working attack is a liability.
A filter parameter reaches the SQL parser
A catalogue endpoint accepts a filter, sort or pagination value and builds its query by string concatenation, often because the value looks internal rather than user-supplied. An attacker submits input that is syntactically meaningful to SQL rather than to the product search, and the response changes. Not every change is proof.
A product search for a string full of quotes and boolean operators legitimately matches nothing, so a shift in the result count on its own is a lead rather than a confirmation. What confirms it is one of four stronger signals: a database error that names the engine or quotes the malformed statement, a matched pair of logically equivalent and logically contradictory inputs of the same length and character class that produce different responses when repeated with cache-busting, a delay that scales predictably with an injected value, or a callback from the database host to a server the tester controls. Any of those shows the input is being parsed as code, and none of them requires extracting a single row.
Union-based and blind SQL injection map the query without error messages
Suppressed errors slow an attacker down but do not stop one. Where the page renders the result set, the number and types of selected columns can be inferred from which positions render and which break the response, which is the union-based route. Where the page returns nothing useful, the attacker switches to inference, making the response depend on a true or false condition about the data, called boolean-based blind injection, or on a deliberate delay inside the database, called time-based blind injection.
Both reduce the problem to asking the database a long series of yes or no questions. A third route sends the answer out of band, through a DNS or HTTP request made by the database host itself, which works even where the application returns nothing an attacker can observe.
One database account for everything means the injection sees the whole schema
Applications commonly connect with a single account that has accumulated every permission the application has ever needed, so the injected query inherits read access to tables that have nothing to do with the catalogue. Where the engine exposes its metadata views to that account, tables and columns are listed rather than guessed, and the customer, order, address and credential tables are found by name. How much is visible depends on the engine and the grants: MySQL shows in information_schema only the objects the account holds some privilege on, Oracle has no information_schema at all and enumeration runs through the user and all-objects views on the same privilege-scoped basis, and PostgreSQL exposes object names in pg_catalog to any role while still gating contents.
On the privilege-filtered engines the metadata view maps the boundary of the account's reach, which is evidence worth capturing for the privilege finding. This step is where an injection stops being a catalogue bug and becomes the whole estate, and it is governed by privilege design, not by the vulnerable code.
Bulk extraction rides the page's own response, where the query shape allows it
That route exists only where the injection sits in a SELECT whose result set is rendered and whose column count and types can be matched. Where it does, the attacker places the data they want into fields the catalogue page is already designed to display, so rows of customer records arrive formatted as product results, through the same URL, over normal HTTPS, in the session the application expects. Sort-parameter injection, injection into an aggregate or count-only query, injection inside a stored procedure and injection into an INSERT or UPDATE statement all deny that route and fall back to inference or an out-of-band channel, and the timings then differ sharply.
Union-based, error-based and out-of-band extraction move bulk data in a single request or a handful of them. Blind inference costs several requests for every recovered character, so it reaches targeted high-value rows quickly but recovers a full table only over sustained high-volume traffic, which rate limiting, edge filtering and database query timeouts materially slow and which traffic anomaly detection can catch. At the web tier the request is hard to distinguish from a long search query, which is why detection has to sit at the database and egress layers rather than in the application log.
Whether exfiltrated hashes become working logins is decided by the hashing choice
A stolen password hash is not a password, and how much of that distinction survives depends on how the hashes were produced. Unsalted MD5 or SHA-1 falls to offline cracking at commodity hardware rates, and a table stored that way should be treated as cleartext. A memory-hard or cost-hard function such as Argon2id, scrypt or bcrypt, tuned low enough to keep login fast, narrows the attacker's margin rather than removing it, so the weak and reused end of the password distribution is recovered while strong passwords stay intact, which is still enough to take over a meaningful share of accounts.
Recovered passwords are then tried against the storefront, the staff login and unrelated services where customers reused them. Password reset and session token tables are a second route, and whether reading one is an immediate takeover depends on whether the token is stored in recoverable form rather than hashed, is unexpired, is unused where the flow is single use, is not bound to the originating session or device, and completes without a second factor or mailbox possession. Where those conditions hold, a token that can be read can simply be used.
Where any of them fails, this is a takeover route contingent on token-lifecycle weakness, which is a separate finding to test and report rather than an automatic consequence of the read.
Where a write vector exists, the attacker changes money, not just data
Writing needs two things at once, and the privilege on its own is not enough. Inside an injected SELECT no UPDATE can be issued, so the attacker needs the grant and a delivery mechanism: an injection point that already sits inside an INSERT, UPDATE or DELETE statement, a driver or API that permits more than one statement per call, or a stored procedure or function invoked from the injected query that performs the write itself. The engine and driver decide this more than the application does.
PHP's mysqli and PDO against MySQL in single-query mode block stacked statements, so a MySQL SELECT injection is usually read-only, while stacking is commonly available on SQL Server and PostgreSQL. Where both halves are present the attacker stops reading and starts editing: order totals, discount and voucher balances, payment status flags, and in some configurations a new privileged row in the staff table. On some engine and privilege combinations the same primitive extends to reading or writing files on the database host or loading a library, which escalates to code execution there.
Tampering done this way lands through the application's own database account, so it appears in the order table as ordinary business activity and tends to surface in financial reconciliation rather than in the security monitoring stack. Where no write vector is demonstrated, the finding is a read-only exposure and is rated as one.
Business impact
A retail database concentrates the entire customer relationship in one schema, which is what makes a single successful read so expensive. Names, email addresses, phone numbers, delivery and billing addresses, full order history, loyalty balances, voucher and gift-card codes, stored payment tokens with card expiry and last four digits, password hashes and the internal staff account table are typically all reachable from the same query context. The fraud value is immediate. Voucher and gift-card balances are monetisable without ever touching a card number, and order history combined with a delivery address gives an attacker enough accurate detail to pass as the customer when calling the support desk for a refund or a redelivery. The credential half of the same dataset feeds account takeover against the storefront and against every other service where those customers reused the password.
The direct financial consequences arrive from several directions at once. Forensic investigation and legal advice are immediate and unbudgeted, a forced password reset across the whole account base generates support volume and measurable drop-off at the next login, and refund, voucher and chargeback fraud continues for as long as the stolen data stays useful, which for a postal address is years. Where the injectable query runs with write access, the loss is quieter and larger: orders marked paid that were never paid, discounts applied outside any campaign, and balance adjustments that reconcile to nothing. Because those writes land through the application's own database account, they look like legitimate transactions, so the first reliable signal is usually a finance variance rather than an alert.
The regulatory position is the part that removes the organisation's discretion. Under the UK and EU GDPR a personal-data breach must be reported to the supervisory authority within 72 hours of the organisation becoming aware of it, with notification to affected individuals where the risk to them is high, and exfiltration of contact details, delivery addresses, order history and password hashes normally clears that bar. Comparable breach notification duties exist under the data protection laws of the UAE, Saudi Arabia and Qatar, with different triggers and deadlines in each. PCI DSS names injection explicitly in its secure software development requirements, requires penetration testing at least once every 12 months, and separately requires public-facing web applications to be protected by an automated technical solution that continually detects and prevents web-based attacks, so an injectable parameter is simultaneously an incident and a control failure at the next assessment. Operationally, the hardest question is scope. Extraction that rides normal catalogue requests leaves an application log showing product searches, so unless database query logging was already enabled and retained, there is no evidence to support a narrow blast radius, and the organisation ends up notifying every customer because it cannot demonstrate that fewer were affected.
How it is found
| Signal | How it is confirmed |
|---|---|
| Every value the application passes to the database, not only the search box | Filters, sort keys, pagination offsets, hidden form fields, JSON and API request bodies, cookies, and headers that get logged or looked up are all submitted with input that is meaningful to SQL. Confirmation needs more than a response that changed, because a literal search for a string full of quotes and operators legitimately returns nothing. We confirm a parameter with a matched pair of logically equivalent and logically contradictory inputs that produce different responses, repeated with cache-busting so that caching, filtering and network jitter are excluded, which proves the value is parsed rather than compared. |
| Why parameterised code still leaves sort columns and ORDER BY injectable | Sort columns, ORDER BY direction keywords and dynamic table, schema or column names cannot be bound as parameters at all, so they stay as string building even in code that is otherwise fully parameterised. LIMIT and OFFSET are a separate case. Current MySQL, PostgreSQL and SQL Server all accept placeholders there, and the historic failure was a driver configuration artefact under emulated prepared statements rather than a limit of binding, but those values are still found concatenated in legacy code, so we probe them as a matter of habit. We work these positions by column index and ordering behaviour, and a source code review finds them directly by tracing request input into query-building strings. |
| How do you confirm blind SQL injection when the application returns no errors? | Absence of database errors is not absence of injection. We confirm inference by repeating a conditional response difference, or a deliberate database delay that scales with an injected value, across several attempts so that caching and network jitter are ruled out. Where no usable response channel exists at all, an out-of-band callback from the database host to a server we control proves execution rather than suggesting it. |
| Second-order SQL injection through stored input | Input that is safely parameterised when written can be concatenated when read back by a different component, such as an admin report, a CSV export, a search index rebuild or a scheduled job. We find this by writing a benign marker in one feature and watching for it to break a query or trigger a callback elsewhere, which is why reporting, export and batch paths belong in scope and not only the request path a user sees. |
| How far does the injection reach beyond the catalogue? | An injection point is only half the finding. The blast radius is set by the privileges behind it, so once injection is proven we establish the current database user, which schemas and tables it can read, and whether it holds write, DDL or file privileges, so the report states what an attacker would obtain rather than describing a generic injection flaw. Attacker capability and tester proof are kept apart here. We demonstrate capability with non-sensitive evidence, meaning the current database user and version, privilege and schema enumeration, row counts, and at most a single consented or synthetic record, with bulk extraction described rather than performed, and any sensitive output encountered is minimised and handled under the engagement's data-handling terms. The severity rating rests on the privileges proven, not on the volume of data removed. |
How it is fixed
| Control | What makes it hold |
|---|---|
| How do you prevent SQL injection? Parameterised queries, everywhere | SQL injection is prevented by prepared statements with bound parameters, because the query plan is fixed before the data arrives, so input can never be parsed as syntax. What makes it hold is removing the alternative: no string-building helper left in the data layer, raw query escape hatches in the ORM restricted and reviewed, and dynamic SQL inside stored procedures treated as application code, because a procedure that concatenates internally is injectable no matter how it is called. The requirement covers every consumer of stored data and not only the request path, so admin reports, CSV exports, search index rebuilds, scheduled jobs and analytics queries are in scope for the same review. One boundary is worth stating plainly: parameterisation closes value positions only, so it is sufficient only alongside the allow-list control for identifier positions below. |
| Map sort columns and table names from a fixed allow-list, do not escape them | Sort columns, table and column names and direction keywords must be mapped from a closed set of permitted values to the literal identifier used in the query, rather than escaped and interpolated. The control holds when the mapping is a lookup that rejects anything unrecognised, rather than a character filter, because filters are defeated by encoding and comment syntax while a closed set has nothing to bypass. |
| Least privilege on the database account bounds the blast radius | Each service should connect with its own account, restricted to the tables it needs, with no DDL rights, no file or library privileges and no cross-schema or cross-tenant read, so an injection that survives review returns one dataset instead of the estate. One limit is worth naming: on some engines object names remain discoverable by any role regardless of grants, so the control bounds what can be read rather than what can be seen. This is what converts a potential Critical into a contained issue, and it is verified by connecting as the application account and confirming that cross-schema and credential-table reads are refused. |
| Credential storage that survives exfiltration | Passwords stored with Argon2id, scrypt or bcrypt at a cost tuned to current hardware mean a leaked table is not a set of working logins, which breaks the chain from data exposure to account takeover. Reset and session tokens need the same treatment, stored hashed with short expiry and single use, because a readable token is a takeover that needs no cracking. |
| Detection has to sit at the database, because the web log will not show it | Statement-level audit logging for the application's database account, alerting on access to metadata views and to credential, token and payment tables by that account, and response-size or egress anomaly detection together reduce dwell time and establish breach scope. None of it prevents the injection, and that is the point. When extraction arrives as ordinary search traffic, this is the only evidence that will later support notifying a defined set of customers rather than all of them. Logging and alerting failures are their own category in the OWASP Top 10, at A09:2025. |
| Containment, required wherever bulk read was reachable | Fixing the query does not revoke data already taken, so wherever bulk read was reachable, and not only where exfiltration was observed, the response includes forcing a password reset across affected accounts, invalidating active sessions and outstanding reset tokens, and rotating any API keys, integration credentials or signing secrets held in the database. Application and database logs are preserved first, because they are the input to the breach-notification assessment and cleanup can destroy them. |
| Treat a WAF as time bought, not as the fix | Virtual patching at the edge is the right first move during an incident, and it is not remediation. Signature filters are routinely bypassed through encoding, comment insertion, case and whitespace variation and request fragmentation, so the code change still has to ship. The verification step is a retest of the specific parameter with the filter bypassed, plus a source code review of the data flow that produced it, not a clean scanner run. |
Questions
What can an attacker do with SQL injection?
SQL injection lets an attacker read any data the application's database account can see, which in practice means whole tables rather than single records: customers, addresses, order history, vouchers and password hashes. Writing needs more than a privilege, because an injected SELECT cannot issue an UPDATE on its own, but where the injection sits in a statement that already writes, or the driver allows more than one statement per call, the attacker can alter prices, payment status, balances and user roles. On some engine and privilege combinations the same access extends to code execution on the database host.
How do you test for SQL injection?
Testing for SQL injection means submitting input that is syntactically meaningful to SQL into every value the application passes to the database, not just the search box, then looking for a response difference that only a parsed query could explain. Scanners find the loud cases. The quiet ones, meaning blind inference, second-order input read back by a report or a scheduled job, and positions that cannot be bound as parameters, need a tester and usually a look at the query-building code.
Does a web application firewall stop SQL injection?
No. A web application firewall blocks opportunistic attempts and it is the right first move during an incident, but the flaw stays in the code and the rules only match patterns in a request, which are routinely evaded with encoding and comment syntax. PCI DSS separately expects an automated technical solution in front of public-facing web applications, so having one is a control in its own right and not evidence the injection is fixed. Verification is a retest of the specific parameter with the filter bypassed, plus a review of the data flow behind it.
Do ORMs prevent SQL injection?
An ORM used through its normal query interface parameterises for you and removes most of the risk, which is why remaining injection tends to live in the exceptions: raw query methods, interpolated fragments passed into a query builder, native queries added for performance, dynamic sort and column names that cannot be bound, and stored procedures that concatenate internally.
Is blind SQL injection less serious than error-based SQL injection?
No. Blind injection only means the attacker gets no helpful error text, so extraction runs on inference instead and is slower, not smaller. The same tables are reachable and the process is automated, so the difference is in how much request traffic extraction takes rather than in what is finally exposed. Severity should be set by what the database account can read and write, not by how talkative the application is.
Can a sort column or pagination parameter be SQL injectable?
Yes. Parameter placeholders bind values, not identifiers, so a sort column, an ORDER BY direction or a dynamic table name has to be built as text, which is why these positions stay injectable in code that is otherwise fully parameterised. Pagination is a slightly different case, because current database engines do accept placeholders in LIMIT and OFFSET, and those values are still often concatenated in older code. The same pattern runs through catalogue filter and facet parameters, region selectors, voucher and tracking lookups, the reporting screens behind the staff login, and scheduled jobs that read stored input back.
How would we know if SQL injection had already been used against us?
Evidence of past SQL injection usually comes from the database side rather than the application side, because extraction arrives as ordinary catalogue requests over HTTPS in a normal session. The useful evidence is database query and audit logs, unusual result-set sizes or query durations from the application account, reads of metadata views, and unexplained variance in order totals, voucher balances or payment status flags found during reconciliation.