An unauthenticated SQL injection in Craft's search box became full admin takeover — here's how
Some advisories are interesting because of what they expose about a system's design; this one's interesting because the bug itself is a textbook case worth teaching from. GHSA-qcvp-ggx3-97r6 (CWE-89, High severity, published 22 October) affects Craft 4.0.0 up to before 4.18.9 and 5.0.0 up to before 5.11.2 — patched in both lines simultaneously, which is itself a signal of how seriously it was treated.
The mechanism
Craft's element search endpoint concatenates an attribute parameter directly into a SQL query without sanitizing it. On its own, string concatenation into a query is a familiar, bad-but-contained class of bug — usually you can inject into a single clause and nothing more. What makes this one High severity rather than a narrower information leak is that Craft's database connection runs with PDO's emulated prepares enabled. Emulated prepares build the full query string client-side before sending it, rather than sending the statement and parameters separately for the server to combine — and critically, that client-side string can contain multiple statements separated by semicolons, and the server will execute all of them. That's a stacked query. The advisory confirms unauthenticated read and write access to the database as a direct result — not just "read one extra row," but arbitrary additional statements.
The demonstrated impact is about as concrete as a proof-of-concept gets: the reporter used the write primitive to set the admin flag on user ID 1, then confirmed the change by reading it back. Craft's permission checks short-circuit on that column, so flipping it was the entire privilege escalation — no further steps needed. The same write capability could just as easily overwrite any account's password hash. No CVE has been assigned as of this writing, but the GHSA record alone is enough to treat this as the real thing: unauthenticated, database-wide read/write, demonstrated full admin takeover, one endpoint.
Why emulated prepares specifically matter here
This isn't a Craft-specific footgun — PDO's emulated-prepares mode has bitten other PHP frameworks for the same reason, and it's enabled by default in a lot of setups because it's historically been faster and more compatible across different MySQL/MariaDB configurations. The tradeoff that default quietly makes is: if unsanitized input ever reaches a query string before it's sent, emulated prepares turn a single-clause injection into a stacked-statement injection. True prepared statements, sent to the server as statement-plus-parameters rather than pre-built as one string, don't have that failure mode — a parameter is a parameter, not a place a semicolon can smuggle a second statement through.
The Drupal comparison I keep coming back to
This is exactly the caution Drupal bakes into its own conventions: prefer \Drupal::entityQuery() over raw SQL, and when raw SQL is genuinely necessary, use the database API's parameter binding rather than building a string by hand. It's not that Drupal developers are smarter about SQL injection — it's that the framework makes the safe path the path of least resistance, so a single endpoint doing manual string concatenation into a query is the exception you'd have to go out of your way to write, not a pattern that exists in a shared utility everything else reuses. Craft's bug lived in exactly the kind of shared, broadly-reachable endpoint that discipline is meant to keep narrow-surfaced. It's a good reminder that the actual security property isn't "we know SQL injection is bad" — everyone knows that — it's whether your framework's own conventions make the dangerous pattern rare enough that one unsanitized parameter can't take down the whole install.