From d7ceb8faa7f00bcc4f1f9f28970c1b065d70e1f7 Mon Sep 17 00:00:00 2001 From: ledrypotato Date: Fri, 21 Mar 2025 18:09:17 +0100 Subject: [PATCH 01/11] initial Recycle Bin module --- nxc/modules/recycle_bin.py | 106 +++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 nxc/modules/recycle_bin.py diff --git a/nxc/modules/recycle_bin.py b/nxc/modules/recycle_bin.py new file mode 100644 index 0000000000..55804278d5 --- /dev/null +++ b/nxc/modules/recycle_bin.py @@ -0,0 +1,106 @@ +from io import BytesIO +from os import makedirs +from os.path import join, abspath +from nxc.paths import NXC_PATH +import re + + +# TODO implement the display of file deletion time to know when the file was deleted (this information should be in the metadata file but I couldn't parse it correctly) +# TODO handle directories in the Recycle Bin as well as single files +# TODO specify what files you want to download as a filter + +class NXCModule: + # Finds files in the Recycle Bin + # Module by @leDryPotato + + name = "recycle_bin" + description = "Lists (and downloads) files in the Recycle Bin." + supported_protocols = ["smb"] + opsec_safe = True + multiple_hosts = True + false_positive = [".", "..", "desktop.ini", "S-1-5-18"] + + def __init__(self, context=None, module_options=None): + self.context = context + self.module_options = module_options + + def options(self, context, module_options): + ''' + DOWNLOAD Download the files in the Recycle Bin (default:False), enable by specifying -o DOWNLOAD=True + ''' + self.download = bool(module_options.get("DOWNLOAD", False)) + + def read_file(self, connection, context, file_path): + buf = BytesIO() + try: + connection.conn.getFile("C$", file_path, buf.write) + except Exception as e: + context.log.debug(f"Cannot read file {file_path}: {e}") + + buf.seek(0) + binary_data = buf.read() + return binary_data + + def on_admin_login(self, context, connection): + found_dirs = 0 + found_files = 0 + metadata_map = {} + + for directory in connection.conn.listPath("C$", "$Recycle.Bin\\*"): + if directory.get_longname() not in self.false_positive and directory.is_directory(): + # Each directory corresponds to a different user account, the SID identifies the user + sid_dir = f"$Recycle.Bin\\{directory.get_longname()}" + if(sid_dir is not None): + context.log.highlight(f"Found directory {sid_dir}") + found_dirs += 1 + + for file in connection.conn.listPath("C$", f"{sid_dir}\\*"): + # File naming convention for files in the Recycle Bin + # $R: actual file content + # $I: associated metadata file + try: + # Metadata files (start with $I) + if file.get_longname() not in self.false_positive and file.get_longname().startswith("$I"): + file_path = f"{sid_dir}\\{file.get_longname()}" + + # The structure of the metadata file contains the file deletion time, the file size and the original file path + data = self.read_file(connection, context, file_path) + # Get original location of the deleted file from the associated metadata file, this can help determine if we want to download it or not + if len(data) > 16: + # Extract and clean path + original_path = data[16:].decode("utf-16", errors="ignore").strip("\x00") + match = re.search(r"([a-z]:\\.+)", original_path, re.IGNORECASE) + if match: + original_path = match.group(1) + metadata_map[file.get_longname().replace("$I", "")] = original_path + context.log.highlight(f"\tFile: {file.get_longname()}, Original location: {original_path}") + else: + context.log.info(f"\tInvalid metadata file: {file.get_longname()}") + found_files += 1 + except Exception as e: + context.log.debug(f"Error parsing metadata file: {e}") + try: + #Actual files (start with $R) + if file.get_longname() not in self.false_positive and file.get_longname().startswith("$R"): + file_path = f"{sid_dir}\\{file.get_longname()}" + context.log.highlight(f"\tFile: {file.get_longname()}, size: {file.get_filesize()}KB") + + # Download files if the module option is set + if self.download: + context.log.info(f"Downloading {file_path}") + data = self.read_file(connection, context, file_path) + file_content = data.decode("utf-8", errors="ignore") + original_path = metadata_map.get(file.get_longname().replace("$R", ""), "unknown_file") + filename = f"{connection.host}_{original_path}" + export_path = join(NXC_PATH, "modules", "recycle_bin") + path = abspath(join(export_path, filename)) + makedirs(export_path, exist_ok=True) + try: + with open(path, "w+") as f: + f.write(file_content) + context.log.success(f"Recycle Bin file {file.get_longname()} written to: {path}") + except Exception as e: + context.log.fail(f"Failed to write Recycle Bin file to {filename}: {e}") + found_files += 1 + except Exception as e: + context.log.debug(f"Error parsing content file: {e}") \ No newline at end of file From 4f9b631830fb57bb37ca93d2f6ccdf4664a324a0 Mon Sep 17 00:00:00 2001 From: ledrypotato Date: Fri, 21 Mar 2025 18:16:33 +0100 Subject: [PATCH 02/11] add e2e_commands --- tests/e2e_commands.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index 4a36484f21..1711379ae1 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -138,6 +138,8 @@ netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M putty netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M rdcman #netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M rdp -o ACTION=enable #netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M rdp -o ACTION=disable +netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M recycle_bin +netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M recycle_bin -o DOWNLOAD=true netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M reg-query -o PATH=HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion KEY=DevicePath netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M runasppl netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M scuffy -o SERVER=127.0.0.1 NAME=test From 7ceab059dc7479448ef763599176c5799908d3cd Mon Sep 17 00:00:00 2001 From: ledrypotato Date: Fri, 21 Mar 2025 18:17:07 +0100 Subject: [PATCH 03/11] cleanup with Ruff --- nxc/modules/recycle_bin.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/nxc/modules/recycle_bin.py b/nxc/modules/recycle_bin.py index 55804278d5..c51c64607b 100644 --- a/nxc/modules/recycle_bin.py +++ b/nxc/modules/recycle_bin.py @@ -25,9 +25,7 @@ def __init__(self, context=None, module_options=None): self.module_options = module_options def options(self, context, module_options): - ''' - DOWNLOAD Download the files in the Recycle Bin (default:False), enable by specifying -o DOWNLOAD=True - ''' + """DOWNLOAD Download the files in the Recycle Bin (default:False), enable by specifying -o DOWNLOAD=True""" self.download = bool(module_options.get("DOWNLOAD", False)) def read_file(self, connection, context, file_path): @@ -38,8 +36,7 @@ def read_file(self, connection, context, file_path): context.log.debug(f"Cannot read file {file_path}: {e}") buf.seek(0) - binary_data = buf.read() - return binary_data + return buf.read() def on_admin_login(self, context, connection): found_dirs = 0 @@ -50,7 +47,7 @@ def on_admin_login(self, context, connection): if directory.get_longname() not in self.false_positive and directory.is_directory(): # Each directory corresponds to a different user account, the SID identifies the user sid_dir = f"$Recycle.Bin\\{directory.get_longname()}" - if(sid_dir is not None): + if (sid_dir is not None): context.log.highlight(f"Found directory {sid_dir}") found_dirs += 1 @@ -80,21 +77,21 @@ def on_admin_login(self, context, connection): except Exception as e: context.log.debug(f"Error parsing metadata file: {e}") try: - #Actual files (start with $R) + # Actual files (start with $R) if file.get_longname() not in self.false_positive and file.get_longname().startswith("$R"): file_path = f"{sid_dir}\\{file.get_longname()}" context.log.highlight(f"\tFile: {file.get_longname()}, size: {file.get_filesize()}KB") # Download files if the module option is set if self.download: - context.log.info(f"Downloading {file_path}") - data = self.read_file(connection, context, file_path) - file_content = data.decode("utf-8", errors="ignore") - original_path = metadata_map.get(file.get_longname().replace("$R", ""), "unknown_file") - filename = f"{connection.host}_{original_path}" - export_path = join(NXC_PATH, "modules", "recycle_bin") - path = abspath(join(export_path, filename)) - makedirs(export_path, exist_ok=True) + context.log.info(f"Downloading {file_path}") + data = self.read_file(connection, context, file_path) + file_content = data.decode("utf-8", errors="ignore") + original_path = metadata_map.get(file.get_longname().replace("$R", ""), "unknown_file") + filename = f"{connection.host}_{original_path}" + export_path = join(NXC_PATH, "modules", "recycle_bin") + path = abspath(join(export_path, filename)) + makedirs(export_path, exist_ok=True) try: with open(path, "w+") as f: f.write(file_content) From f2819b679cdb4b2da38e31c0f326e1e6b84e2ac2 Mon Sep 17 00:00:00 2001 From: ledrypotato Date: Mon, 31 Mar 2025 23:47:15 +0200 Subject: [PATCH 04/11] handled retrieving deletion time + filter for file download --- nxc/modules/recycle_bin.py | 80 ++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/nxc/modules/recycle_bin.py b/nxc/modules/recycle_bin.py index c51c64607b..123cf4b563 100644 --- a/nxc/modules/recycle_bin.py +++ b/nxc/modules/recycle_bin.py @@ -3,11 +3,10 @@ from os.path import join, abspath from nxc.paths import NXC_PATH import re +from datetime import datetime, timedelta +import struct - -# TODO implement the display of file deletion time to know when the file was deleted (this information should be in the metadata file but I couldn't parse it correctly) # TODO handle directories in the Recycle Bin as well as single files -# TODO specify what files you want to download as a filter class NXCModule: # Finds files in the Recycle Bin @@ -25,8 +24,15 @@ def __init__(self, context=None, module_options=None): self.module_options = module_options def options(self, context, module_options): - """DOWNLOAD Download the files in the Recycle Bin (default:False), enable by specifying -o DOWNLOAD=True""" + """ + DOWNLOAD Download the files in the Recycle Bin (default: False) + Example: -o DOWNLOAD=True + FILTER Filter what files you want to download (default: all) based on their original location, supports regular expressions + Examples: -o FILTER=pass + -o FILTER=ssh + """ self.download = bool(module_options.get("DOWNLOAD", False)) + self.filter = module_options.get("FILTER", "all") def read_file(self, connection, context, file_path): buf = BytesIO() @@ -38,9 +44,18 @@ def read_file(self, connection, context, file_path): buf.seek(0) return buf.read() + def convert_filetime_to_datetime(self, filetime): + """Convert Windows FILETIME to a readable timestamp rounded to the closest minute.""" + try: + WINDOWS_EPOCH = datetime(1601, 1, 1) # Windows FILETIME epoch + + timestamp = filetime / 10_000_000 # Convert 100-ns intervals to seconds + dt = WINDOWS_EPOCH + timedelta(seconds=timestamp) + return dt.replace(microsecond=0) + except Exception: + return "Conversion Error" + def on_admin_login(self, context, connection): - found_dirs = 0 - found_files = 0 metadata_map = {} for directory in connection.conn.listPath("C$", "$Recycle.Bin\\*"): @@ -49,7 +64,6 @@ def on_admin_login(self, context, connection): sid_dir = f"$Recycle.Bin\\{directory.get_longname()}" if (sid_dir is not None): context.log.highlight(f"Found directory {sid_dir}") - found_dirs += 1 for file in connection.conn.listPath("C$", f"{sid_dir}\\*"): # File naming convention for files in the Recycle Bin @@ -62,18 +76,23 @@ def on_admin_login(self, context, connection): # The structure of the metadata file contains the file deletion time, the file size and the original file path data = self.read_file(connection, context, file_path) - # Get original location of the deleted file from the associated metadata file, this can help determine if we want to download it or not - if len(data) > 16: - # Extract and clean path - original_path = data[16:].decode("utf-16", errors="ignore").strip("\x00") + # Get original location/deletion time of the deleted file from the associated metadata file, this can help determine if we want to download it or not + if len(data) < 24: + context.log.info(f"\tInvalid metadata file: {file.get_longname()} (too small: {len(data)} bytes)") + else: + # Read 8 bytes for the deletion time + deletion_time_raw, = struct.unpack(" Date: Tue, 1 Apr 2025 01:16:54 +0200 Subject: [PATCH 05/11] handle recursive folders as best as i can --- nxc/modules/recycle_bin.py | 125 +++++++++++++++++-------------------- 1 file changed, 57 insertions(+), 68 deletions(-) diff --git a/nxc/modules/recycle_bin.py b/nxc/modules/recycle_bin.py index 123cf4b563..a36a1d51aa 100644 --- a/nxc/modules/recycle_bin.py +++ b/nxc/modules/recycle_bin.py @@ -6,8 +6,6 @@ from datetime import datetime, timedelta import struct -# TODO handle directories in the Recycle Bin as well as single files - class NXCModule: # Finds files in the Recycle Bin # Module by @leDryPotato @@ -55,6 +53,62 @@ def convert_filetime_to_datetime(self, filetime): except Exception: return "Conversion Error" + def process_recycle_bin_directory(self, connection, context, sid_dir, metadata_map, depth=0): + """Recursively process the Recycle Bin directory and its subdirectories.""" + for item in connection.conn.listPath("C$", f"{sid_dir}\\*"): + try: + if item.get_longname() in self.false_positive: + continue + + item_path = f"{sid_dir}\\{item.get_longname()}" + if item.is_directory(): + for _ in range(depth, depth + 1): + context.log.highlight(f"{'\t' * (depth + 1)}Found subdirectory: {item_path}") + + # Recursively process subdirectories + self.process_recycle_bin_directory(connection, context, item_path, metadata_map, depth + 1) + else: + # Process files in the directory + if item.get_longname().startswith("$I"): + # Metadata file + data = self.read_file(connection, context, item_path) + if len(data) >= 24: + deletion_time_raw, = struct.unpack(": actual file content - # $I: associated metadata file - try: - # Metadata files (start with $I) - if file.get_longname() not in self.false_positive and file.get_longname().startswith("$I"): - file_path = f"{sid_dir}\\{file.get_longname()}" - - # The structure of the metadata file contains the file deletion time, the file size and the original file path - data = self.read_file(connection, context, file_path) - # Get original location/deletion time of the deleted file from the associated metadata file, this can help determine if we want to download it or not - if len(data) < 24: - context.log.info(f"\tInvalid metadata file: {file.get_longname()} (too small: {len(data)} bytes)") - else: - # Read 8 bytes for the deletion time - deletion_time_raw, = struct.unpack(" Date: Tue, 1 Apr 2025 01:18:45 +0200 Subject: [PATCH 06/11] cleanup with Ruff --- nxc/modules/recycle_bin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/modules/recycle_bin.py b/nxc/modules/recycle_bin.py index a36a1d51aa..952e3959b6 100644 --- a/nxc/modules/recycle_bin.py +++ b/nxc/modules/recycle_bin.py @@ -84,7 +84,7 @@ def process_recycle_bin_directory(self, connection, context, sid_dir, metadata_m else: # Actual file for _ in range(depth, depth + 1): - context.log.highlight(f"{'\t' * (depth +1)}File: {item.get_longname()}, size: {item.get_filesize()}KB") + context.log.highlight(f"{'\t' * (depth + 1)}File: {item.get_longname()}, size: {item.get_filesize()}KB") if self.download: # TODO handle reconstructing the original path better when there is no associated metadata file # Would need to access the key in metadata_map that is associated with the current directory we are in From f54abff29adcc4aa55e1c70dc362c593455b0ceb Mon Sep 17 00:00:00 2001 From: ledrypotato Date: Tue, 1 Apr 2025 01:22:36 +0200 Subject: [PATCH 07/11] add TODOs --- nxc/modules/recycle_bin.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/nxc/modules/recycle_bin.py b/nxc/modules/recycle_bin.py index 952e3959b6..8dcbc8d721 100644 --- a/nxc/modules/recycle_bin.py +++ b/nxc/modules/recycle_bin.py @@ -6,6 +6,9 @@ from datetime import datetime, timedelta import struct +# TODO handle reconstructing the original path better when there is no associated metadata file (we are in a subdirectory) +# TODO handle the struture of downloaded directories better + class NXCModule: # Finds files in the Recycle Bin # Module by @leDryPotato @@ -86,8 +89,7 @@ def process_recycle_bin_directory(self, connection, context, sid_dir, metadata_m for _ in range(depth, depth + 1): context.log.highlight(f"{'\t' * (depth + 1)}File: {item.get_longname()}, size: {item.get_filesize()}KB") if self.download: - # TODO handle reconstructing the original path better when there is no associated metadata file - # Would need to access the key in metadata_map that is associated with the current directory we are in + # Would need to access the key in metadata_map that is associated with the current directory we are in to get the original path original_path = metadata_map.get(item.get_longname().replace("$R", ""), f"{sid_dir}\\{item.get_longname()}") if self.filter and self.filter.lower() != "all": match = re.search(self.filter, original_path, re.IGNORECASE) From 6d408497fe64c30e889546499de90b8f89766c69 Mon Sep 17 00:00:00 2001 From: ledrypotato Date: Tue, 1 Apr 2025 01:30:12 +0200 Subject: [PATCH 08/11] add e2e_commands --- tests/e2e_commands.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index 1711379ae1..528eb38c94 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -140,6 +140,7 @@ netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M rdcman #netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M rdp -o ACTION=disable netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M recycle_bin netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M recycle_bin -o DOWNLOAD=true +netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M recycle_bin -o DOWNLOAD=true FILTER=pass netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M reg-query -o PATH=HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion KEY=DevicePath netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M runasppl netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M scuffy -o SERVER=127.0.0.1 NAME=test From 1a6117ae6e993c1fe9e62028ea77c60e41a25448 Mon Sep 17 00:00:00 2001 From: ledrypotato Date: Sat, 20 Sep 2025 12:12:56 +0200 Subject: [PATCH 09/11] Merged/moved my RecyleBin module into Defte's existing file. I removed the registry key logic to fetch the username since it's already given in the metadata file ($I). --- nxc/modules/recycle_bin.py | 124 ---------------------- nxc/modules/recyclebin.py | 205 ++++++++++++++++++++++--------------- tests/e2e_commands.txt | 6 +- 3 files changed, 123 insertions(+), 212 deletions(-) delete mode 100644 nxc/modules/recycle_bin.py diff --git a/nxc/modules/recycle_bin.py b/nxc/modules/recycle_bin.py deleted file mode 100644 index 8dcbc8d721..0000000000 --- a/nxc/modules/recycle_bin.py +++ /dev/null @@ -1,124 +0,0 @@ -from io import BytesIO -from os import makedirs -from os.path import join, abspath -from nxc.paths import NXC_PATH -import re -from datetime import datetime, timedelta -import struct - -# TODO handle reconstructing the original path better when there is no associated metadata file (we are in a subdirectory) -# TODO handle the struture of downloaded directories better - -class NXCModule: - # Finds files in the Recycle Bin - # Module by @leDryPotato - - name = "recycle_bin" - description = "Lists (and downloads) files in the Recycle Bin." - supported_protocols = ["smb"] - opsec_safe = True - multiple_hosts = True - false_positive = [".", "..", "desktop.ini", "S-1-5-18"] - - def __init__(self, context=None, module_options=None): - self.context = context - self.module_options = module_options - - def options(self, context, module_options): - """ - DOWNLOAD Download the files in the Recycle Bin (default: False) - Example: -o DOWNLOAD=True - FILTER Filter what files you want to download (default: all) based on their original location, supports regular expressions - Examples: -o FILTER=pass - -o FILTER=ssh - """ - self.download = bool(module_options.get("DOWNLOAD", False)) - self.filter = module_options.get("FILTER", "all") - - def read_file(self, connection, context, file_path): - buf = BytesIO() - try: - connection.conn.getFile("C$", file_path, buf.write) - except Exception as e: - context.log.debug(f"Cannot read file {file_path}: {e}") - - buf.seek(0) - return buf.read() - - def convert_filetime_to_datetime(self, filetime): - """Convert Windows FILETIME to a readable timestamp rounded to the closest minute.""" - try: - WINDOWS_EPOCH = datetime(1601, 1, 1) # Windows FILETIME epoch - - timestamp = filetime / 10_000_000 # Convert 100-ns intervals to seconds - dt = WINDOWS_EPOCH + timedelta(seconds=timestamp) - return dt.replace(microsecond=0) - except Exception: - return "Conversion Error" - - def process_recycle_bin_directory(self, connection, context, sid_dir, metadata_map, depth=0): - """Recursively process the Recycle Bin directory and its subdirectories.""" - for item in connection.conn.listPath("C$", f"{sid_dir}\\*"): - try: - if item.get_longname() in self.false_positive: - continue - - item_path = f"{sid_dir}\\{item.get_longname()}" - if item.is_directory(): - for _ in range(depth, depth + 1): - context.log.highlight(f"{'\t' * (depth + 1)}Found subdirectory: {item_path}") - - # Recursively process subdirectories - self.process_recycle_bin_directory(connection, context, item_path, metadata_map, depth + 1) - else: - # Process files in the directory - if item.get_longname().startswith("$I"): - # Metadata file - data = self.read_file(connection, context, item_path) - if len(data) >= 24: - deletion_time_raw, = struct.unpack("= 24: + deletion_time_raw, = struct.unpack(" 0: - context.log.highlight(f"Recycle bin's content downloaded to {export_path}") - except DCERPCSessionError as e: - context.log.exception(e) - context.log.fail(f"Error connecting to RemoteRegistry {e} on host {connection.host}") - finally: - remote_ops.finish() + original_path = metadata_map.get(item.get_longname().replace("$R", ""), f"{sid_dir}\\{item.get_longname()}") + # Process actual file ($R) + for _ in range(depth, depth + 1): + context.log.highlight(f"{'\t' * (depth + 1)}File: {item.get_longname()} ({original_path}), size: {item.get_filesize()}KB") + if self.download: + # Would need to access the key in metadata_map that is associated with the current directory we are in to get the original path + if self.filter and self.filter.lower() != "all": + match = re.search(self.filter, original_path, re.IGNORECASE) + if not match: + context.log.info(f"\tSkipping file {item.get_longname()} ({original_path})") + continue + context.log.info(f"\tDownloading file {item.get_longname()} from {original_path}") + data = self.read_file(connection, context, item_path) + filename = f"{connection.host}_{original_path}" + export_path = join(NXC_PATH, "modules", "recyclebin") + path = abspath(join(export_path, filename)) + makedirs(export_path, exist_ok=True) + try: + with open(path, "wb") as f: + f.write(data) + context.log.success(f"Recycle Bin file {item.get_longname()} written to: {path}") + except Exception as e: + context.log.fail(f"Failed to write Recycle Bin file to {filename}: {e}") + except Exception as e: + context.log.debug(f"Error processing item {item.get_longname()}: {e}") + + def on_admin_login(self, context, connection): + metadata_map = {} + + for directory in connection.conn.listPath("C$", "$Recycle.Bin\\*"): + if directory.get_longname() not in self.false_positive and directory.is_directory(): + # Each directory corresponds to a different user account, the SID identifies the user + sid_dir = f"$Recycle.Bin\\{directory.get_longname()}" + if (sid_dir is not None): + context.log.highlight(f"Found directory {sid_dir}") + + self.process_recycle_bin_directory(connection, context, sid_dir, metadata_map) diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index 528eb38c94..a971dd7603 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -138,9 +138,9 @@ netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M putty netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M rdcman #netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M rdp -o ACTION=enable #netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M rdp -o ACTION=disable -netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M recycle_bin -netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M recycle_bin -o DOWNLOAD=true -netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M recycle_bin -o DOWNLOAD=true FILTER=pass +netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M recyclebin +netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M recyclebin -o DOWNLOAD=true +netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M recyclebin -o DOWNLOAD=true FILTER=pass netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M reg-query -o PATH=HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion KEY=DevicePath netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M runasppl netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M scuffy -o SERVER=127.0.0.1 NAME=test From ef0f697755271fed4b47dba228e9efee0513e81b Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 14 Mar 2026 11:50:21 -0400 Subject: [PATCH 10/11] Formatting --- nxc/modules/recyclebin.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nxc/modules/recyclebin.py b/nxc/modules/recyclebin.py index 9be439386c..32f47810b4 100644 --- a/nxc/modules/recyclebin.py +++ b/nxc/modules/recyclebin.py @@ -7,13 +7,14 @@ import re from io import BytesIO -# TODO handle reconstructing the original path better when there is no associated metadata file (we are in a subdirectory) -# TODO handle the struture of downloaded directories better - class NXCModule: - # Module by @Defte_ & @leDryPotato - # Find (and download) files from Recycle Bins + """ + Module by @Defte_ & @leDryPotato + Find (and download) files from Recycle Bins + """ + # TODO handle reconstructing the original path better when there is no associated metadata file (we are in a subdirectory) + # TODO handle the struture of downloaded directories better name = "recyclebin" description = "Lists (and downloads) files in the Recycle Bin." From ea3bd521984bb8b44230482f7ef20655ee0a4123 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 14 Mar 2026 11:50:40 -0400 Subject: [PATCH 11/11] Formatting --- nxc/modules/recyclebin.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/nxc/modules/recyclebin.py b/nxc/modules/recyclebin.py index 32f47810b4..c7c3c31d79 100644 --- a/nxc/modules/recyclebin.py +++ b/nxc/modules/recyclebin.py @@ -19,14 +19,8 @@ class NXCModule: name = "recyclebin" description = "Lists (and downloads) files in the Recycle Bin." supported_protocols = ["smb"] - opsec_safe = True - multiple_hosts = True - false_positive = [".", "..", "desktop.ini", "S-1-5-18",] category = CATEGORY.CREDENTIAL_DUMPING - - def __init__(self, context=None, module_options=None): - self.context = context - self.module_options = module_options + false_positive = [".", "..", "desktop.ini", "S-1-5-18",] def options(self, context, module_options): """