//nefariousplan

CVE-2026-9082: The Comment Said Use the Array Values. A 2020 Refactor Started Using the Keys.

patterns

cve

proof of concept

Drupal's patch for CVE-2026-9082 is one function call: array_values(), wrapped around a foreach in the PostgreSQL Entity Query condition translator. The comment two lines above that foreach has named that exact function since June 30, 2015. Between 2015 and December 18, 2020, the code under the comment did what the comment said. On that date, a refactor that had nothing to do with security replaced the inner counter with the loop key. The comment stayed. The loop did not. The five years and five months between the refactor and the CVE are the window the comment kept telling readers what the code should have been doing.

The 2015 loop did what the 2015 comment said

The first version of Drupal\Core\Database\Driver\pgsql\EntityQuery\Condition::translateCondition() landed on June 30, 2015, in commit 40ae780a3f, authored by Alex Pott. The PostgreSQL override existed because PostgreSQL is case-sensitive by default on text columns and Drupal treats most string fields as logically case-insensitive. To emulate case-insensitive IN matching, the override could not delegate to the base class's array handling. The base class, Drupal\Core\Entity\Query\Sql\Condition::translateCondition(), opens with:

public static function translateCondition(&$condition, SelectInterface $sql_query, $case_sensitive) {
  // // There is nothing we can do for IN ().
  if (is_array($condition['value'])) {
    return;
  }
  ...
}

The base class refuses to handle array values. The pgsql override has to handle them. It builds its own LOWER(...) IN (LOWER(:placeholder), ...) clause by walking the value array and emitting one bound placeholder per entry. Building those placeholder names manually is what makes the override more dangerous than the base class. The base class abstains from the unsafe operation; the override has to perform it.

In the 2015 implementation, the placeholder names were generated from a manually incremented counter $n:

$n = 1;
// Only use the array values in case an associative array is passed as an
// argument following similar pattern in
// \Drupal\Core\Database\Connection::expandArguments().
foreach ($condition['value'] as $value) {
  $condition['where'] .= 'LOWER(:value' . $n . '),';
  $condition['where_args'][':value' . $n] = $value;
  $n++;
}

The comment is a contract description. It tells the next reader what the loop does and why: ignore the keys, use only the values, follow the pattern that \Drupal\Core\Database\Connection::expandArguments() already uses for parameter expansion across the codebase. The loop honors the contract: foreach ($condition['value'] as $value) discards the key entirely; $n is an integer the function controls; the placeholder name :value1, :value2, :value3 is a constant string concatenated with an integer the attacker cannot influence.

The comment and the code agreed for five and a half years.

The 2020 refactor kept the comment

On December 18, 2020, commit 1cb136a1f0 landed under issue #3162603, titled "EntityStorageBase::loadByProperties() is broken on PostgreSQL when using two or more case insensitive properties." The bug being fixed was real and unrelated to security. When a single query carried two IN conditions on different case-insensitive fields, both conditions emitted :value1, :value2, ... placeholders, and the placeholder names collided. The fix was to namespace the placeholder names per field by prepending the field's flattened path, node_field_data_title, node_field_data_status, and so on. That part of the change was correct.

The diff:

- $n = 1;
  // Only use the array values in case an associative array is passed as an
  // argument following similar pattern in
  // \Drupal\Core\Database\Connection::expandArguments().
- foreach ($condition['value'] as $value) {
-   $condition['where'] .= 'LOWER(:value' . $n . '),';
-   $condition['where_args'][':value' . $n] = $value;
-   $n++;
+ $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;
  }
  $condition['where'] = trim($condition['where'], ',');
  $condition['where'] .= ')';
- return;

The $n counter is gone. The foreach now extracts $key from the iterator. The placeholder name $where_id is the field prefix concatenated with whatever $key happens to be at runtime. The author needed per-field namespacing and could have written $where_prefix . $n to get it without disturbing the original "values only" invariant; they chose to read the key directly out of the iteration and concatenate it. PHP's foreach ($array as $key => $value) yields the array's actual keys, not synthetic indices. When $condition['value'] is associative, $key is whatever the caller put there.

