diff --git a/nxc/helpers/modules.py b/nxc/helpers/modules.py new file mode 100644 index 000000000..9c7c86dad --- /dev/null +++ b/nxc/helpers/modules.py @@ -0,0 +1,37 @@ +from nxc.paths import NXC_PATH +from os import makedirs +import logging +import datetime + + +def create_log_dir(module_name): + makedirs(f"{NXC_PATH}/logs/{module_name}", exist_ok=True) + +def create_loot_dir(module_name): + makedirs(f"{NXC_PATH}/loot/{module_name}", exist_ok=True) + +def generate_module_log_file(module_name): + create_log_dir(module_name) + return f"{NXC_PATH}/logs/{module_name}/{datetime.now().strftime('%Y-%m-%d')}.log" + +def create_module_logger(module_name): + log_file = generate_module_log_file(module_name) + module_logger = logging.getLogger(module_name) + module_logger.propagate = False + module_logger.setLevel(logging.INFO) + module_file_handler = logging.FileHandler(log_file) + module_file_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) + module_logger.addHandler(module_file_handler) + return module_logger + +def add_loot_data(module_name, filename, data): + create_loot_dir(module_name) + loot_file = get_loot_data_filepath(module_name, filename) + with open(loot_file, "a") as file: + file.write(data) + +def get_loot_data_filepath(module_name, filename): + return f"{NXC_PATH}/loot/{module_name}/{filename}" + +def get_loot_data_folder(module_name): + return f"{NXC_PATH}/loot/{module_name}" diff --git a/nxc/modules/example_module.py b/nxc/modules/example_module.py index ed935c89c..1addf7a95 100644 --- a/nxc/modules/example_module.py +++ b/nxc/modules/example_module.py @@ -1,4 +1,4 @@ - +from nxc.helpers.modules import add_loot_data class NXCModule: """ Example: @@ -6,7 +6,7 @@ class NXCModule: Module by @yomama """ - name = "example module" + name = "example_module" # Make sure this is unique and one word (no spaces) description = "I do something" supported_protocols = [] # Example: ['smb', 'mssql'] opsec_safe = True # Does the module touch disk? @@ -42,12 +42,17 @@ def on_login(self, context, connection): raise Exception("Exception that might have occurred") except Exception as e: context.log.exception(f"Exception occurred: {e}") # This will display an exception traceback screen after an exception was raised and should only be used for critical errors + + # Use this function to add loot data you want to save to $NXC_PATH/loot/$MODULE_NAME/$FILENAME + add_loot_data(self.name, "custom_loot_file.txt", "Data can be anything you want, passwords, hashes, or anything") def on_admin_login(self, context, connection): """Concurrent. Required if on_login is not present - This gets called on each authenticated connection with Administrative privileges + This gets called on each authenticated connection with Administrative privileges """ + # Use this function to add loot data you want to save to $NXC_PATH/loot/$MODULE_NAME/$FILENAME + add_loot_data(self.name, "custom_loot_file.txt", "Data can be anything you want, passwords, hashes, or anything") def on_request(self, context, request): """Optional. diff --git a/nxc/modules/get_gpos.py b/nxc/modules/get_gpos.py new file mode 100644 index 000000000..29460f52b --- /dev/null +++ b/nxc/modules/get_gpos.py @@ -0,0 +1,66 @@ +from impacket.ldap import ldapasn1 as ldapasn1_impacket + +class NXCModule: + """Module by @Marshall-Hallenbeck + Retrieves Group Policy Objects (GPOs) in Active Directory + """ + + name = "get_gpos" + description = "Retrieves Group Policy Objects (GPOs) in Active Directory" + supported_protocols = ["ldap"] + opsec_safe = True + multiple_hosts = False + + def __init__(self): + self.context = None + self.module_options = None + self.gpo_name = None + self.fuzzy_search = False + self.all_props = False + + def options(self, context, module_options): + """ + NAME Name of the GPO (default retrieve all GPOs) + FUZZY Fuzzy search for name of GPOs (using wildcards) + ALL_PROPS Retrieve all properties of the GPO (default is name, guid, and sysfile path) + """ + self.gpo_name = module_options.get("NAME") + self.fuzzy_search = module_options.get("FUZZY") + self.all_props = module_options.get("ALL_PROPS") + + def on_login(self, context, connection): + # name is actually the GUID of the GPO + attributes = ["*"] if self.all_props else ["displayName", "name", "gPCFileSysPath"] + + if self.gpo_name: + context.log.display(f"Searching for GPO '{self.gpo_name}'") + self.gpo_name = f"*{self.gpo_name}*" if self.fuzzy_search else self.gpo_name + search_filter = f"(&(objectCategory=groupPolicyContainer)(displayname={self.gpo_name}))" + else: + context.log.display("Searching for all GPOs") + search_filter = "(objectCategory=groupPolicyContainer)" + context.log.debug(f"Search filter: '{search_filter}'") + + results = connection.search(search_filter, attributes, 10000) + results = [r for r in results if isinstance(r, ldapasn1_impacket.SearchResultEntry)] + context.log.success(f"GPOs Found: {len(results)}") + + if results: + for gpo in results: + gpo_values = {str(attr["type"]).lower(): str(attr["vals"][0]) for attr in gpo["attributes"]} + context.log.success(f"GPO Found: '{gpo_values['displayname']}'") + for k, v in gpo_values.items(): + if self.gpo_name: + if k == "displayname": + context.log.highlight(f"Display Name: {v}") + elif k == "name": + context.log.highlight(f"GUID: {v}") + else: + context.log.highlight(f"{k}: {v}") + else: + context.log.highlight(f"{k}: {v}") + else: + if self.gpo_name: + context.log.error(f"No GPO found with the name '{self.gpo_name}'") + else: + context.log.error("No GPOs found") \ No newline at end of file diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index 59b13dce0..d4832cf10 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -7,6 +7,7 @@ from impacket.smbconnection import SMBConnection, SessionError from impacket.smb import SMB_DIALECT +from impacket.smb3structs import FILE_READ_DATA, FILE_WRITE_DATA from impacket.examples.secretsdump import ( RemoteOperations, SAMHashes, @@ -1549,24 +1550,79 @@ def put_file_single(self, src, dst): def put_file(self): for src, dest in self.args.put_file: self.put_file_single(src, dest) - - def get_file_single(self, remote_path, download_path): + + def download_file(self, share_name, remote_path, dest_file, access_mode=FILE_READ_DATA): + try: + self.logger.debug(f"Getting file from {share_name}:{remote_path} with access mode {access_mode}") + self.conn.getFile(share_name, remote_path, dest_file, shareAccessMode=access_mode) + return True + except SessionError as e: + if "STATUS_SHARING_VIOLATION" in str(e): + self.logger.debug(f"Sharing violation on {remote_path}: {e}") + return False + except Exception as e: + self.logger.debug(f"Other error when attempting to download file {remote_path}: {e}") + return False + + def get_file_single(self, remote_path, download_path, silent=False): share_name = self.args.share - self.logger.display(f'Copying "{remote_path}" to "{download_path}"') + if not silent: + self.logger.display(f"Copying '{remote_path}' to '{download_path}'") if self.args.append_host: download_path = f"{self.hostname}-{remote_path}" with open(download_path, "wb+") as file: - try: - self.conn.getFile(share_name, remote_path, file.write) - self.logger.success(f'File "{remote_path}" was downloaded to "{download_path}"') - except Exception as e: - self.logger.fail(f'Error writing file "{remote_path}" from share "{share_name}": {e}') - if os.path.getsize(download_path) == 0: - os.remove(download_path) - + if self.download_file(share_name, remote_path, file.write): + if not silent: + self.logger.success(f"File '{remote_path}' was downloaded to '{download_path}'") + else: + self.logger.debug("Opening with READ alone failed, trying to open file with READ/WRITE access") + if self.download_file(share_name, remote_path, file.write, FILE_READ_DATA | FILE_WRITE_DATA): + if not silent: + self.logger.success(f"File '{remote_path}' was downloaded to '{download_path}'") + else: + if not silent: + self.logger.fail(f"Error downloading file '{remote_path}' from share '{share_name}'") + def get_file(self): for src, dest in self.args.get_file: self.get_file_single(src, dest) + + def download_folder(self, folder, dest, recursive=False, silent=False, base_dir=None): + normalized_folder = ntpath.normpath(folder) + base_folder = os.path.basename(normalized_folder) + self.logger.debug(f"Base folder: {base_folder}") + + try: + items = self.conn.listPath(self.args.share, ntpath.join(folder, "*")) + except SessionError as e: + self.logger.error(f"Error listing folder '{folder}': {e}") + return + self.logger.debug(f"{len(items)} items in folder: {items}") + + for item in items: + item_name = item.get_longname() + if item_name not in [".", ".."]: + dir_path = ntpath.normpath(ntpath.join(normalized_folder, item_name)) + if item.is_directory() and recursive: + self.download_folder(dir_path, dest, silent, recursive, base_dir or folder) + elif not item.is_directory(): + remote_file_path = ntpath.join(folder, item_name) + # change the Windows path to Linux and then join it with the base directory to get our actual save path + relative_path = os.path.join(*folder.replace(base_dir or folder, "").lstrip("\\").split("\\")) + local_folder_path = os.path.join(dest, relative_path) + local_file_path = os.path.join(local_folder_path, item_name) + self.logger.debug(f"{dest=} {remote_file_path=} {relative_path=} {local_folder_path=} {local_file_path=}") + + os.makedirs(local_folder_path, exist_ok=True) + try: + self.get_file_single(remote_file_path, local_file_path, silent) + except FileNotFoundError: + self.logger.fail(f"Error downloading file '{remote_file_path}' due to file not found (probably a race condition between listing and downloading)") + + def get_folder(self): + recursive = self.args.recursive + for folder, dest in self.args.get_folder: + self.download_folder(folder, dest, recursive) def enable_remoteops(self): try: diff --git a/nxc/protocols/smb/proto_args.py b/nxc/protocols/smb/proto_args.py index 8ce85dc82..6011de8cd 100644 --- a/nxc/protocols/smb/proto_args.py +++ b/nxc/protocols/smb/proto_args.py @@ -67,9 +67,11 @@ def proto_args(parser, parents): segroup.add_argument("--pattern", nargs="+", help="pattern(s) to search for in folders, filenames and file content") segroup.add_argument("--regex", nargs="+", help="regex(s) to search for in folders, filenames and file content") - files_group = smb_parser.add_argument_group("Files", "Options for remote file interaction") + files_group = smb_parser.add_argument_group("Files", "Options for put and get remote files") files_group.add_argument("--put-file", action="append", nargs=2, metavar="FILE", help="Put a local file into remote target, ex: whoami.txt \\\\Windows\\\\Temp\\\\whoami.txt") files_group.add_argument("--get-file", action="append", nargs=2, metavar="FILE", help="Get a remote file, ex: \\\\Windows\\\\Temp\\\\whoami.txt whoami.txt") + files_group.add_argument("--get-folder", action="append", nargs=2, metavar="DIR", help="Get a remote directory, ex: \\\\Windows\\\\Temp\\\\testing testing") + files_group.add_argument("--recursive", default=False, action="store_true", help="Recursively get a folder") files_group.add_argument("--append-host", action="store_true", help="append the host to the get-file filename") cmd_exec_group = smb_parser.add_argument_group("Command Execution", "Options for executing commands") diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index edbbba191..581cdf80a 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -200,6 +200,13 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M subnets netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M user-desc netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M whoami netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M pso +netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos +netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos -o NAME="Default Domain Policy" +netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos -o NAME="Default Domain Policy" -o ALL_PROPS=True +netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos -o NAME="Default" +netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos -o NAME="Default" -o FUZZY=True +netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos -o NAME="Default" -o FUZZY=True -o ALL_PROPS=True +netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos -o ALL_PROPS=True ##### WINRM netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS # need an extra space after this command due to regex netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig