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/path.py
Original file line number Diff line number Diff line change
@@ -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 ""
107 changes: 97 additions & 10 deletions nxc/protocols/smb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -35,7 +38,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
Expand Down Expand Up @@ -1944,24 +1947,108 @@ 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 = 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}")

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)
# 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:
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 = 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:
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:
Expand Down
3 changes: 3 additions & 0 deletions nxc/protocols/smb/proto_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions tests/e2e_commands.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading