Skip to content

Recycle bin module#609

Open
ledrypotato wants to merge 16 commits into
Pennyw0rth:mainfrom
ledrypotato:RecycleBin-module
Open

Recycle bin module#609
ledrypotato wants to merge 16 commits into
Pennyw0rth:mainfrom
ledrypotato:RecycleBin-module

Conversation

@ledrypotato
Copy link
Copy Markdown
Contributor

@ledrypotato ledrypotato commented Mar 21, 2025

Description

This PR adds the Recycle Bin module that will list files in the Recycle Bin. It will parse the associated metadata files in the Recycle Bin to display the "Original Location" of the deleted file, this can give a good indication whether or not the file is interesting or not. You can also download files using the module options.

I have a few things on my TODO list that I will implement as soon as I can as well as update the NetExec documentation.

Feel free to make any comments on the implementation/development as it's not my speciality 😆!

Type of change

  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update

How Has This Been Tested?

I have tested this module against my local Windows 11 VM (Microsoft Windows 11 Enterprise Evaluation) from my Kali VM running Python 3.12.8.

Screenshots:

Here is an example of deleted files in the Recycle Bin:

Pasted image 20250321181836

We can list files in the Recycle Bin with this command:

poetry run nxc smb 192.168.1.42 -u user -p potato -M recycle_bin

Pasted image 20250321180724

or download them with this command:

poetry run nxc smb 192.168.1.42 -u user -p potato -M recycle_bin -o DOWNLOAD=true

Pasted image 20250321183124

Checklist:

  • I have ran Ruff against my changes (via poetry: poetry run python -m ruff check . --preview, use --fix to automatically fix what it can)
  • I have added or updated the tests/e2e_commands.txt file if necessary
  • New and existing e2e tests pass locally with my changes
  • My code follows the style guidelines of this project (should be covered by Ruff above)
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (PR here: Added Recycle Bin module page NetExec-Wiki#39)

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)
  • Handle directories in the Recycle Bin as well as single files
  • Specify what files you want to download as a module option filter

@NeffIsBack
Copy link
Copy Markdown
Member

Thanks for the PR!

If you still want to work on that module it's best to turn the PR into a "DRAFT", so that we know it is still worked on :)

@ledrypotato
Copy link
Copy Markdown
Contributor Author

ledrypotato commented Apr 1, 2025

I have made all the changes that I had planned and also created a PR for the associated documentation for the NetExec wiki.

Here is an updated screenshot for the changes that handle displaying the deletion time of files, directories and using a filter to download specific files.

filter-on-filename

I have gone ahead and removed the draft tag from my PR :)

@ledrypotato ledrypotato marked this pull request as ready for review April 1, 2025 00:30
@ledrypotato
Copy link
Copy Markdown
Contributor Author

Any updates?

@NeffIsBack
Copy link
Copy Markdown
Member

Hi,
to be honest i lost a bit track of all currently open PRs.
A few minutes ago i just merged ##463 which has the same purpose. However, your PR contains a few pretty cool features that the module from @Dfte does not. Could you integrate your Pull Request into the current recycle bin module?

@ledrypotato
Copy link
Copy Markdown
Contributor Author

Hey, oh I didn't even realise there was a previous module for the Recycle Bin by @Dfte 😆 !
I will have to take a look and see how easy it is to integrate into that.

@NeffIsBack NeffIsBack added the duplicate This issue or pull request already exists label May 15, 2025
@NeffIsBack
Copy link
Copy Markdown
Member

Fyi, until this has been resolved i will turn it into a DRAFT PR so that it is clear it isn't ready for review :)

@NeffIsBack NeffIsBack marked this pull request as draft July 3, 2025 23:01
@Marshall-Hallenbeck
Copy link
Copy Markdown
Collaborator

@ledrypotato have you had a chance to look at the other module and see if you can integrate anything?

@ledrypotato
Copy link
Copy Markdown
Contributor Author

@ledrypotato have you had a chance to look at the other module and see if you can integrate anything?

Hey, not yet unfortunately. I should have some more time beginning of next month.

@ledrypotato
Copy link
Copy Markdown
Contributor Author

ledrypotato commented Sep 20, 2025

Hi, I finally had some time to look at this merge.

