20 min read
Critical
By

Drupal CVE-2026-9082 as a parser-differential class — what the 20/22 May SQLi wave tells TYPO3 operators

24 May 2026. Drupal published SA-CORE-2026-004 on 20 May (CVE-2026-9082, Highly Critical 20/25) — an unauthenticated SQL injection in the core login endpoint that only affects PostgreSQL backends; CISA added it to the KEV catalog on 22 May with a remediation due date of 27 May, and Searchlight Cyber reports „thousands of attacks within 48 hours“ (Drupal SA-CORE-2026-004, CISA KEV).

We don't run Drupal — but the flaw belongs to the same class as the SymfonyRuntime patch bypass from 20 May (CVE-2026-46626): a parser differential between two layers that appear to parse the same input.

This post dissects the cause, sorts it as a pattern, and carries the audit reflex over to the TYPO3 / Doctrine DBAL stack.

Aufsicht-Stillleben auf matt-dunkler Schieferflaeche: zwei bruenierte messingfarbene Petschafte nebeneinander, darunter zwei cremefarbene Probe-Karten mit fast identischen Abdrucken — an einer winzigen abweichenden Stelle des linken Abdrucks beginnt ein einzelner Tropfen Oxblood-Tinte sich in die Papierfaser zu ziehen, die einzige gesaettigte Farbe im Bild. Links Auditor-Hauptbuch, oben rechts Messing-Lupe im Negativraum.
AI-generated · gpt-image 2.0

TL;DR — the 90-second summary

QuestionAnswer
Affected?Drupal core 8.9.0 – <10.4.10, 10.5.0 – <10.5.10, 10.6.0 – <10.6.9, 11.0.0 – <11.1.10, 11.2.0 – <11.2.12, 11.3.0 – <11.3.10only on PostgreSQL backends. MySQL, MariaDB, and SQLite Drupal installations are not affected. End-of-life lines Drupal 8.9 and 9 have best-effort patches.
Risk?Unauthenticated SQL injection through the JSON login endpoint /user/login?_format=json (enabled by default in the core user module). No session, no CSRF token, no auth state required. Escalation to RCE via PostgreSQL functions (e.g. COPY FROM PROGRAM with appropriate DB privileges, or platform-specific privilege escalation). Actively exploited in the wild since 22 May 2026, PoC exploit published.
Immediate action?Patch Drupal core to 11.3.10 / 11.2.12 / 11.1.10 / 10.6.9 / 10.5.10 / 10.4.10composer update drupal/core-recommended. If patching is not possible in the current window: block the JSON format endpoint at the reverse proxy (reject ?_format=json on /user/login) or temporarily disable the JSON API module. Review logs for POST bodies on /user/login with an object-shaped name field. We don't run Drupal — the immediate-action path applies to third parties that operate Drupal-PostgreSQL setups.
Recommendation?Drupal operators: patch within the next 24 hours — the CISA KEV due date is 27 May 2026. TYPO3 operators: this post is not a patch directive, it's an audit trigger. Review third-party extensions in your TYPO3 tree for user paths that pass associatively structured JSON input into QueryBuilder ->where() calls or dynamically composed expr()->in(…) conditions.
Criticality?See hero badge — critical. Active in-the-wild exploitation, CISA KEV listing within 48 hours, PoC exploit published, unauthenticated through a default-enabled endpoint. For Drupal-PostgreSQL setups this is the kind of CVE that doesn't wait for the next maintenance window.

What's the problem? — three independent design decisions that collapse on top of each other

The flaw doesn't arise in one place. It arises in the interplay of three design decisions, each of which is sensible on its own and would pass any Drupal code review. Only the overlay of all three in the same request produces the unauthenticated DB access.

First contribution — Symfony JsonEncoder hands out associative arrays

