-
Notifications
You must be signed in to change notification settings - Fork 708
Recycle bin module #609
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Recycle bin module #609
Changes from all commits
d7ceb8f
4f9b631
7ceab05
f2819b6
802a51b
49abf8a
f54abff
6d40849
1a6117a
a97ecae
ef0f697
ea3bd52
7f6bd10
1107d3b
a650f7d
e505403
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,101 +1,181 @@ | ||||||||||||||||||
| 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 impacket.dcerpc.v5 import rrp | ||||||||||||||||||
| from impacket.dcerpc.v5.rrp import DCERPCSessionError | ||||||||||||||||||
| from impacket.examples.secretsdump import RemoteOperations | ||||||||||||||||||
| import struct | ||||||||||||||||||
| import re | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| class NXCModule: | ||||||||||||||||||
| # Module by @Defte_ | ||||||||||||||||||
| # Dumps files from recycle bins | ||||||||||||||||||
| """ | ||||||||||||||||||
| Module by @Defte_ & @leDryPotato | ||||||||||||||||||
| Find (and download) files from Recycle Bins | ||||||||||||||||||
| """ | ||||||||||||||||||
|
|
||||||||||||||||||
| name = "recyclebin" | ||||||||||||||||||
| description = "Lists and exports users' recycle bins" | ||||||||||||||||||
| description = "Lists (and downloads) files in the Recycle Bin." | ||||||||||||||||||
| supported_protocols = ["smb"] | ||||||||||||||||||
| category = CATEGORY.CREDENTIAL_DUMPING | ||||||||||||||||||
| false_positive = (".", "..", "desktop.ini", "S-1-5-18") | ||||||||||||||||||
|
|
||||||||||||||||||
| def options(self, context, module_options): | ||||||||||||||||||
| """No options available""" | ||||||||||||||||||
| """ | ||||||||||||||||||
| 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 filename (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 on_admin_login(self, context, connection): | ||||||||||||||||||
| false_positive_users = [".", "..", "desktop.ini", "Public", "Default", "Default User", "All Users", ".NET v4.5", ".NET v4.5 Classic"] | ||||||||||||||||||
| found = 0 | ||||||||||||||||||
| try: | ||||||||||||||||||
| remote_ops = RemoteOperations(connection.conn, connection.kerberos) | ||||||||||||||||||
| remote_ops.enableRegistry() | ||||||||||||||||||
| metadata_map = {} | ||||||||||||||||||
| size_map = {} | ||||||||||||||||||
|
|
||||||||||||||||||
| for sid_directory in connection.conn.listPath("C$", "$Recycle.Bin\\*"): | ||||||||||||||||||
| 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)] | ||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe we should do the filtering before the spidering, otherwise we waste unnecessary runtime (potentially a lot if these have large dirs) |
||||||||||||||||||
| if not filtered_paths: | ||||||||||||||||||
| context.log.display("No files found in the Recycle Bin.") | ||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that should be |
||||||||||||||||||
| 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.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}") | ||||||||||||||||||
|
Comment on lines
+44
to
+71
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks overly complicated, what is that trying to achieve? Can't we just do a log statement in the primary looping logic down below? Also, it looks to me like currently we are only looping over the last iterated |
||||||||||||||||||
|
|
||||||||||||||||||
| # 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) | ||||||||||||||||||
|
Comment on lines
+85
to
+86
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just do that once at the start of the module execution, otherwise this will be checked in every loop which is unnecessary and in addition is duplicate to the logic in L167-L169 |
||||||||||||||||||
|
|
||||||||||||||||||
| 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() | ||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You don't need to close the file, this is already done by the context manager ( |
||||||||||||||||||
|
|
||||||||||||||||||
| with open(dest_path, "rb") as f: | ||||||||||||||||||
| data = f.read() | ||||||||||||||||||
| if len(data) >= 24: | ||||||||||||||||||
| file_size_raw, = struct.unpack("<q", data[8:16]) # original file size | ||||||||||||||||||
| deletion_time_raw, = struct.unpack("<Q", data[16:24]) # original file path | ||||||||||||||||||
| deletion_time = convert_filetime_to_datetime(self, deletion_time_raw) | ||||||||||||||||||
| original_path = data[24:].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[remote_file_path.replace("$I", "")] = original_path | ||||||||||||||||||
| size_map[remote_file_path.replace("$I", "")] = file_size_raw | ||||||||||||||||||
| size_display = f"{file_size_raw // 1024}KB" if file_size_raw >= 1024 else f"{file_size_raw}B" | ||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should probably move the Lines 754 to 761 in 1884cd3
|
||||||||||||||||||
| 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: | ||||||||||||||||||
| if sid_directory.get_longname() and sid_directory.get_longname() not in false_positive_users: | ||||||||||||||||||
|
|
||||||||||||||||||
| # Extracts the username from the SID | ||||||||||||||||||
| reg_handle = rrp.hOpenLocalMachine(remote_ops._RemoteOperations__rrp)["phKey"] | ||||||||||||||||||
| key_handle = rrp.hBaseRegOpenKey(remote_ops._RemoteOperations__rrp, reg_handle, f"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\ProfileList\\{sid_directory.get_longname()}")["phkResult"] | ||||||||||||||||||
| username = None | ||||||||||||||||||
| try: | ||||||||||||||||||
| _, profileimagepath = rrp.hBaseRegQueryValue(remote_ops._RemoteOperations__rrp, key_handle, "ProfileImagePath\x00") | ||||||||||||||||||
| # Get username and remove embedded null byte | ||||||||||||||||||
| username = profileimagepath.split("\\")[-1].rstrip("\x00") | ||||||||||||||||||
| except rrp.DCERPCSessionError as e: | ||||||||||||||||||
| context.log.debug(f"Couldn't get username from SID {e} on host {connection.host}") | ||||||||||||||||||
|
|
||||||||||||||||||
| # Lists for any file or directory in the recycle bin | ||||||||||||||||||
| spider_folder = f"$Recycle.Bin\\{sid_directory.get_longname()}\\" | ||||||||||||||||||
| paths = connection.spider( | ||||||||||||||||||
| "C$", | ||||||||||||||||||
| folder=spider_folder, | ||||||||||||||||||
| regex=[r"(.*)"], | ||||||||||||||||||
| silent=True | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| false_positive = (".", "..", "desktop.ini") | ||||||||||||||||||
| filtered_file_paths = [path for path in paths if not path.endswith(false_positive)] | ||||||||||||||||||
| if filtered_file_paths: | ||||||||||||||||||
| if username is not None: | ||||||||||||||||||
| context.log.highlight(f"CONTENT FOUND {sid_directory.get_longname()} ({username})") | ||||||||||||||||||
| else: | ||||||||||||||||||
| context.log.highlight(f"CONTENT FOUND {sid_directory.get_longname()}") | ||||||||||||||||||
|
|
||||||||||||||||||
| for path in filtered_file_paths: | ||||||||||||||||||
| # Returned path look like: | ||||||||||||||||||
| # $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 so we split the path from the SID | ||||||||||||||||||
| # And check that the filename contains $R only to prevent downloading useless stuff | ||||||||||||||||||
|
|
||||||||||||||||||
| if "$R" in path.split(sid_directory.get_longname())[1] and not path.endswith(false_positive): | ||||||||||||||||||
| # Create the export path | ||||||||||||||||||
| export_path = join(NXC_PATH, "modules", "recyclebin") | ||||||||||||||||||
| makedirs(export_path, exist_ok=True) | ||||||||||||||||||
|
|
||||||||||||||||||
| # Formatting the destination filename | ||||||||||||||||||
| file_path = path.split("$")[-1].replace("/", "_") | ||||||||||||||||||
| filename = f"{connection.host}_{username if username else sid_directory.get_longname()}_recyclebin_{file_path}" | ||||||||||||||||||
| dest_path = abspath(join(export_path, filename)) | ||||||||||||||||||
| try: | ||||||||||||||||||
| with open(dest_path, "wb+") as file: | ||||||||||||||||||
| connection.conn.getFile("C$", path, file.write) | ||||||||||||||||||
| except Exception as e: | ||||||||||||||||||
| if "STATUS_FILE_IS_A_DIRECTORY" in str(e): | ||||||||||||||||||
| context.log.debug(f"Couldn't open {dest_path} because of {e}") | ||||||||||||||||||
| else: | ||||||||||||||||||
| context.log.fail(f"Failed to write recyclebin file to {filename}: {e}") | ||||||||||||||||||
| else: | ||||||||||||||||||
| context.log.highlight(f"\t{dest_path}") | ||||||||||||||||||
| found += 1 | ||||||||||||||||||
| except DCERPCSessionError as e: | ||||||||||||||||||
| if "ERROR_FILE_NOT_FOUND" in str(e): | ||||||||||||||||||
| continue | ||||||||||||||||||
| else: | ||||||||||||||||||
| context.log.fail(f"Error opening {sid_directory.get_longname()} on host {connection.host} because of {e}") | ||||||||||||||||||
| 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}") | ||||||||||||||||||
|
Comment on lines
112
to
+116
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we remove the file anyway we should probably use BytesIO, so we can skip all that creating dir, writing to disk etc. logic. See: NetExec/nxc/modules/gpp_password.py Lines 46 to 47 in 1884cd3
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. God, that diff looks crazy due to the "removal" detected by git. This belongs only to the 4 addition lines |
||||||||||||||||||
|
|
||||||||||||||||||
| # 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: | ||||||||||||||||||
| original_path = metadata_map.get(remote_file_path.replace("$R", ""), f"{sid_dir}\\{remote_file_path}") | ||||||||||||||||||
|
|
||||||||||||||||||
| # Get size from size_map (direct $R files) or listPath (nested files) | ||||||||||||||||||
| file_size_raw = size_map.get(remote_file_path.replace("$R", "")) if not is_nested else None | ||||||||||||||||||
|
|
||||||||||||||||||
| if file_size_raw is None: | ||||||||||||||||||
| try: | ||||||||||||||||||
| # Have to use listPath to get the file size since nested files don't have metadata files that we can read the size from | ||||||||||||||||||
| parent_smb = remote_full_path.rstrip("/").rsplit("/", 1)[0].replace("/", "\\") | ||||||||||||||||||
| dir_listing = connection.conn.listPath("C$", parent_smb + "\\*") | ||||||||||||||||||
| for f_obj in dir_listing: | ||||||||||||||||||
| if f_obj.get_longname() == remote_file_path.split("/")[-1]: | ||||||||||||||||||
| file_size_raw = f_obj.get_filesize() | ||||||||||||||||||
| break | ||||||||||||||||||
| except Exception as e: | ||||||||||||||||||
| context.log.debug(f"Could not get file size for {remote_file_path} via listPath: {e}") | ||||||||||||||||||
|
|
||||||||||||||||||
| size_display = f"{file_size_raw // 1024}KB" if file_size_raw and file_size_raw >= 1024 else f"{file_size_raw}B" if file_size_raw else "unknown" | ||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See above |
||||||||||||||||||
| 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}") | ||||||||||||||||||
|
|
||||||||||||||||||
| 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) | ||||||||||||||||||
|
|
||||||||||||||||||
| 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.") | ||||||||||||||||||
|
Comment on lines
+171
to
+172
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is it a folder when the len is <= 8? Isn't that a check in the name of the dir/file? |
||||||||||||||||||
| continue | ||||||||||||||||||
| if found > 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() | ||||||||||||||||||
| 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() | ||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See above |
||||||||||||||||||
| 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}") | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any reason why Default/Public etc are not included in the list? Do they not have a folder there?