The comment was not deleted. The comment still said "Only use the array values in case an associative array is passed." Three lines below the comment, the new loop did the opposite.

The path from URL to placeholder name

JSON:API has been bundled in Drupal core since 8.7 and enabled by default for read access since 8.8. It exposes every entity type as a JSON-API-spec collection at /jsonapi/<entity-type>/<bundle> and accepts filter[...] query parameters that translate directly into EntityQuery conditions. The filter syntax accepts nested keys; PHP's URL parser produces a nested associative array; JSON:API forwards the nested structure into the condition without normalizing the array's keys, because there was no perceived reason to.

A request that fires the bug:

GET /jsonapi/node/article
  ?filter[t][condition][path]=title
  &filter[t][condition][operator]=IN
  &filter[t][condition][value][PAYLOAD]=x
  &page[limit]=1

PHP parses the query string into:

['filter' => ['t' => ['condition' => [
  'path'     => 'title',
  'operator' => 'IN',
  'value'    => ['PAYLOAD' => 'x'],
]]]]

JSON:API's Filter::createFromQueryParameter() normalizes that into a Drupal\Core\Entity\Query\Condition with value = ['PAYLOAD' => 'x']. EntityQuery execution reaches pgsql\EntityQuery\Condition::translateCondition(). The guard is_array($value) && $case_sensitive === FALSE fires because title is one of Drupal's case-insensitive-by-default string fields. The foreach runs with $key = 'PAYLOAD':

$where_id = 'node_field_data_title' . 'PAYLOAD'
         = 'node_field_data_titlePAYLOAD'

$condition['where']      .= 'LOWER(:node_field_data_titlePAYLOAD),'
$condition['where_args'][':node_field_data_titlePAYLOAD'] = 'x'

The attacker's string is now part of a PDO placeholder name. PostgreSQL's prepared-statement parser accepts identifier characters in a placeholder name and stops at the first non-identifier character. A payload that contains SQL metacharacters terminates the placeholder grammar and the remainder is parsed as raw SQL inside the WHERE clause. The PoC's smoking-gun probe is _) AND 1=1--, which produces this SQL:

SELECT "base_table"."vid", "base_table"."nid"
FROM "node" "base_table"
INNER JOIN "node_field_data" "node_field_data" ON ...
WHERE (
  (LOWER("node_field_data"."title") IN (
    LOWER(:node_field_data_title_) AND 1=1--
  ))
) AND
  ("node_field_data_2"."status" = :db_condition_placeholder_0) AND
  ("node_field_data_3"."type"   = :db_condition_placeholder_1)
GROUP BY "base_table"."vid", "base_table"."nid"
LIMIT 2 OFFSET 0

The PDO driver fails to reconcile the bind key (still the literal string :node_field_data_title_) AND 1=1--) with the placeholder PostgreSQL just parsed (:node_field_data_title_), throws SQLSTATE[HY093]: Invalid parameter number: number of bound variables does not match number of tokens, and Drupal returns HTTP 500 with the failed SQL in the response body. That diagnostic dump is itself a confirmation channel: the failed query reveals the structural injection. A more careful payload produces well-formed SQL that runs, and the WHERE clause becomes attacker-controlled.

The 2015 loop emitted LOWER(:value1). The 2020 loop emits LOWER(: followed by attacker bytes. Both loops have the same comment above them.

The patch is the function call the comment named

The advisory SA-CORE-2026-004, tracked as CVE-2026-9082, releases the fix across every supported branch: Drupal 11.3.10, 11.2.12, 11.1.10, 10.6.9, 10.5.10, and the EOL emergency releases 10.4.10 and 11.1.10. The fix lands in three places. The primary one is the line below the 2015 comment:

- foreach ($condition['value'] as $key => $value) {
+ foreach (array_values($condition['value']) as $key => $value) {

array_values() returns the values of the passed array re-indexed from zero. After the wrap, $key is guaranteed to be 0, 1, 2, ... regardless of what the caller supplied. The placeholder names become :node_field_data_title0, :node_field_data_title1, all safe identifiers. The comment above this line, present since 2015, now describes the code below it again.

The patch is also applied as defense-in-depth in the base class, two directories up, in \Drupal\Core\Entity\Query\Sql\Condition::compile():

  $condition['real_field'] = $field;
+ if (is_array($condition['value'])) {
+   $condition['value'] = array_values($condition['value']);
+ }
  static::translateCondition($condition, $sql_query, $tables->isFieldCaseSensitive($condition['field']));

This is the admission. The base class's own translateCondition returns early on array values; it is not directly vulnerable. The patch adds array_values() to the base class anyway, before any subclass's translateCondition runs. The Drupal core maintainers are saying, in code, that they do not trust the next dialect-specific subclass to handle the key-vs-value distinction either. The same guard was added to ConditionAggregate::compile() in the same diff. Three insertions of array_values(), all closing the same shape, all assuming the shape will try to recur.

Why this is PostgreSQL only

A common reading of "SQL injection in Drupal core" is that the database abstraction layer broke a parameterization invariant that should have held across every backend. That is not what happened. The base class's translateCondition returns early on array values, which means MySQL and SQLite installs never reach a code path that builds custom placeholder names from array data. Drupal's MySQL and SQLite layers fall back to the database driver's own IN-array expansion logic, which uses positional placeholders the array's keys never reach.

PostgreSQL is exposed because of a property of the dialect that has nothing to do with security. PostgreSQL's text type is case-sensitive; the default text collation does not fold case. Drupal pretends string fields are case-insensitive because every other backend it supports treats them that way. To preserve the illusion, the pgsql override has to emit LOWER() on both sides of the comparison, which means it has to construct the IN clause manually, which means it has to choose its own placeholder names. The base class is safe because it gives up. The pgsql override is unsafe because it does not give up. The fix is to give up on attacker-influenced keys while still doing the work.

Every Drupal site on PostgreSQL in the supported version range exposes a JSON:API endpoint at /jsonapi by default; the Standard install profile enables it with anonymous read access; every entity type with access content is reachable. The exploit precondition for CVE-2026-9082 is "site administrator left core upgrades on the standard cadence between December 2020 and May 2026." That covers every long-running Drupal/PostgreSQL deployment the maintainers care about, which is the population the advisory rates 20/25 against.

The comment outlasted the code under it

The Todo That Shipped pattern in the nefariousplan catalog covers cases where a comment names a security control and the code below the comment never implements the named control. This case is the variant where the named control was implemented, lived under the comment for five and a half years, and was then deleted by a refactor that left the comment standing. The artifact is the same: a contract description in source, sitting above a line of code that does not honor it. Attackers grep for the contract; defenders grep for the keyword in the comment. Both find the same artifact. Only one side is auditing on the assumption the comment might be lying.

The kindred pattern in this case is also visible. The base class refuses to handle array IN because the maintainers knew building placeholder names from array data was hard. The pgsql override does it anyway because dialect-specific case-insensitivity made it necessary. The Parallel Implementation Gap framing fits cleanly: the canonical Sql/Condition is safe because it abstains; the divergent pgsql Condition is unsafe because it overrides the abstention. The 2026 patch wires array_values() into the base class's compile() so the next dialect override does not have to remember.

Drupal's response to this CVE includes emergency releases for two EOL branches (10.4.x and 11.1.x), which is the maintainer body's standard practice for Highly Critical issues. The emergency releases are evidence of the threat model the project actually holds: a publicly disclosed unauthenticated SQLi against the JSON:API endpoint that ships enabled by default is a population-level event, not a per-site one. The five and a half years the comment spent describing the loop and the loop spending five and a half years not honoring the comment is the gap that population was exposed across.

The 2015 author wrote the comment to describe their own code. The 2026 patch rewrote the code to match the comment. The five and a half years between them are the CVE.