The JSON login endpoint /user/login?_format=json is part of the Drupal core user module and is enabled by default in every installation. It parses the request body via Symfony's JsonEncoder. The encoder correctly decodes JSON objects into associative PHP arrays — that's not the bug, it's the documented expected behaviour. If you send the name field as a JSON string ({"name": "alice"}), the controller receives a string. If you send it as a JSON object ({"name": {"x": "1"}}), the controller receives an associative array. The Drupal login controller passes whatever it gets, without form-type validation, on to the entity query — because Drupal's entity query can interpret arrays as multi-value conditions (IN (…) clauses) and is therefore a legitimate input type. From the controller's perspective, name is polymorphic: string or array, both are permitted.

Second contribution — the implicit trust assumption in the DB abstraction layer

Drupal's DB API works with named placeholders (:name) and an auto-expand mechanism for array conditions (:name[]:name_0, :name_1, :name_2, …). The expand logic iterates over the array, builds a cleanly parameterised placeholder for each value — and constructs the placeholder name from the array key. For numerically indexed arrays ([0 => 'a', 1 => 'b']) the keys are 0, 1, …, the placeholders read :name_0, :name_1, …, everything flows cleanly through PDO.

This is where the Drupal core authors' unspoken assumption sits: values are untrusted, keys are trusted, because keys are always machine-generated indexes from the framework's perspective. This assumption holds for every internal call site of the DB layer; it does not hold once a user path passes an associative array form through to placeholder construction. That's the actual weakness — not a forgotten escape, not a missing cast, but an assumption about the shape of the data that is nowhere reified as a constraint in code.

Third contribution — the PostgreSQL-specific emission path

This is the reason the flaw is PG-only and not Drupal-wide. Drupal's DB API has per-backend driver overrides. The pgsql driver has its own placeholder emission because PostgreSQL behaves differently from MySQL and SQLite around type-cast hints, standard_conforming_strings handling, and named-vs-positional mapping. In the PG driver, the attacker-controlled key reached a code path where it was interpolated raw into the SQL string before the PDO layer parameterised the values. On MySQL and SQLite the equivalent path either dropped the key or interpolated it at a SQL position that didn't leave the placeholder context — and therefore didn't turn into SQLi.

Three conditions, one bypass. You need all three: an entry point that hands the controller an associative array (JSON endpoint with an object-shaped field); an abstraction-layer assumption that the keys are trusted (universal in Drupal core); and a backend driver that emits the key into the SQL string (only PG). Once the three line up, an anonymous attacker has SQL injection on a login route that was explicitly built for anonymous requests.

The patch and what it doesn't fix

Drupal's fix in 11.3.10 / 11.2.12 / 11.1.10 / 10.6.9 / 10.5.10 / 10.4.10 is a one-liner: array_values() on the incoming condition array. That strips the attacker-controlled keys and replaces them with numeric indexes — the DB layer's implicit assumption is restored on the caller side. It's a defensible 24-hour patch and it closes the exploitable spot completely.

What it doesn't fix: the assumption „keys are trusted“ still lives in the DB layer and is nowhere reified in the Drupal coding standard. The PG-vs-MySQL/SQLite divergence in the placeholder emit path remains. And the question of whether other call sites exist where a user path passes an associative array shape through to placeholder construction is not answered by the patch — it's only pragmatically suppressed at this one site. The Drupal Security Team's reflex „patch first, harden later“ is not wrong at this pace; it leaves an audit debt in the codebase, though, and Drupal as an open-source project will look at it in the coming months.

Who is affected?

AffectedNot affectedCondition
Drupal core 8.9.0 – <10.4.10≥ 10.4.10+ PostgreSQL backend
Drupal core 10.5.0 – <10.5.10≥ 10.5.10+ PostgreSQL backend
Drupal core 10.6.0 – <10.6.9≥ 10.6.9+ PostgreSQL backend
Drupal core 11.0.0 – <11.1.10≥ 11.1.10+ PostgreSQL backend
Drupal core 11.2.0 – <11.2.12≥ 11.2.12+ PostgreSQL backend
Drupal core 11.3.0 – <11.3.10≥ 11.3.10+ PostgreSQL backend
End-of-life Drupal 8.9 / 9 (PostgreSQL backend)Best-effort patches, no long-term support
Drupal core on MySQL / MariaDB / SQLiteNot covered by the backend driver override — not exploitable through the known path combination