To be honest I moved most of my code that I had into Dfte's module. I removed the registry key logic to fetch the username since we can get that information from the metadata file in the Recycle Bin (files that start with $I).

  • I updated the e2e_commands.txt file
  • I ran ruff against the code

Here is an updated screenshot of the output when specifying that you want to download files (-o DOWNLOAD=true) with a filter on the file name (FILTER=password).

poetry run nxc smb 192.168.1.42 -u user -p potato -M recyclebin -o DOWNLOAD=true FILTER=password

image

Most of the code I originally posted remains the same. I felt it was easier to keep what I already had than try and merge everything. That said I did review the original code to check if things were better in it. Perhaps the connection.spider is more efficient, I will let you decide as you are more familiar.

Feel free to give any feedback on what should be changed/optimized.

@NeffIsBack
Copy link
Copy Markdown
Member

Only took a short look at the code, but looks mostly good from what i can tell 👍

@ledrypotato
Copy link
Copy Markdown
Contributor Author

Yeah the logic remains the same.
As a side note I personally prefer the naming convention with the underscore (recycle_bin) and it aligns with most other module names which are multiple words. I'll let you decide what suits best.

@NeffIsBack
Copy link
Copy Markdown
Member

NeffIsBack commented Sep 25, 2025

As a side note I personally prefer the naming convention with the underscore (recycle_bin) and it aligns with most other module names which are multiple words. I'll let you decide what suits best.

I don't mind either, but it is currently pretty mixed. Not sure what is better but at some point we'll probably enforce one of both, but i'll leave it as is now.

@Marshall-Hallenbeck
Copy link
Copy Markdown
Collaborator

I think underscore is easier to read and I was going to normalize it eventually, maybe after the module arg stuff.

@ledrypotato ledrypotato marked this pull request as ready for review September 28, 2025 15:37
@ledrypotato
Copy link
Copy Markdown
Contributor Author

Hey I assume this thread got lost will all the other open PRs. Let me know if anything needs changed before it can be merged.

@NeffIsBack
Copy link
Copy Markdown
Member

Hey I assume this thread got lost will all the other open PRs. Let me know if anything needs changed before it can be merged.

More a struggle of good prioritization. But yeah, this PR has waited a long time now, i'll put it up the chain. Thanks for reminding.

Copy link
Copy Markdown
Collaborator

@Marshall-Hallenbeck Marshall-Hallenbeck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The download functionality reads the raw bytes and writes them to a file, but there's already functions to download for SMB instead of re-inventing the wheel. Why did you go with this method instead of using an existing one?

Comment thread nxc/modules/recyclebin.py Outdated
Comment thread nxc/modules/recyclebin.py Outdated
Comment thread nxc/modules/recyclebin.py Outdated
try:
connection.conn.getFile("C$", file_path, buf.write)
except Exception as e:
if "STATUS_FILE_IS_A_DIRECTORY" in str(e):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this if else makes no sense, it logs the same thing for both paths

Comment thread nxc/modules/recyclebin.py Outdated
buf.seek(0)
return buf.read()

def convert_filetime_to_datetime(self, filetime):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be a helper function that we can re-use elsewhere.

@NeffIsBack
Copy link
Copy Markdown
Member

NeffIsBack commented Mar 14, 2026

Sorry for the late review. I took a seconds look at the code and realized there are still a few things to do, of which @Marshall-Hallenbeck pointed out a few already. I fixed some minor format issues already.

Perhaps the connection.spider is more efficient, I will let you decide as you are more familiar.

Yeah actually i think the spidering is much easier to read and also less prone to errors. Recursive execution tends to break because states change or assumptions that have been made break at some point.

In addition, please integrate your changes into the existing module without breaking its logic, e.g. as far as I can tell the check on false positives users has been removed, which should remain if there isn't a good reason to remove it.

@NeffIsBack NeffIsBack added enhancement New feature or request and removed duplicate This issue or pull request already exists new module labels Mar 14, 2026
@ledrypotato
Copy link
Copy Markdown
Contributor Author

ledrypotato commented Mar 18, 2026

Hello, thanks for your feedback 😄

I've done quite a few changes to hopefully make the code less prone to error, here are the main ones:

  • Removed manual recursion and replaced it with spidering
  • Added nested folder support
  • Added helper function for converting Windows FILETIME to a readable timestamp

There was already a convert method in misc.py but instead of modifying it to avoid conflicts I added my method just after as the output they provide are not exactly the same.

The download functionality reads the raw bytes and writes them to a file, but there's already functions to download for SMB instead of re-inventing the wheel. Why did you go with this method instead of using an existing one?

I initially wrote this wrapper as I thought it was better to save the small metadata file to memory then parse it without saving it to disk. I have reverted this change and used the existing methods to download files.

In addition, please integrate your changes into the existing module without breaking its logic, e.g. as far as I can tell the check on false positives users has been removed, which should remain if there isn't a good reason to remove it.

The check on the false positives files that can be found in the Recycle Bin is still here. What has changed is the false_positive_users array that has been removed since the username is now read from the metadata file (starting with $I) so there are no false positives and this check can be removed.

@ledrypotato
Copy link
Copy Markdown
Contributor Author

ledrypotato commented Mar 18, 2026

For reference here is the outdated output:

Listing the files in each user's Recycle Bin:

image

Downloading a file with a specific name:

image

Also as a side note:

@NeffIsBack
Copy link
Copy Markdown
Member

Thanks! I'll look at it soon ™️

If i don't review it in the coming 2-3 weeks please ping me :D

@ledrypotato
Copy link
Copy Markdown
Contributor Author

If i don't review it in the coming 2-3 weeks please ping me :D

Whenever you get the chance 😄

@NeffIsBack
Copy link
Copy Markdown
Member

If i don't review it in the coming 2-3 weeks please ping me :D

Whenever you get the chance 😄

Of course I forgot😅 Thanks for the ping, I will look at it the next days (hopefully tomorrow).

Copy link
Copy Markdown
Member

@NeffIsBack NeffIsBack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, sorry for the delay, tight week. Here is the review tho.

Looking at the structure, we are downloading $I and $R files, mapping metadata information from $I to $R. However, with the current structure this would mean if we retrieve the $R file before we retrieved $I we would miss out on the metadata information.

What about we are just looking for $R and then try to download the respective metadata file? Then we wouldn't have to rely on some internal metadata mapping where everything could exist or not. Thoughts?

Comment thread nxc/modules/recyclebin.py
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.")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that should be .info() so it doesn't spam too much when we scan large ranges

Comment thread nxc/modules/recyclebin.py
description = "Lists (and downloads) files in the Recycle Bin."
supported_protocols = ["smb"]
category = CATEGORY.CREDENTIAL_DUMPING
false_positive = (".", "..", "desktop.ini", "S-1-5-18")
Copy link
Copy Markdown
Member

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?

Comment thread nxc/modules/recyclebin.py

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)]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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)

Comment thread nxc/modules/recyclebin.py
Comment on lines +44 to +71
# 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}")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 sid_dir as this is set in the loop above and appended to the path in the main for loop down below. I haven't actually tested it yet, but does this indeed iterate over all sids? Maybe I am missing something here tho.

Comment thread nxc/modules/recyclebin.py
Comment on lines +85 to +86
export_path = join(NXC_PATH, "modules", "recyclebin")
makedirs(export_path, exist_ok=True)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

Comment thread nxc/modules/recyclebin.py
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"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably move the convert_size function from nfs.py to nxc/helpers/misc, add a doc string and use it in nfs and here. See:

def convert_size(size_bytes):
if size_bytes == 0:
return "0B"
size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
i = math.floor(math.log(size_bytes, 1024))
p = math.pow(1024, i)
s = round(size_bytes / p, 1)
return f"{s}{size_name[i]}"

Comment thread nxc/modules/recyclebin.py
Comment on lines 112 to +116
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}")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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:

buf = BytesIO()
connection.conn.getFile(sysvol, path, buf.write)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

Comment thread nxc/modules/recyclebin.py
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"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

Comment thread nxc/modules/recyclebin.py
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()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

Comment thread nxc/modules/recyclebin.py
Comment on lines +171 to +172
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.")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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?

@ledrypotato
Copy link
Copy Markdown
Contributor Author

Thanks for the in-depth review. I won't have time to check the review this week and I won't be here for the next few weeks so it will have to wait a bit 😄 .

@NeffIsBack
Copy link
Copy Markdown
Member

Thanks for the in-depth review. I won't have time to check the review this week and I won't be here for the next few weeks so it will have to wait a bit 😄 .

No worries, we are not in a rush :D

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants