Injection

SQL injection didn't die, it moved

The industry has spent twenty years declaring this bug solved. The largest data theft campaign of 2023 started with it.

On 31 May 2023, Progress Software disclosed a SQL injection vulnerability in MOVEit Transfer — a managed file transfer product used by banks, hospitals, universities, payroll processors and government departments precisely because it was the careful way to move sensitive files about. Attackers had been exploiting it since at least 27 May. It was a zero-day, it was rated CVSS 9.8, and the campaign that followed reached thousands of organisations and tens of millions of individuals, mostly through third parties the victims had never heard of.

The interesting part, for anyone who builds or buys web applications, is how unremarkable the underlying flaw was. Not a novel cryptographic weakness, not a memory corruption chain. A value that arrived from the internet was allowed to help compose a database query. That is the first vulnerability class most developers ever learn about, and it was still, in 2023, the way into some of the best-defended organisations in the world.

CVE
CVE-2023-34362
Product
Progress MOVEit Transfer before 2021.0.6 (13.0.6), 2021.1.4 (13.1.4), 2022.0.4 (14.0.4), 2022.1.5 (14.1.5) and 2023.0.1 (15.0.1). MOVEit Cloud was also affected and was patched by Progress.
Class
SQL injection (CWE-89)
Severity
CVSS 9.8 (Critical)
Disclosed
31 May 2023, with exploitation observed from at least 27 May — a zero-day. Attributed to the Cl0p extortion group, subject of a joint CISA and FBI advisory, and added to CISA's Known Exploited Vulnerabilities catalogue.
Impact
Unauthenticated access to the application's database, escalated to deploying a web shell on the server and stealing the files the product existed to transfer

The root cause, in one sentence

Data that came from the network was treated as part of the query's structure rather than as a value inside it.

That distinction is the whole of SQL injection, and it is why parameterised queries work. A placeholder tells the database engine, in advance, the shape of the statement it is about to run; the value arrives afterwards and can only ever be a value. String concatenation reverses that order — the query's structure is not decided until the attacker's text has already been pasted into it. Every mitigation that is not "separate the structure from the data" is an attempt to guess which characters a parser will find meaningful, and that guess has been losing for two decades.

No payloads appear here. As in the other posts in this series, the mechanism is described to the depth a defender needs and no further. If you run MOVEit Transfer, work from Progress's advisory and the CISA and FBI indicators rather than this page. And note the order of events in that campaign: a database bug became a web shell, which means patching was never the whole remediation.

The five shapes it takes

Almost every team we test has an ORM, and almost every team believes the ORM has settled this. It mostly has — which is the problem. Injection no longer lives in the login form on page one. It lives in the handful of queries the ORM did not write, and nobody can tell you which those are from memory.

1. The query the ORM didn't write

Every ORM has an escape hatch for the query that was too awkward to express: a raw-SQL method, a hand-built fragment passed to a query builder, a stored procedure invoked with a concatenated argument list. These are written by the most experienced person on the team, on the hardest problem of the sprint, under time pressure — and they sit in a codebase where hundreds of other queries are genuinely safe, which is exactly the context in which nobody looks twice. In our experience the number of raw queries in a mature application is small enough to audit in an afternoon, and nobody has.

2. The positions a placeholder cannot bind

Placeholders bind values. They do not bind identifiers — table names, column names, sort direction, and in many drivers the operands of ORDER BY or LIMIT. So a feature as ordinary as "let the user sort this table by any column" cannot be written with parameters alone, and the path of least resistance is to interpolate the column name the browser sent. Sortable grids, configurable report columns, dynamic filter builders and "advanced search" screens are therefore the highest-yield places we look, because the safe pattern requires an allowlist and the unsafe one requires nothing.

3. Second-order injection

Input arrives, is stored correctly through a parameterised insert, and is injected days later by something else entirely — a nightly report, a CSV export, an admin screen, a data migration — that builds a query from the stored value. The second piece of code is not careless so much as mistaken about provenance: it believes it is reading from the database, which is trusted, when it is really reading attacker-controlled text that the database merely held for a while. This is the shape automated scanners miss most reliably, because the injection and its effect are separated by a schedule.

4. Filter languages that compile to SQL

Modern APIs love expressive filtering: a JSON object of field, operator and value; a search string with its own small grammar; a GraphQL where argument; an OData-style query parameter. Each is a miniature language whose compiler is a function in your codebase, and whose job is to turn client input into SQL. If the operator or field names are taken from the request and used to build the statement rather than looked up in a fixed map, the API has handed the client a query interface. The documentation usually calls this flexibility.

5. Reporting, export and internal tooling

The highest-impact injection we find is rarely in the product. It is in the analytics screen, the invoice export, the scheduled statement generator, the back-office search, the admin page built for twelve internal users. That code is written quickly, reviewed lightly, excluded from the test scope because "it's internal", and connects to the database as an account with far more rights than the application's own. It is also, increasingly, the code an AI assistant wrote on a Friday afternoon. One injectable parameter there is worth ten anywhere else.

Two things that are not fixes. Suppressing database errors removes the most convenient way to find injection, not the injection itself — boolean and time-based techniques need no error message, and the attacker is patient. A WAF blocks the payloads someone has written a signature for; the entire study of this field is the generation of payloads nobody has written a signature for yet. Both are reasonable layers. Neither is the control, and the MOVEit campaign ran against organisations that had both.

How we test for it

  1. Enumerate every input, not every page. Query and body parameters, but also JSON keys at every level of nesting, headers the application reads, cookie values, multipart filenames and content types, and anything the front end sends that the developer never expected a human to edit. The count is usually several times what the client estimates.
  2. Establish what a correct response looks like, then look for differences. We record the baseline for a legitimate value and compare it against values that are syntactically meaningful to a SQL parser but semantically equivalent. A difference in response — content, length, status, or timing — is the signal. This is why we test by differential rather than by error message.
  3. Go after the unbindable positions first. Sort columns, direction, pagination bounds, grouping fields, report column selectors, filter field names. The developer could not have used a placeholder here, so the only question is whether they wrote the allowlist.
  4. Test blind and out-of-band where the response says nothing. Boolean-differential and timing-based techniques for inference, and out-of-band interactions where the database can be made to reach outward. Timing tests are rate-limited and agreed in advance in the rules of engagement, because an unbounded one is a denial of service against a production database.
  5. Chase second-order deliberately. We place distinctive markers through every input that is stored, then look for them where stored data is later re-queried: exports, reports, admin views, search indexes, anything scheduled. This is a planned pass, not something you stumble into.
  6. Establish reach, then stop. Where injection is confirmed we determine what it grants — the rights of the database account, whether it can read other schemas, write files or execute commands — because the difference between "one table is readable" and "the database account can write to disk" is the difference between a high and a critical. We demonstrate that reach; we do not exfiltrate your data to prove a point, and the agreed limit is written down before testing starts.

Each finding is written up with the request, the evidence that distinguishes it from a false positive, and the specific line-level change that fixes it — not a generic "use prepared statements". That is the standard described in what's in a penetration test report.

Six things to check in your own application this week

  • Search your codebase for your ORM's raw-query methods and string-built SQL, and read every result. This is a finite list, it is shorter than you fear, and it is where your injection is.
  • Find every place a column name or sort direction comes from the request, and resolve it through a fixed map of permitted values. Rejecting anything not in the map is the control; escaping it is not.
  • Give the application a least-privilege database account — no schema changes, no filesystem or shell access, no reach into other databases. This is what decides whether an injection is a bad day or a breach notification, and MOVEit is the case study.
  • Treat your own database as untrusted input anywhere a stored value is used to build a query. Parameterise the report generator exactly as carefully as the sign-up form.
  • Put your reporting, export and admin tooling inside the test scope. It is excluded by habit and it is where we find the serious ones.
  • If error messages were suppressed and the ticket was closed, reopen it. Hiding the symptom moved the bug out of your logs, not out of your application.

Injection, access control and business logic are where the findings that matter actually live, and all three need someone reasoning about your application rather than matching it against a signature list. That is the gap our testing is built around — fixed price, agreed before we start, with the retest included.

Build your quote

Which of your queries weren't written by the ORM?

If nobody can answer that from memory, that is the test. Fixed price, retest included.