Outside direct Drupal exposure, but stylistically part of the class:

Impact

We don't sort by CVSS numbers (Drupal awarded the 20/25 score via their own risk scale, NVD runs CVSS 6.5, the operational severity sits considerably above that), we sort by observed escalation depth.

Escalation depth 1 — information disclosure. An anonymous attacker can read arbitrary table contents via the SQLi: user hashes, session tokens, configuration values, arbitrary custom-entity data. For Drupal platforms that hold personal data in custom entities or fields, this is a complete data exfiltration from the DB layer.

Escalation depth 2 — privilege escalation. Via INSERT/UPDATE on users_field_data and the Drupal permissions tables, the attacker can create their own accounts or equip existing accounts with privileged roles. Once an admin account is controlled, the attack has migrated from the SQLi layer into the application layer.

Escalation depth 3 — remote code execution via PostgreSQL. PostgreSQL provides direct OS command invocation through COPY FROM PROGRAM, provided the DB user has the necessary privileges (typical for self-hosted PG installations, less typical for managed services like RDS, Aiven, Hetzner Managed PG). On top of that, lo_import / lo_export give file-system access, and CREATE EXTENSION opens further RCE paths. Searchlight Cyber demonstrates an end-to-end path to OS shell in their published analysis. Akamai confirms active exploit attempts with RCE payloads in their own telemetry.

Escalation depth 4 — lateral movement in the cloud. Once the OS layer is reached, IAM roles, cloud metadata endpoints, and potentially SSM / cloud-init paths sit in the same trust zone. If you operate a Drupal installation on an EC2 instance whose IAM role has access to S3 buckets or Secrets Manager, you lose the cloud credentials at the moment the SQLi escalates to RCE.

The bracket around all four: operational severity is not set by the SQLi itself, it's set by what hangs below the DB layer. A Hetzner Managed PG installation behind a Drupal instance with moderate DB user privileges probably ends at depth 2. A self-hosted PG instance on an EC2 host with broad IAM roles ends at depth 4. The CVSS 6.5 number is an average modelling; in practice the distribution sits at both extremes.

Mitigation / immediate action

We keep the immediate-action block for Drupal operators short — the patch is trivial, the recommendation is immediate. The emphasis is on the next section („What we actually did“).

Path 1 — full patch (recommended)

 

# Pull the patch
composer update drupal/core-recommended drupal/core --with-all-dependencies

# Verify the version
composer show drupal/core
# Expected: 10.4.10 | 10.5.10 | 10.6.9 | 11.1.10 | 11.2.12 | 11.3.10 — matching your line

# Apply database updates
drush updatedb
drush cache:rebuild

 

If you have Composer audits integrated, you'll see the block immediately. If you don't, install now:

 

composer require --dev roave/security-advisories:dev-latest

 

Path 2 — stopgap, if patching is not possible in the current window

Four edge-level mitigations, cumulative — none replaces the patch:

1. Block the JSON format on the login endpoint. Reject the _format=json query parameter on /user/login at the reverse proxy. nginx:

 

location = /user/login {
    if ($arg__format = "json") {
        return 403;
    }
    # ... usual Drupal backend chain
}

 

Caddy:

 

@drupal_login_json {
    path /user/login
    query _format=json
}
respond @drupal_login_json 403

 

2. Temporarily disable the JSON API module, if it's not used in production:

 

drush pm:uninstall jsonapi

 

3. WAF rule on POST bodies to /user/login with an object-shaped name field. Suricata rule sketch (not complete):

 

alert http any any -> $HTTP_SERVERS any (msg:"Drupal CVE-2026-9082 JSON name-object";
    http.uri; content:"/user/login"; http.method; content:"POST";
    http.request_body; content:"\"name\":{"; sid:2026908201;)

 

4. Constrain PostgreSQL user privileges hard. If the Drupal DB user doesn't actually need the pg_execute_server_program role or superuser privilege, the escalation to RCE via COPY FROM PROGRAM is closed. This is the default for managed PG services anyway, but for self-hosted PG installations it's an audit step.

Path 3 — detection: identify active exploit attempts

 

# Scan the nginx access log for JSON login POSTs
grep -E 'POST /user/login.*_format=json' /var/log/nginx/access.log \
  | awk '{print $1, $4, $7}' \
  | sort | uniq -c | sort -nr | head -20

# Review the Drupal watchdog log for DB errors on the entity query
drush watchdog:show --type=database --severity=error --count=200

# Scan the PostgreSQL slow query log for unusual condition structures
grep -E "FROM users_field_data WHERE.*name" /var/log/postgresql/postgresql-*.log \
  | head -50

 

If you run Falco / Tetragon / eBPF monitoring, you'll additionally see whether unusual outbound connections or shell-exec paths arise from the PHP-FPM process — the escalation-depth-3 signal.

Detection / verification

This section is provided as a standalone block in the classic CVE template (element 9 per section 3 of the editorial guide). We fold it together with the „Path 3“ block above — the detection notes sit technically cleaner in the context of mitigation. The explicit detection block for platform operators:

Composer state and audit:

 

composer audit --format=json | jq '.advisories[] | select(.package == "drupal/core")'

 

Watchdog inspection via Drush:

 

drush watchdog:show --type=user --severity=warning --count=500 \
  | grep -iE 'login|json|format'

 

Pre-patch forensics: If you operated the platform publicly before the patch and no WAF sat in front, retroactively scan access logs from the last two weeks for POSTs to /user/login?_format=json and preserve the request bodies, provided your log format includes them. On hits: full audit of the Drupal DB user's privileges, audit of all admin accounts for new entries, audit of modules for subsequently installed extensions.

Operator recommendation

Operational decision block

Before the sub-scenarios, the if/then reading order:

German Mittelstand

For the few Drupal-PostgreSQL setups in the German Mittelstand: 24 hours is the operational window, not the maintenance window. If you run monthly Composer updates, this wave is the trigger to move to biweekly — the Glasswing waves are not getting smaller.

Enterprise

Same patch path, plus an audit of the DB user privileges. If the Drupal DB user holds more privileges than actually required for operation (typical: superuser default from the initial setup, never rolled back), escalation depth 3 (RCE via PostgreSQL) is operationally open. Least-privilege hardening of the DB user is the structural consequence of this CVE — independently of whether the concrete patch is in place.

Kubernetes / container platforms

Rebuild Drupal images as soon as the Composer update is incorporated into the build layer. If you run Wolfi-OS / Chainguard PHP images, you don't see the patch in the base image — it sits in the app layer (Composer tree). Trigger a new multi-stage build, push a new image tag, roll the pods. Container restart is mandatory because Drupal otherwise continues running with the old codebase via container-reuse optimisations (e.g. OPCache, Twig compiled cache).

Declarative stacks (NixOS / Talos / Flatcar)

Pull the nixpkgs pin to the Drupal patch tag, or fix the composer update in the build layer. Talos and Flatcar: patches sit in the app image, not in the OS layer; the rollout follows the app update pipeline, not the OS update window.

What we actually did

We don't run Drupal in production customer platforms — so no patch train at our end for this CVE. What we did concerns our own codebase and the third-party extensions in the TYPO3 trees we operate.

The real audit reflex sits in the carry-over to the TYPO3 / Doctrine DBAL stack. TYPO3 uses Doctrine DBAL rather than its own DB API. Doctrine has no auto-expand on arrays — if you want an IN condition, you have to call Connection::executeQuery() with an explicit value array and either enumerate the placeholders yourself or set the Connection::PARAM_*_ARRAY type hint. That makes the exact Drupal flaw non-portable. The pattern, however, is very much portable: every TYPO3 extension that accepts user input via $request->getParsedBody() or $request->getQueryParams() and passes it without explicit form validation into a QueryBuilder->where(…) call can stumble on associatively structured input — particularly if the where condition is built via $queryBuilder->expr()->in(…) with a dynamically composed column string, or if createNamedParameter() is called without an explicit type hint.

Over the weekend we ran a grep sweep across the third-party extension trees of our customer platforms:

 

# In each TYPO3 project tree
find typo3conf/ext -type f -name "*.php" -exec grep -lE \
  'getParsedBody|getQueryParams' {} \; \
  | xargs -I {} grep -lE 'QueryBuilder|createNamedParameter|expr\(\)->in' {} \
  | sort -u

 

The hit list is manageable, because most extensions do strict form validation via Extbase/Fluid or TYPO3 Forms. Three extensions in the long-tail range (all custom builds from the years before 2024, i.e. before the broad migration to Extbase 14) we reviewed manually — no exploitable spot found, but two of the three extensions had code paths that would have been cleaner with an explicit type hint in createNamedParameter(). We added those to the next refactoring window, without escalation.

Reflection in two points. First: the fact that we don't run a Drupal platform isn't a glory; it's a platform choice we made years ago that occasionally produces less work in waves like this. In other waves (for instance the Symfony 20 May Patch Tuesday) we were fully on the hook. The platform choice is a risk distribution, not a risk exclusion. Second: parser differentials of this CVE class are not controllable through codebase size or code-review culture of a single project — they sit at layer boundaries where two components interpret the same input slightly differently. Drupal-Symfony-JsonEncoder ↔ Drupal DB layer is one class; SymfonyRuntime parse_str ↔ SAPI argv builder is another; HtmlSanitizer URL parser ↔ browser URL parser is the third. If you want to push this class structurally out of your own stack, you don't build a better sanitising function — you reduce the number of layer transitions: fewer parsing stages, fewer format indirections, less auto-magic in abstraction layers.

Frequently asked questions about the Drupal SQLi of 20/22 May 2026

Are TYPO3 platforms with MariaDB backends indirectly affected by CVE-2026-9082?+

No. The flaw sits in Drupal core, not in TYPO3 core; it additionally requires PostgreSQL as the backend. TYPO3 on MariaDB is in no way touched by this CVE. The audit lesson (check third-party extensions for user-structured array input flowing into DB conditions), however, applies independently of the DB backend.

We operate a TYPO3 platform on PostgreSQL. Do we have to do anything additional now?+

The Drupal CVE itself doesn't hit you. PostgreSQL as a TYPO3 backend has been stably supported since TYPO3 v11 and we actively recommend it in certain setups (multi-master replication, JSONB-heavy workloads). What you should do: audit the DB user privileges for the TYPO3 DB user for least privilege — no pg_execute_server_program, no superuser, no CREATE EXTENSION rights. That's standard hardening, independent of this CVE, but the wave is a good trigger.

How do I check whether my third-party TYPO3 extensions pass user paths into DB conditions?+

The grep sweep from the „What we actually did“ block is a first filter. Stricter: for each extension that lands on the hit list, review the controller action methods manually — where is getParsedBody() or getQueryParams() called, what happens with the result, does it flow without an instanceof string check or without Extbase form validation into a QueryBuilder call? If yes: a createNamedParameter($value, Connection::PARAM_STR) type hint is the simplest mitigation. Stricter: equip the action method signature with a Symfony validator constraint or an Extbase DTO, and validate the input before the QueryBuilder.

Drupal awarded a 20/25 risk score, NVD calls it CVSS 6.5 — which one is correct?+

Both. The Drupal risk scale („Highly Critical 20/25“) weights more strongly by exploitability and privilege requirement; NVD's CVSS 3.1 modelling sits stricter in the standard and comes out at 6.5 (above all because RCE escalation is setup-dependent). What matters operationally is the CISA KEV entry: active in-the-wild exploitation is the strongest external signal that the CVSS number understates the actual severity. If you wait for the NVD score before you patch, you're too late.

What is the parser-differential pattern and why does the post emphasise it so much?+

A parser differential is a vulnerability class in which two software components interpret the same input apparently identically, but in fact differently at an edge-case spot — and an attacker sits at the translation boundary. Drupal-Symfony-JsonEncoder hands out associative arrays, the Drupal DB layer assumes index arrays — the translation boundary is the flaw. SymfonyRuntime parse_str and the SAPI argv builder build the same query string differently — the translation boundary was CVE-2026-46626. HtmlSanitizer URL parser and browser URL parser accept slightly different URL formats — the translation boundary was CVE-2026-45064/45066. Naming the pattern as a class gives you an audit reflex that carries beyond the single CVE.

Is a WAF patch (Cloudflare, Akamai, Imperva) enough for the next few days until we can patch?+

As a stopgap, yes; as a replacement, no. All three major WAF vendors rolled out a rule within 24 hours that blocks POST bodies to /user/login?_format=json with an object-shaped name field. That closes the known exploit path. What it doesn't close: alternative JSON endpoints in the Drupal tree that could trigger the same pattern, and custom modules with their own JSON routes that likewise pass associative arrays on to entity queries. Patching remains mandatory; the WAF buys you time for the rollout.

Conclusion

CVE-2026-9082 isn't the patch trigger for Moselwal — it's the audit trigger. We don't run Drupal, but we run TYPO3, and the class to which the Drupal flaw belongs isn't Drupal-specific. Three independent design decisions, each sensible on its own, produce unauthenticated DB access when overlaid: a JSON encoder that correctly decodes associative arrays; a DB layer that implicitly assumes array keys are trusted; a backend driver that emits the key into the SQL string. We saw the same pattern on 20 May in SymfonyRuntime (CVE-2026-46626, parse_str vs SAPI argv) and in the Symfony HtmlSanitizer wave (CVE-2026-45064/-45066, URL parser differentials) — three different stacks, the same class, three weeks of time window. The structural consequence isn't „better sanitising functions“, it's fewer transitions between parsing stages, less auto-magic in abstraction layers, more explicit type hints at the layer boundaries. The question isn't whether the next parser-differential CVE arrives in your stack. It's at which layer boundary you'll see it first — and whether you've looked there carefully before it arrives.

Before the next parser-differential CVE arrives — let's talk about your extension tree.

We audit your TYPO3 / Doctrine DBAL stack for parser-differential patterns before the next CVE wave arrives.

Concretely: a grep sweep across third-party extension trees, manual code review of the hit list for controller action methods with user-structured input flowing into QueryBuilder conditions, mitigation patches with explicit type hints in createNamedParameter(), optional Extbase DTO hardening with validator constraints. We deliver the hit list, the patch proposal, and the test suite extension to you as a coherent audit deliverable.

Platform operations rather than advice-on-paper. If you want to pull the audit reflex permanently into the CI pipeline, you come to us for the DevSecOps service with a composer audit gate, static analysis (Psalm / PHPStan at security level), and QueryBuilder-specific custom rules — see /en/devsecops.html and /en/services.html.

Schedule a call

About the author

[Translate to English:] Foto von Kai Ole Hartwig.

Kai Ole Hartwig

Founder · Moselwal Digitalagentur · OnlyOle

Programming since 2002 – self-taught, set up my own business with KO-Web in 2012, now Moselwal. Over 100 projects, with a focus on security, performance, automation and quality.