From 1409fea631e2b7d16bd85d047f3fc578492cadb7 Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Thu, 16 May 2024 12:06:16 -0400 Subject: [PATCH 01/13] feat(get-gpos): add new module to get GPOs from LDAP --- nxc/modules/get_gpos.py | 70 +++++++++++++++++++++++++++++++++++++++++ tests/e2e_commands.txt | 8 +++++ 2 files changed, 78 insertions(+) create mode 100644 nxc/modules/get_gpos.py diff --git a/nxc/modules/get_gpos.py b/nxc/modules/get_gpos.py new file mode 100644 index 000000000..2829a8292 --- /dev/null +++ b/nxc/modules/get_gpos.py @@ -0,0 +1,70 @@ +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 + self.download = False + + def options(self, context, module_options): + """ + NAME Name of the GPO + FUZZY Fuzzy search for name of GPOs (using wildcards) + ALL_PROPS Retrieve all properties of the GPO + DOWNLOAD Attempt to download GPOs retrieved + """ + self.gpo_name = module_options.get("NAME") + self.fuzzy_search = module_options.get("FUZZY") + self.all_props = module_options.get("ALL_PROPS") + self.download = module_options.get("DOWNLOAD") + + 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/tests/e2e_commands.txt b/tests/e2e_commands.txt index b895b6ad2..68264bf97 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -199,6 +199,14 @@ 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 whoami --options 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 pso --options +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 --options +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 whoami From 3ec55a60495adfc479d438f5e0190d9c633f7728 Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Thu, 16 May 2024 17:31:58 -0400 Subject: [PATCH 02/13] feat(modules): add module helper for logging and loot saving --- nxc/helpers/modules.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 nxc/helpers/modules.py diff --git a/nxc/helpers/modules.py b/nxc/helpers/modules.py new file mode 100644 index 000000000..fe4159123 --- /dev/null +++ b/nxc/helpers/modules.py @@ -0,0 +1,35 @@ +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): + create_log_dir(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}" From e5151ff352461af19f1e754d0ca5fd8604b71673 Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Thu, 16 May 2024 17:32:33 -0400 Subject: [PATCH 03/13] feat(get_gpos): add in more options --- nxc/modules/get_gpos.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/nxc/modules/get_gpos.py b/nxc/modules/get_gpos.py index 2829a8292..f8e857971 100644 --- a/nxc/modules/get_gpos.py +++ b/nxc/modules/get_gpos.py @@ -1,5 +1,5 @@ from impacket.ldap import ldapasn1 as ldapasn1_impacket - +from nxc.helpers.modules import get_loot_data_filepath class NXCModule: """Module by @Marshall-Hallenbeck @@ -19,13 +19,17 @@ def __init__(self): self.fuzzy_search = False self.all_props = False self.download = False + self.report = True + self.report_file = get_loot_data_filepath(self.name, "gpos.txt") def options(self, context, module_options): """ - NAME Name of the GPO + 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 - DOWNLOAD Attempt to download GPOs retrieved + ALL_PROPS Retrieve all properties of the GPO (default is name, guid, and sysfile path) + REPORT Save GPO information to a file (default True) + REPORT_FILE File to save GPO information to (by default saves to loot directory) + DOWNLOAD Attempt to download GPOs retrieved (default) """ self.gpo_name = module_options.get("NAME") self.fuzzy_search = module_options.get("FUZZY") @@ -47,7 +51,7 @@ def on_login(self, context, connection): 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)}") + context.log.success(f"GPOs Found: {len(results)}") if results: for gpo in results: From 83a47587553d58d8ba8cc082b47269227050e596 Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Thu, 16 May 2024 12:06:16 -0400 Subject: [PATCH 04/13] feat(get-gpos): add new module to get GPOs from LDAP --- nxc/modules/get_gpos.py | 70 +++++++++++++++++++++++++++++++++++++++++ tests/e2e_commands.txt | 8 +++++ 2 files changed, 78 insertions(+) create mode 100644 nxc/modules/get_gpos.py diff --git a/nxc/modules/get_gpos.py b/nxc/modules/get_gpos.py new file mode 100644 index 000000000..2829a8292 --- /dev/null +++ b/nxc/modules/get_gpos.py @@ -0,0 +1,70 @@ +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 + self.download = False + + def options(self, context, module_options): + """ + NAME Name of the GPO + FUZZY Fuzzy search for name of GPOs (using wildcards) + ALL_PROPS Retrieve all properties of the GPO + DOWNLOAD Attempt to download GPOs retrieved + """ + self.gpo_name = module_options.get("NAME") + self.fuzzy_search = module_options.get("FUZZY") + self.all_props = module_options.get("ALL_PROPS") + self.download = module_options.get("DOWNLOAD") + + 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/tests/e2e_commands.txt b/tests/e2e_commands.txt index 2d1d8e054..f88899be0 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -220,6 +220,14 @@ 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 whoami --options 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 pso --options +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 --options +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 From 13bf6eee75c03f6a786fcb0d0a3ec86cf32bd593 Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Thu, 16 May 2024 17:31:58 -0400 Subject: [PATCH 05/13] feat(modules): add module helper for logging and loot saving --- nxc/helpers/modules.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 nxc/helpers/modules.py diff --git a/nxc/helpers/modules.py b/nxc/helpers/modules.py new file mode 100644 index 000000000..fe4159123 --- /dev/null +++ b/nxc/helpers/modules.py @@ -0,0 +1,35 @@ +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): + create_log_dir(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}" From 9c952be7a221481b304ec349be3961737910f9cd Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Thu, 16 May 2024 17:32:33 -0400 Subject: [PATCH 06/13] feat(get_gpos): add in more options --- nxc/modules/get_gpos.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/nxc/modules/get_gpos.py b/nxc/modules/get_gpos.py index 2829a8292..f8e857971 100644 --- a/nxc/modules/get_gpos.py +++ b/nxc/modules/get_gpos.py @@ -1,5 +1,5 @@ from impacket.ldap import ldapasn1 as ldapasn1_impacket - +from nxc.helpers.modules import get_loot_data_filepath class NXCModule: """Module by @Marshall-Hallenbeck @@ -19,13 +19,17 @@ def __init__(self): self.fuzzy_search = False self.all_props = False self.download = False + self.report = True + self.report_file = get_loot_data_filepath(self.name, "gpos.txt") def options(self, context, module_options): """ - NAME Name of the GPO + 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 - DOWNLOAD Attempt to download GPOs retrieved + ALL_PROPS Retrieve all properties of the GPO (default is name, guid, and sysfile path) + REPORT Save GPO information to a file (default True) + REPORT_FILE File to save GPO information to (by default saves to loot directory) + DOWNLOAD Attempt to download GPOs retrieved (default) """ self.gpo_name = module_options.get("NAME") self.fuzzy_search = module_options.get("FUZZY") @@ -47,7 +51,7 @@ def on_login(self, context, connection): 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)}") + context.log.success(f"GPOs Found: {len(results)}") if results: for gpo in results: From 0d5daf6d95ade8144798de908cf92dc4a78387ee Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Tue, 21 May 2024 20:14:56 -0400 Subject: [PATCH 07/13] feat(smb): add feature to download entire folders from target --- nxc/protocols/smb.py | 33 ++++++++++++++++++++++++++++++++- nxc/protocols/smb/proto_args.py | 2 ++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index c83efd539..0d6ae11a6 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -1297,7 +1297,38 @@ def get_file_single(self, remote_path, download_path): 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, base_dir=None): + normalized_folder = ntpath.normpath(folder) + base_folder = os.path.basename(normalized_folder) + self.logger.debug(f"Base folder: {base_folder}") + folder_wildcard = ntpath.join(folder, "*") + self.logger.debug(f"Requesting folder '{normalized_folder}' from share {self.args.share}") + items = self.conn.listPath(self.args.share, folder_wildcard) + self.logger.debug(f"{len(items)} items in folder: {items}") + + for item in items: + if item.get_longname() not in [".", ".."]: + if item.is_directory(): + dir_path = ntpath.normpath(os.path.join(normalized_folder, item.get_longname())) + if recursive: + self.download_folder(dir_path, dest, recursive, base_dir or folder) + else: + filename = item.get_longname() + remote_file_path = ntpath.join(folder, filename) + self.logger.debug(f"File found: {remote_file_path}") + relative_path = folder.replace(base_dir or folder, "").lstrip("\\") + relative_path = os.path.join(*relative_path.split("\\")) + local_folder_path = os.path.join(dest, relative_path) + local_file_path = os.path.join(local_folder_path, filename) + self.logger.debug(f"Saving {remote_file_path} to {local_file_path}") + os.makedirs(local_folder_path, exist_ok=True) + self.get_file_single(remote_file_path, local_file_path) + + def get_folder(self): + recursive = self.args.recursive + for folder, dest in self.args.get_folder: + self.download_folder(folder, dest, recursive, None) def enable_remoteops(self): try: diff --git a/nxc/protocols/smb/proto_args.py b/nxc/protocols/smb/proto_args.py index 36fbc3bc5..864a46c9f 100644 --- a/nxc/protocols/smb/proto_args.py +++ b/nxc/protocols/smb/proto_args.py @@ -61,6 +61,8 @@ def proto_args(parser, std_parser, module_parser): tgroup = smb_parser.add_argument_group("Files", "Options for put and get remote files") tgroup.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") tgroup.add_argument("--get-file", action="append", nargs=2, metavar="FILE", help="Get a remote file, ex: \\\\Windows\\\\Temp\\\\whoami.txt whoami.txt") + tgroup.add_argument("--get-folder", action="append", nargs=2, metavar="DIR", help="Get a remote directory, ex: \\\\Windows\\\\Temp\\\\testing testing") + tgroup.add_argument("--recursive", default=False, action="store_true", help="Recursively get a folder") tgroup.add_argument("--append-host", action="store_true", help="append the host to the get-file filename") cgroup = smb_parser.add_argument_group("Command Execution", "Options for executing commands") From b551d11e4027590c988089a9212bc80b0d5f78eb Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Tue, 21 May 2024 20:37:10 -0400 Subject: [PATCH 08/13] smb: clean up get-folder code --- nxc/protocols/smb.py | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index 0d6ae11a6..dd2581866 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -1302,33 +1302,31 @@ def download_folder(self, folder, dest, recursive=False, base_dir=None): normalized_folder = ntpath.normpath(folder) base_folder = os.path.basename(normalized_folder) self.logger.debug(f"Base folder: {base_folder}") - folder_wildcard = ntpath.join(folder, "*") - self.logger.debug(f"Requesting folder '{normalized_folder}' from share {self.args.share}") - items = self.conn.listPath(self.args.share, folder_wildcard) + + items = self.conn.listPath(self.args.share, ntpath.join(folder, "*")) self.logger.debug(f"{len(items)} items in folder: {items}") for item in items: - if item.get_longname() not in [".", ".."]: - if item.is_directory(): - dir_path = ntpath.normpath(os.path.join(normalized_folder, item.get_longname())) - if recursive: - self.download_folder(dir_path, dest, recursive, base_dir or folder) - else: - filename = item.get_longname() - remote_file_path = ntpath.join(folder, filename) - self.logger.debug(f"File found: {remote_file_path}") - relative_path = folder.replace(base_dir or folder, "").lstrip("\\") - relative_path = os.path.join(*relative_path.split("\\")) + 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, 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, filename) - self.logger.debug(f"Saving {remote_file_path} to {local_file_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) self.get_file_single(remote_file_path, local_file_path) - + def get_folder(self): recursive = self.args.recursive for folder, dest in self.args.get_folder: - self.download_folder(folder, dest, recursive, None) + self.download_folder(folder, dest, recursive) def enable_remoteops(self): try: From d17460de9e3b7b8c27abcbc3ed1f7efc4c97a5a7 Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Wed, 22 May 2024 05:47:06 -0400 Subject: [PATCH 09/13] add function to get loot data folder for modules --- nxc/helpers/modules.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nxc/helpers/modules.py b/nxc/helpers/modules.py index fe4159123..a29617e15 100644 --- a/nxc/helpers/modules.py +++ b/nxc/helpers/modules.py @@ -33,3 +33,6 @@ def add_loot_data(module_name, filename, 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}" From beb0885945ab344acef71a8ccac34b03f4b04237 Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Wed, 22 May 2024 05:47:39 -0400 Subject: [PATCH 10/13] get_gpos: remove download option as you can only retrieve certain info from LDAP --- nxc/modules/get_gpos.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/nxc/modules/get_gpos.py b/nxc/modules/get_gpos.py index f8e857971..29460f52b 100644 --- a/nxc/modules/get_gpos.py +++ b/nxc/modules/get_gpos.py @@ -1,5 +1,4 @@ from impacket.ldap import ldapasn1 as ldapasn1_impacket -from nxc.helpers.modules import get_loot_data_filepath class NXCModule: """Module by @Marshall-Hallenbeck @@ -18,23 +17,16 @@ def __init__(self): self.gpo_name = None self.fuzzy_search = False self.all_props = False - self.download = False - self.report = True - self.report_file = get_loot_data_filepath(self.name, "gpos.txt") 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) - REPORT Save GPO information to a file (default True) - REPORT_FILE File to save GPO information to (by default saves to loot directory) - DOWNLOAD Attempt to download GPOs retrieved (default) """ self.gpo_name = module_options.get("NAME") self.fuzzy_search = module_options.get("FUZZY") self.all_props = module_options.get("ALL_PROPS") - self.download = module_options.get("DOWNLOAD") def on_login(self, context, connection): # name is actually the GUID of the GPO From 8e1bc7b504880f43788f24c166053b60b91d13d5 Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Tue, 18 Jun 2024 11:53:37 -0400 Subject: [PATCH 11/13] feat: add all updated folder download stuff from #335 since that may not be merged --- nxc/protocols/smb.py | 50 +++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index 4648bc64d..22a7c71a2 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, @@ -1306,26 +1307,44 @@ 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, base_dir=None): + 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}") @@ -1338,7 +1357,7 @@ def download_folder(self, folder, dest, recursive=False, base_dir=None): 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, recursive, base_dir or folder) + 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 @@ -1348,7 +1367,10 @@ def download_folder(self, folder, dest, recursive=False, base_dir=None): self.logger.debug(f"{dest=} {remote_file_path=} {relative_path=} {local_folder_path=} {local_file_path=}") os.makedirs(local_folder_path, exist_ok=True) - self.get_file_single(remote_file_path, local_file_path) + 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 From e70955af5b2fcab43adff5ab7add2127f54cd60a Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Sat, 7 Sep 2024 19:07:42 -0400 Subject: [PATCH 12/13] tests: remove options call for get_gpos --- tests/e2e_commands.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index aedc78d4e..6818f9bb2 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -181,7 +181,6 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M user-de 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 --options 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" From 1c2131bbaf39def0c68e2701fda8ec01d6f91c10 Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Sat, 7 Sep 2024 19:18:19 -0400 Subject: [PATCH 13/13] update example module to include new add loot function and fix the name --- nxc/modules/example_module.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/nxc/modules/example_module.py b/nxc/modules/example_module.py index ed935c89c..20bf80d64 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? @@ -46,8 +46,10 @@ def on_login(self, context, connection): 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.