From b020b2444189a5ba5fc623a2fadb585e4fc3d04c Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Thu, 5 Mar 2026 08:47:58 -0500 Subject: [PATCH 1/6] feat(smb): add --get-folder command for downloading remote directories Add download_file(), download_folder(), and get_folder() methods to the SMB protocol. Refactor get_file_single() to use download_file() with automatic fallback from READ to READ/WRITE access on sharing violations. New CLI args: --get-folder, --recursive, --ignore-empty-folders Example: nxc smb target -u user -p pass --share SYSVOL --get-folder 'domain\Policies' ./output --recursive --- nxc/protocols/smb.py | 96 +++++++++++++++++++++++++++++---- nxc/protocols/smb/proto_args.py | 3 ++ 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index 6c5618464e..0cb13e243c 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -35,7 +35,7 @@ from impacket.dcerpc.v5.dtypes import NULL from impacket.dcerpc.v5.dcomrt import DCOMConnection from impacket.dcerpc.v5.dcom.wmi import CLSID_WbemLevel1Login, IID_IWbemLevel1Login, IWbemLevel1Login -from impacket.smb3structs import FILE_SHARE_WRITE, FILE_SHARE_DELETE, SMB2_0_IOCTL_IS_FSCTL +from impacket.smb3structs import FILE_READ_DATA, FILE_WRITE_DATA, FILE_SHARE_WRITE, FILE_SHARE_DELETE, SMB2_0_IOCTL_IS_FSCTL from impacket.dcerpc.v5 import tsts as TSTS from nxc.config import process_secret, host_info_colors, check_guest_account @@ -1944,24 +1944,100 @@ def put_file(self): for src, dest in self.args.put_file: self.put_file_single(src, dest) - def get_file_single(self, remote_path, download_path): + def download_file(self, share_name, remote_path, dest_file, access_mode=FILE_READ_DATA): + try: + self.logger.debug(f"Getting file from {share_name}:{remote_path} with access mode {access_mode}") + self.conn.getFile(share_name, remote_path, dest_file, shareAccessMode=access_mode) + return True + except SessionError as e: + if "STATUS_SHARING_VIOLATION" in str(e): + self.logger.debug(f"Sharing violation on {remote_path}: {e}") + else: + self.logger.debug(f"SessionError when attempting to download file {remote_path}: {e}") + return False + except Exception as e: + self.logger.debug(f"Other error when attempting to download file {remote_path}: {e}") + return False + + def get_file_single(self, remote_path, download_path, silent=False): share_name = self.args.share - self.logger.display(f'Copying "{remote_path}" to "{download_path}"') + if not silent: + self.logger.display(f"Copying '{remote_path}' to '{download_path}'") if self.args.append_host: download_path = f"{self.hostname}-{remote_path}" with open(download_path, "wb+") as file: - try: - self.conn.getFile(share_name, remote_path, file.write) - self.logger.success(f'File "{remote_path}" was downloaded to "{download_path}"') - except Exception as e: - self.logger.fail(f'Error writing file "{remote_path}" from share "{share_name}": {e}') - if os.path.getsize(download_path) == 0: - os.remove(download_path) + if self.download_file(share_name, remote_path, file.write): + if not silent: + self.logger.success(f"File '{remote_path}' was downloaded to '{download_path}'") + else: + self.logger.debug("Opening with READ alone failed, trying to open file with READ/WRITE access") + if self.download_file(share_name, remote_path, file.write, FILE_READ_DATA | FILE_WRITE_DATA): + if not silent: + self.logger.success(f"File '{remote_path}' was downloaded to '{download_path}'") + else: + if not silent: + self.logger.fail(f"Error downloading file '{remote_path}' from share '{share_name}'") def get_file(self): for src, dest in self.args.get_file: self.get_file_single(src, dest) + def download_folder(self, folder, dest, recursive=False, silent=False, base_dir=None, ignore_empty=False): + self.logger.debug(f"Downloading folder with args: {folder}, {dest}, Recursive: {recursive}, Silent: {silent}, Base dir: {base_dir}, Ignore empty: {ignore_empty}") + normalized_folder = ntpath.normpath(folder) + base_folder = os.path.basename(normalized_folder) + self.logger.debug(f"Base folder: {base_folder}") + + try: + items = self.conn.listPath(self.args.share, ntpath.join(folder, "*")) + except SessionError as e: + self.logger.error(f"Error listing folder '{folder}': {e}") + return + self.logger.debug(f"{len(items)} items in folder: {items}") + + filtered_items = [item for item in items if item.get_longname() not in [".", ".."]] + + # create local directory structure regardless of content; download empty folders by default + # change the Windows path to Linux and then join it with the base directory to get our actual save path + relative_path = os.path.join(*folder.replace(base_dir or folder, "").lstrip("\\").split("\\")) + local_folder_path = os.path.join(dest, relative_path) + + if not filtered_items and ignore_empty: + self.logger.debug(f"Skipping empty folder '{folder}'") + return + + # create the directory for this folder + os.makedirs(local_folder_path, exist_ok=True) + if not filtered_items and not silent: + self.logger.display(f"Created empty directory '{local_folder_path}'") + + for item in filtered_items: + item_name = item.get_longname() + dir_path = ntpath.normpath(ntpath.join(normalized_folder, item_name)) + self.logger.debug(f"Parsing item: {item_name}, {dir_path}") + + if item.is_directory() and recursive: + self.logger.debug(f"Found new directory to parse: {dir_path}") + self.download_folder(dir_path, dest, recursive, silent, base_dir or folder, ignore_empty) + elif not item.is_directory(): + remote_file_path = ntpath.join(folder, item_name) + local_file_path = os.path.join(local_folder_path, item_name) + self.logger.debug(f"{dest=} {remote_file_path=} {relative_path=} {local_folder_path=} {local_file_path=}") + + try: + self.get_file_single(remote_file_path, local_file_path, silent) + except FileNotFoundError: + self.logger.fail(f"Error downloading file '{remote_file_path}' due to file not found (probably a race condition between listing and downloading)") + + def get_folder(self): + recursive = self.args.recursive + ignore_empty = getattr(self.args, "ignore_empty_folders", False) + self.logger.debug(f"Recursive option set to {recursive}") + self.logger.debug(f"Ignore empty folders option set to {ignore_empty}") + for folder, dest in self.args.get_folder: + self.download_folder(folder, dest, recursive, False, None, ignore_empty) + self.logger.success(f"Folder '{folder}' was downloaded to '{dest}'") + def enable_remoteops(self, regsecret=False): try: if regsecret: diff --git a/nxc/protocols/smb/proto_args.py b/nxc/protocols/smb/proto_args.py index 6765559c24..5c52d1b79c 100644 --- a/nxc/protocols/smb/proto_args.py +++ b/nxc/protocols/smb/proto_args.py @@ -90,6 +90,9 @@ def proto_args(parser, parents): files_group = smb_parser.add_argument_group("File Operations") files_group.add_argument("--put-file", action="append", nargs=2, metavar="FILE", help="Put a local file into remote target, ex: whoami.txt \\\\Windows\\\\Temp\\\\whoami.txt") files_group.add_argument("--get-file", action="append", nargs=2, metavar="FILE", help="Get a remote file, ex: \\\\Windows\\\\Temp\\\\whoami.txt whoami.txt") + files_group.add_argument("--get-folder", action="append", nargs=2, metavar="DIR", help="Get a remote directory, ex: \\\\Windows\\\\Temp\\\\testing testing") + files_group.add_argument("--recursive", default=False, action="store_true", help="Recursively get a folder") + files_group.add_argument("--ignore-empty-folders", default=False, action="store_true", help="Ignore empty folders when downloading") files_group.add_argument("--append-host", action="store_true", help="append the host to the get-file filename") cmd_exec_group = smb_parser.add_argument_group("Command Execution") From 5932bce690d423b4316921fff855daf7bca68c4a Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Mon, 6 Apr 2026 14:42:22 -0400 Subject: [PATCH 2/6] fix(smb): add path sanitization helper and apply to download_folder --- nxc/helpers/path.py | 13 +++++++++++++ nxc/protocols/smb.py | 13 ++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 nxc/helpers/path.py diff --git a/nxc/helpers/path.py b/nxc/helpers/path.py new file mode 100644 index 0000000000..a4dd96029e --- /dev/null +++ b/nxc/helpers/path.py @@ -0,0 +1,13 @@ +from pathlib import PurePosixPath + + +def sanitize_filename(name: str) -> str: + """Strip path traversal components from an SMB filename. + + Follows the pattern from spider_plus.py — filters '..' and '.' from + PurePosixPath.parts to prevent directory traversal attacks from + malicious SMB servers. + """ + parts = PurePosixPath(name.replace("\\", "/")).parts + clean = [p for p in parts if p not in ("..", ".", "/")] + return str(PurePosixPath(*clean)) if clean else "" diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index 0cb13e243c..67dae8069e 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -4,6 +4,9 @@ import re import struct import ipaddress +from pathlib import Path + +from nxc.helpers.path import sanitize_filename from Cryptodome.Hash import MD4 from textwrap import dedent @@ -2012,7 +2015,10 @@ def download_folder(self, folder, dest, recursive=False, silent=False, base_dir= self.logger.display(f"Created empty directory '{local_folder_path}'") for item in filtered_items: - item_name = item.get_longname() + item_name = sanitize_filename(item.get_longname()) + if not item_name: + self.logger.fail(f"Path traversal detected in '{item.get_longname()}', skipping") + continue dir_path = ntpath.normpath(ntpath.join(normalized_folder, item_name)) self.logger.debug(f"Parsing item: {item_name}, {dir_path}") @@ -2022,6 +2028,11 @@ def download_folder(self, folder, dest, recursive=False, silent=False, base_dir= elif not item.is_directory(): remote_file_path = ntpath.join(folder, item_name) local_file_path = os.path.join(local_folder_path, item_name) + # Defense-in-depth: verify path stays under destination + resolved = Path(local_file_path).resolve() + if not str(resolved).startswith(str(Path(dest).resolve()) + os.sep): + self.logger.fail(f"Path traversal detected in '{item_name}', skipping") + continue self.logger.debug(f"{dest=} {remote_file_path=} {relative_path=} {local_folder_path=} {local_file_path=}") try: From 598afaa79af2f0f01c1c4b7fd843e7024e48dca1 Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Mon, 6 Apr 2026 14:51:43 -0400 Subject: [PATCH 3/6] tests(get_folder): add e2e test line --- tests/e2e_commands.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index 850262cfc5..d9ffc3a3d2 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -43,6 +43,7 @@ netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -x ipconfig netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --put-file TEST_NORMAL_FILE \\Windows\\Temp\\test_file.txt netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --put-file TEST_NORMAL_FILE \\Windows\\Temp\\test_file.txt --put-file TEST_NORMAL_FILE \\Windows\\Temp\\test_file2.txt netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --get-file \\Windows\\Temp\\test_file.txt /tmp/test_file.txt +netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --get-folder \\Windows\\Temp\\ /tmp/test_folder/ netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --no-admin-check ##### SMB PowerShell netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig From cd6d5b2ee543bf2bbe00291b1b9dbd2317066ed3 Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Tue, 28 Apr 2026 18:51:23 -0400 Subject: [PATCH 4/6] fix(smb): properly reference arg via dot notation --- nxc/protocols/smb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index 67dae8069e..bf51c50c23 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -2042,7 +2042,7 @@ def download_folder(self, folder, dest, recursive=False, silent=False, base_dir= def get_folder(self): recursive = self.args.recursive - ignore_empty = getattr(self.args, "ignore_empty_folders", False) + ignore_empty = self.args.ignore_empty_folders self.logger.debug(f"Recursive option set to {recursive}") self.logger.debug(f"Ignore empty folders option set to {ignore_empty}") for folder, dest in self.args.get_folder: From 9c4ad7fc394ee97decd0d797d2f97b1d2a74a4ca Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Sat, 23 May 2026 19:47:47 -0400 Subject: [PATCH 5/6] fix(smb): recursively download folders by default --- nxc/protocols/smb/proto_args.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nxc/protocols/smb/proto_args.py b/nxc/protocols/smb/proto_args.py index 5c52d1b79c..f3cc4989a6 100644 --- a/nxc/protocols/smb/proto_args.py +++ b/nxc/protocols/smb/proto_args.py @@ -1,4 +1,4 @@ -from argparse import _StoreTrueAction, _StoreAction +from argparse import BooleanOptionalAction, _StoreTrueAction, _StoreAction from nxc.helpers.args import DisplayDefaultsNotNone, DefaultTrackingAction, get_conditional_action @@ -91,7 +91,7 @@ def proto_args(parser, parents): files_group.add_argument("--put-file", action="append", nargs=2, metavar="FILE", help="Put a local file into remote target, ex: whoami.txt \\\\Windows\\\\Temp\\\\whoami.txt") files_group.add_argument("--get-file", action="append", nargs=2, metavar="FILE", help="Get a remote file, ex: \\\\Windows\\\\Temp\\\\whoami.txt whoami.txt") files_group.add_argument("--get-folder", action="append", nargs=2, metavar="DIR", help="Get a remote directory, ex: \\\\Windows\\\\Temp\\\\testing testing") - files_group.add_argument("--recursive", default=False, action="store_true", help="Recursively get a folder") + files_group.add_argument("--recursive", default=True, action=BooleanOptionalAction, help="Recursively get a folder") files_group.add_argument("--ignore-empty-folders", default=False, action="store_true", help="Ignore empty folders when downloading") files_group.add_argument("--append-host", action="store_true", help="append the host to the get-file filename") From e84ca2d510c4ec6f5024f7341507c592d5a059c4 Mon Sep 17 00:00:00 2001 From: Marshall Hallenbeck Date: Sat, 23 May 2026 19:49:21 -0400 Subject: [PATCH 6/6] fix(smb): fix slashes and silent folder download bugs --- nxc/protocols/smb.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index bf51c50c23..89c749e4ba 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -1986,9 +1986,9 @@ def get_file(self): self.get_file_single(src, dest) def download_folder(self, folder, dest, recursive=False, silent=False, base_dir=None, ignore_empty=False): + folder = ntpath.normpath(folder) self.logger.debug(f"Downloading folder with args: {folder}, {dest}, Recursive: {recursive}, Silent: {silent}, Base dir: {base_dir}, Ignore empty: {ignore_empty}") - normalized_folder = ntpath.normpath(folder) - base_folder = os.path.basename(normalized_folder) + base_folder = os.path.basename(folder) self.logger.debug(f"Base folder: {base_folder}") try: @@ -2019,7 +2019,7 @@ def download_folder(self, folder, dest, recursive=False, silent=False, base_dir= if not item_name: self.logger.fail(f"Path traversal detected in '{item.get_longname()}', skipping") continue - dir_path = ntpath.normpath(ntpath.join(normalized_folder, item_name)) + dir_path = ntpath.normpath(ntpath.join(folder, item_name)) self.logger.debug(f"Parsing item: {item_name}, {dir_path}") if item.is_directory() and recursive: @@ -2046,7 +2046,7 @@ def get_folder(self): self.logger.debug(f"Recursive option set to {recursive}") self.logger.debug(f"Ignore empty folders option set to {ignore_empty}") for folder, dest in self.args.get_folder: - self.download_folder(folder, dest, recursive, False, None, ignore_empty) + self.download_folder(folder, dest, recursive, self.args.silent, None, ignore_empty) self.logger.success(f"Folder '{folder}' was downloaded to '{dest}'") def enable_remoteops(self, regsecret=False):