From d7ceb8faa7f00bcc4f1f9f28970c1b065d70e1f7 Mon Sep 17 00:00:00 2001 From: ledrypotato Date: Fri, 21 Mar 2025 18:09:17 +0100 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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): """ From a650f7da0c853bd96a9732c00c1b4fb00e4c68e6 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 13:02:24 +0100 Subject: [PATCH 12/13] add helper function --- nxc/helpers/misc.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/nxc/helpers/misc.py b/nxc/helpers/misc.py index 7f2d7a5f4a..9325766682 100755 --- a/nxc/helpers/misc.py +++ b/nxc/helpers/misc.py @@ -8,6 +8,7 @@ from ipaddress import ip_address from nxc.logger import nxc_logger from time import strftime, gmtime +from datetime import datetime, timedelta def identify_target_file(target_file): @@ -226,6 +227,18 @@ def convert(low, high, lockout=False): return time +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 display_modules(args, modules): for category, color in {CATEGORY.ENUMERATION: "green", CATEGORY.CREDENTIAL_DUMPING: "cyan", CATEGORY.PRIVILEGE_ESCALATION: "magenta"}.items(): # Add category filter for module listing From e5054035cb36da134eea7ab2c5822157bb1b739b Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 13:05:32 +0100 Subject: [PATCH 13/13] replace recursion with spider method and handle nested folders better --- nxc/modules/recyclebin.py | 246 +++++++++++++++++++++++--------------- 1 file changed, 148 insertions(+), 98 deletions(-) diff --git a/nxc/modules/recyclebin.py b/nxc/modules/recyclebin.py index c7c3c31d79..18b7aa02b2 100644 --- a/nxc/modules/recyclebin.py +++ b/nxc/modules/recyclebin.py @@ -1,11 +1,9 @@ -from os import makedirs -from nxc.helpers.misc import CATEGORY +from os import makedirs, remove +from nxc.helpers.misc import CATEGORY, convert_filetime_to_datetime from nxc.paths import NXC_PATH from os.path import join, abspath -from datetime import datetime, timedelta import struct import re -from io import BytesIO class NXCModule: @@ -13,14 +11,12 @@ class NXCModule: 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." supported_protocols = ["smb"] category = CATEGORY.CREDENTIAL_DUMPING - false_positive = [".", "..", "desktop.ini", "S-1-5-18",] + false_positive = (".", "..", "desktop.ini", "S-1-5-18") def options(self, context, module_options): """ @@ -33,99 +29,153 @@ def options(self, context, module_options): 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: - if "STATUS_FILE_IS_A_DIRECTORY" in str(e): - context.log.debug(f"Couldn't read file {file_path}: {e}") + def on_admin_login(self, context, connection): + metadata_map = {} + size_map = {} + + paths = connection.spider("C$", folder="$Recycle.Bin", regex=[r"(.*)"], silent=True) + filtered_paths = [path for path in paths if not path.endswith(self.false_positive)] + if not filtered_paths: + context.log.display("No files found in the Recycle Bin.") + return + + remote_full_paths = [f"C$/{path}" for path in filtered_paths] + + # extract the SID as the third component of the path (e.g. C$/$Recycle.Bin/SID/filename) + sid_dirs = [] + for path in remote_full_paths: + sid_dir = path.split("/")[2] + if sid_dir not in sid_dirs: + sid_dirs.append(sid_dir) + + # extract the full file path after the SID (e.g. filename or $Rfolder/filename) and remove empty values + remote_file_paths = ["/".join(path.split("/")[3:]) for path in remote_full_paths if "/".join(path.split("/")[3:])] + + # parse the remote_files_paths and create a mapping of SID to the files that belong to that SID directory + sid_to_files = {} + for path in remote_full_paths: + sid_dir = path.split("/")[2] + if sid_dir not in sid_to_files: + sid_to_files[sid_dir] = [] + sid_to_files[sid_dir].append(path) + + context.log.display(f"Found {len(remote_file_paths)} files in the Recycle Bin across {len(sid_dirs)} user directories. Processing files...") + + # check for each key in sid_to_files, if the value is a list with only 1 element and if that element is the same as the SID directory then we can skip processing that SID directory because it means that there are no files in that SID directory + for sid_dir, files in sid_to_files.items(): + if len(files) == 1 and files[0].split("/")[2] == sid_dir: + context.log.display(f"No files found in C$\\$Recycle.Bin\\{sid_dir}. Skipping...") else: - context.log.debug(f"Couldn't 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) + context.log.highlight(f"Processing directory: C$\\$Recycle.Bin\\{sid_dir} with {len(files) - 1} file(s)...") + for file in files: + context.log.debug(f"Found file: {file}") + + # Process files in the directory + # Files in the Recycle Bin have two types of names: + # $Recycle.Bin\S-1-5-21-4140170355-2927207985-2497279808-500\$I87021Q.txt + # Or + # $Recycle.Bin\S-1-5-21-4140170355-2927207985-2497279808-500\$R87021Q.txt + # $I files are metadata files while $R are the actual files + + for remote_file_path in remote_file_paths: + remote_full_path = f"$Recycle.Bin/{sid_dir}/{remote_file_path}" + + # Process Metadata files ($I) to extract original path, deletion time and size + if remote_file_path.startswith("$I"): + export_path = join(NXC_PATH, "modules", "recyclebin") + makedirs(export_path, exist_ok=True) + + local_filename = f"{connection.host}_{sid_dir}_{remote_file_path.replace('/', '_')}" + dest_path = abspath(join(export_path, local_filename)) + + with open(dest_path, "wb+") as f: + connection.conn.getFile("C$", remote_full_path, f.write) + f.close() + + with open(dest_path, "rb") as f: + data = f.read() + if len(data) >= 24: + file_size_raw, = struct.unpack("= 1024 else f"{file_size_raw}B" + filename_display = f"Folder: {remote_file_path}" if len(remote_file_path) <= 8 else f"File: {remote_file_path}" + context.log.highlight(f"\t{filename_display}, Original location: {original_path}, Deletion time: {deletion_time}, Original size: {size_display}") + + # close and delete the metadata file since we have already extracted the information we need from it + try: + remove(dest_path) + context.log.debug(f"Deleted metadata file: {dest_path}") + except Exception: + context.log.debug(f"Could not delete metadata file: {dest_path}") + + # Process actual files ($R) + elif remote_file_path.startswith("$R"): + # Determine if we're nested inside a $R folder + # path example (nested): "$Recycle.Bin/SID/$RABGQM6/alice-passwords.txt" + # path example (direct): "$Recycle.Bin/SID/$RR2H6QW.txt" + path_parts = remote_file_path.rstrip("/").split("/") + r_folder = next((p for p in path_parts if p.startswith("$R")), None) + is_nested = r_folder is not None and remote_file_path != r_folder + + if is_nested: + # Look up the original path of the parent $R folder + r_key = r_folder.replace("$R", "") + parent_original = metadata_map.get(r_key, f"{sid_dir}\\{r_folder}") + # Append the nested filename to reconstruct the full original path + original_path = f"{parent_original}\\{remote_file_path.split('/')[-1]}" else: - # Process files in the directory - # Files in the Recycle Bin have two types of names: - # $Recycle.Bin\S-1-5-21-4140170355-2927207985-2497279808-500\/$I87021Q.txt - # Or - # $Recycle.Bin\S-1-5-21-4140170355-2927207985-2497279808-500\/$R87021Q.txt - # $I files are metadata while $R are actual files - - if item.get_longname().startswith("$I"): - # Process Metadata file ($I) to extract original path and deletion time - data = self.read_file(connection, context, item_path) - if len(data) >= 24: - deletion_time_raw, = struct.unpack("= 1024 else f"{file_size_raw}B" if file_size_raw else "unknown" + filename_display = f"Folder: {remote_file_path}" if len(remote_file_path) <= 8 else f"File: {remote_file_path}" + + context.log.highlight(f"\t{filename_display} ({original_path}), size: {size_display}") + + # Download the file if the option is set to True + if self.download: + # Apply filter if specified + 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 {remote_file_path} ({original_path})") + continue + context.log.info(f"\tDownloading file {remote_file_path} from {original_path}") - 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}") + local_filename = f"{connection.host}_{original_path}" + export_path = join(NXC_PATH, "modules", "recyclebin") + path = abspath(join(export_path, local_filename)) + makedirs(export_path, exist_ok=True) - self.process_recycle_bin_directory(connection, context, sid_dir, metadata_map) + if (len(remote_file_path) <= 8): # if it's a folder we can't download it and we should skip it + context.log.debug(f"Skipping download of {remote_file_path} because it appears to be an empty directory.") + continue + try: + with open(path, "wb") as f: + connection.conn.getFile("C$", remote_full_path, f.write) + context.log.success(f"Recycle Bin file {remote_file_path} written to: {path}") + f.close() + except Exception as e: + if "STATUS_FILE_IS_A_DIRECTORY" in str(e): + context.log.debug(f"Couldn't download {dest_path} because of {e}")