Compare commits

...
17 Commits
Author SHA1 Message Date
Rémi GASCOU (Podalirius) 3a8a902a58 Update README to include smbclient-ng information
Added a note about the new smbclient-ng project written in Go.
2026-07-21 09:16:09 +02:00
someyackandDylab 58fbd9e4b6 Add support for domain-qualified usernames (#207)
Co-authored-by: Dylab <dylan@yack.one>
2026-07-10 07:20:08 +02:00
lapinou a40eb121ef fix(pth): fix pth with a LM:NT hash (#206) 2026-07-10 07:20:03 +02:00
Remi GASCOU (Podalirius) 8f3251cad8 Release 3.1.0 2026-07-01 14:14:10 +02:00
Remi GASCOU (Podalirius) 4b021e113d Fixed lint issues 2026-07-01 12:02:41 +02:00
amTeaq 9daaca08bc Allow cd into non-listable SMB directories (#205) 2026-07-01 08:50:39 +02:00
Rémi GASCOU (Podalirius) 1970cc6fcd Fix lcp autocompleting remote files instead of local files (#202) (#203) 2026-04-17 20:02:22 +02:00
Rémi GASCOU (Podalirius) 9d6cf0e976 Fix put command failing on absolute local paths due to file-as-directory listdir (#200) (#201) 2026-04-17 20:02:08 +02:00
Rémi GASCOU (Podalirius) 0648a476d7 Fix rm and put glob matching over-selecting files via unanchored regex (#198) (#199) 2026-04-17 20:01:55 +02:00
Rémi GASCOU (Podalirius) 8956b27c74 Fix rm and rmdir error handlers crashing with TypeError on delete failures (#196) (#197) 2026-04-17 20:01:36 +02:00
Rémi GASCOU (Podalirius) 6a4cc3a4c3 Fix mount command corrupting argv due to shell=True with list args (#194) (#195) 2026-04-17 14:15:43 +02:00
Rémi GASCOU (Podalirius) ff61397074 Fix use and module commands raising IndexError on missing arguments (#192) (#193) 2026-04-17 14:15:19 +02:00
Rémi GASCOU (Podalirius) 68c37a3842 Fix folder autocompletion swallowing user input on space-containing entries (#19) (#191) 2026-04-17 14:11:49 +02:00
Rémi GASCOU (Podalirius) 477f3e9db9 Fix cd falling back to direct listing when parent denies enumeration (#186) (#190) 2026-04-17 14:09:37 +02:00
Rémi GASCOU (Podalirius) a65aee94fc Fix get command silently swallowing local PermissionError (#172) (#189) 2026-04-17 14:09:24 +02:00
Rémi GASCOU (Podalirius) 48cf0951bf Fix get -r creating directories relative to filesystem root instead of CWD (#173) (#188) 2026-04-17 14:09:08 +02:00
Rémi GASCOU (Podalirius) 28b0d0b5ea Fix SessionsManager initialization to prevent AttributeError on current_session_id (#182) (#187) 2026-04-17 14:08:57 +02:00
16 changed files with 160 additions and 76 deletions
+5
View File
@@ -10,6 +10,11 @@
<br>
</p>
> [!NOTE]
> Check out the new smbclient-ng written in Go, faster and cross platform by default!
>
> https://github.com/TheManticoreProject/smbclient-ng
## Features
- [x] `acls`: List ACLs of files and folders in cwd. Syntax: `acls`
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
[project]
name = "smbclientng"
version = "3.0.0"
version = "3.1.0"
description = "smbclient-ng, a fast and user-friendly way to interact with SMB shares."
authors = [
{name = "p0dalirius"}
+1 -3
View File
@@ -4,12 +4,10 @@
# Author: Podalirius (@podalirius_)
# Date created: 24 May 2024
#!/usr/bin/env python3
import sys
# Local library imports
from smbclientng import console
if __name__ == "__main__":
sys.exit(console.run())
sys.exit(console.run())
+1 -1
View File
@@ -18,7 +18,7 @@ class Command_lcp(Command):
HELP = {
"description": [description, "Syntax: 'lcp <srcfile> <dstfile>'"],
"subcommands": [],
"autocomplete": ["remote_file"],
"autocomplete": ["local_file"],
}
def setupParser(self) -> CommandArgumentParser:
+6
View File
@@ -26,6 +26,12 @@ class Command_module(Command):
# Active SMB connection needed : No
# SMB share needed : No
if len(arguments) == 0:
interactive_shell.logger.error(
"Usage: module <name> [module-arguments...]"
)
return
module_name = arguments[0]
if module_name in interactive_shell.modules.keys():
+2 -2
View File
@@ -67,9 +67,9 @@ class Command_rm(Command):
interactive_shell.sessionsManager.current_session.rm(
path=path_to_file
)
except Exception:
except Exception as err:
interactive_shell.logger.error(
"Error removing file '%s' : %s" % path_to_file
"Error removing file '%s': %s" % (path_to_file, err)
)
else:
interactive_shell.logger.error(
+3 -2
View File
@@ -45,9 +45,10 @@ class Command_rmdir(Command):
interactive_shell.sessionsManager.current_session.rmdir(
path=path_to_directory
)
except Exception:
except Exception as err:
interactive_shell.logger.error(
"Error removing directory '%s' : %s" % path_to_directory
"Error removing directory '%s': %s"
% (path_to_directory, err)
)
else:
interactive_shell.logger.error(
+3 -1
View File
@@ -35,8 +35,10 @@ class Command_use(Command):
# SMB share needed : No
self.options = self.processArguments(arguments=arguments)
if self.options is None:
return
sharename = arguments[0]
sharename = self.options.sharename
# Reload the list of shares
shares = interactive_shell.sessionsManager.current_session.list_shares()
+10 -3
View File
@@ -9,7 +9,7 @@ from smbclientng.core.SessionsManager import SessionsManager
from smbclientng.types.Config import Config
from smbclientng.types.Credentials import Credentials
VERSION = "3.0.0"
VERSION = "3.1.0"
def parseArgs():
@@ -90,7 +90,11 @@ def parseArgs():
"-d", "--domain", default=".", type=str, help="Authentication domain."
)
group_auth.add_argument(
"-u", "--user", type=str, default="", help="Username for authentication."
"-u",
"--user",
type=str,
default="",
help=r"Username for authentication. Supports DOMAIN\user.",
)
group_auth.add_argument(
"-k", "--kerberos", action="store_true", help="Use Kerberos authentication."
@@ -132,8 +136,11 @@ def parseArgs():
if options.user and not (options.password or options.no_pass or options.hashes or options.ccache_file or options.kerberos):
from getpass import getpass
prompt_domain, prompt_username = Credentials.parse_domain_username(
domain=options.domain, username=options.user
)
options.password = getpass(
f" | Provide a password for '{options.domain}\\{options.user}': "
f" | Provide a password for '{prompt_domain}\\{prompt_username}': "
)
if options.aes_key or options.ccache_file:
+26 -18
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import ntpath
import os
import shlex
import re
from typing import TYPE_CHECKING
from smbclientng.commands import (Command_acls, Command_bat, Command_bhead,
@@ -35,6 +35,18 @@ if TYPE_CHECKING:
from smbclientng.core.SMBSession import SMBSession
# Characters that must be escaped so readline can still find a common prefix
# when some candidates contain spaces or quoting-significant characters.
# Using backslash escaping (rather than wrapping with quotes) keeps the entry
# name visible to readline's prefix-matching logic, so TAB completion works
# when the user has only typed part of an entry's name.
_SHELL_ESCAPE_PATTERN = re.compile(r"([\s\\\"'])")
def _shell_escape(value: str) -> str:
return _SHELL_ESCAPE_PATTERN.sub(r"\\\1", value)
class CommandCompleter(object):
"""
A class to handle command completion for the smbclient-ng shell.
@@ -156,7 +168,7 @@ class CommandCompleter(object):
matching_entries.append(shares[sharename]["name"])
# Final matches
for m in matching_entries:
self.matches.append(command + " " + shlex.quote(m))
self.matches.append(command + " " + _shell_escape(m))
# Autocomplete directory
if "remote_directory" in self.commands[command]["autocomplete"]:
@@ -186,12 +198,11 @@ class CommandCompleter(object):
)
#
for m in matching_entries:
escaped = _shell_escape(m)
if m.lower().startswith(
remainder.lower()
) or shlex.quote(m).lower().startswith(
remainder.lower()
):
self.matches.append(command + " " + shlex.quote(m))
) or escaped.lower().startswith(remainder.lower()):
self.matches.append(command + " " + escaped)
# Autocomplete file
if "remote_file" in self.commands[command]["autocomplete"]:
@@ -218,12 +229,11 @@ class CommandCompleter(object):
matching_entries.append(entry.get_longname())
#
for m in matching_entries:
escaped = _shell_escape(m)
if m.lower().startswith(
remainder.lower()
) or shlex.quote(m).lower().startswith(
remainder.lower()
):
self.matches.append(command + " " + shlex.quote(m))
) or escaped.lower().startswith(remainder.lower()):
self.matches.append(command + " " + escaped)
# Autocomplete local_directory
if "local_directory" in self.commands[command]["autocomplete"]:
@@ -247,12 +257,11 @@ class CommandCompleter(object):
)
#
for m in matching_entries:
escaped = _shell_escape(m)
if m.lower().startswith(
remainder.lower()
) or shlex.quote(m).lower().startswith(
remainder.lower()
):
self.matches.append(command + " " + shlex.quote(m))
) or escaped.lower().startswith(remainder.lower()):
self.matches.append(command + " " + escaped)
# Autocomplete local_file
if "local_file" in self.commands[command]["autocomplete"]:
@@ -274,12 +283,11 @@ class CommandCompleter(object):
matching_entries.append(entry_path)
#
for m in matching_entries:
escaped = _shell_escape(m)
if m.lower().startswith(
remainder.lower()
) or shlex.quote(m).lower().startswith(
remainder.lower()
):
self.matches.append(command + " " + shlex.quote(m))
) or escaped.lower().startswith(remainder.lower()):
self.matches.append(command + " " + escaped)
else:
# Generic case for subcommands
+15 -8
View File
@@ -70,12 +70,15 @@ class LocalFileIO(object):
"Openning local '%s' with mode '%s'" % (self.path, self.mode)
)
local_path = self.dir + os.path.sep + os.path.basename(self.path)
try:
self.fd = open(
self.dir + os.path.sep + os.path.basename(self.path), self.mode
)
except PermissionError:
self.fd = open(local_path, self.mode)
except (PermissionError, OSError) as err:
self.fd = None
self.logger.error(
"Cannot open local file '%s' for writing: %s"
% (local_path, err)
)
# Write to remote (read local)
elif self.mode in ["rb"]:
@@ -88,15 +91,19 @@ class LocalFileIO(object):
try:
self.fd = open(self.path, self.mode)
except PermissionError:
except (PermissionError, OSError) as err:
self.fd = None
self.logger.error(
"Cannot open local file '%s' for reading: %s"
% (self.path, err)
)
if self.fd is not None:
if self.expected_size is None:
self.expected_size = os.path.getsize(filename=self.path)
# Create progress bar
if self.expected_size is not None:
# Create progress bar only if the file descriptor was opened successfully
if self.expected_size is not None and self.fd is not None:
self.__progress = Progress(
TextColumn("[bold blue]{task.description}", justify="right"),
BarColumn(bar_width=None),
@@ -177,7 +184,7 @@ class LocalFileIO(object):
except (PermissionError, FileNotFoundError):
pass
if self.expected_size is not None:
if self.expected_size is not None and self.fd is not None:
self.__progress.stop()
del self
+51 -20
View File
@@ -6,6 +6,7 @@
from __future__ import annotations
import fnmatch
import io
import ntpath
import os
@@ -23,7 +24,7 @@ from impacket.smb import SMBFileStreamInformation
from impacket.smb3structs import (DACL_SECURITY_INFORMATION,
FILE_DIRECTORY_FILE, FILE_NON_DIRECTORY_FILE,
FILE_OPEN, FILE_READ_ATTRIBUTES,
GROUP_SECURITY_INFORMATION,
FILE_TRAVERSE, GROUP_SECURITY_INFORMATION,
OWNER_SECURITY_INFORMATION, READ_CONTROL,
SMB2_0_INFO_FILE, SMB2_FILE_STREAM_INFO)
from impacket.smbconnection import SessionError, SMBConnection
@@ -409,7 +410,7 @@ class SMBSession(object):
try:
if entry.is_directory():
if keepRemotePath:
base_path = "./" # Use root as base
base_path = ntpath.sep # Use remote share root as base
relative_path = ntpath.relpath(fullpath, base_path)
relative_path = relative_path.replace(
ntpath.sep, os.path.sep
@@ -424,7 +425,7 @@ class SMBSession(object):
pass
else:
if keepRemotePath:
base_path = "./" # Use root as base
base_path = ntpath.sep # Use remote share root as base
relative_path = ntpath.relpath(fullpath, base_path)
relative_path = relative_path.replace(
ntpath.sep, os.path.sep
@@ -460,7 +461,7 @@ class SMBSession(object):
)
return
if keepRemotePath:
base_path = "./" # Use root as base
base_path = ntpath.sep # Use remote share root as base
relative_path = ntpath.relpath(path, base_path)
relative_path = relative_path.replace(ntpath.sep, os.path.sep)
output_filepath = os.path.normpath(
@@ -498,6 +499,11 @@ class SMBSession(object):
expected_size=entry.get_filesize(),
keepRemotePath=keepRemotePath,
)
if f.fd is None:
# The local file could not be opened (already logged by LocalFileIO).
# Skip the remote transfer to avoid discarding data silently.
f.close()
return
try:
self.smbClient.getFile(
shareName=self.smb_share, pathName=full_path, callback=f.write
@@ -1276,9 +1282,8 @@ class SMBSession(object):
"Executing '%s' with arguments: %s" % (executable, args)
)
process = subprocess.Popen(
executable=executable,
args=args,
shell=True,
args=[executable, *args],
shell=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
@@ -1332,8 +1337,8 @@ class SMBSession(object):
"""
Checks if the specified path is a directory on the SMB share.
This method determines if a given path corresponds to a directory on the SMB share. It does this by listing the
contents of the path and filtering for entries that match the basename of the path and are marked as directories.
This method determines if a given path corresponds to a directory on the SMB share. It first tries to resolve it
through a directory listing, then falls back to opening the target path directly as a directory.
Args:
path (str, optional): The path to check on the SMB share. Defaults to None.
@@ -1366,9 +1371,9 @@ class SMBSession(object):
if c.get_longname() == ntpath.basename(path)
and c.is_directory()
]
return len(contents) != 0
return len(contents) != 0 or self._can_open_directory(path)
except Exception:
return False
return self._can_open_directory(path)
else:
return False
@@ -1435,7 +1440,7 @@ class SMBSession(object):
if os.path.sep in localpath:
if localpath.startswith(os.path.sep):
# Absolute path
tmp_search_path = os.path.normpath(localpath)
tmp_search_path = os.path.normpath(os.path.dirname(localpath))
else:
# Relative path
tmp_search_path = os.path.normpath(
@@ -1454,9 +1459,8 @@ class SMBSession(object):
for entry in matches:
if entry == filename:
matching_entries.append(entry)
elif "*" in filename:
regexp = filename.replace(".", "\\.").replace("*", ".*")
if re.match(regexp, entry):
elif "*" in filename or "?" in filename or "[" in filename:
if fnmatch.fnmatchcase(entry, filename):
matching_entries.append(entry)
matching_entries = sorted(list(set(matching_entries)))
@@ -1668,9 +1672,8 @@ class SMBSession(object):
continue
if entry.get_longname() == filename:
matching_entries.append(entry)
elif "*" in filename:
regexp = filename.replace(".", "\\.").replace("*", ".*")
if re.match(regexp, entry.get_longname()):
elif "*" in filename or "?" in filename or "[" in filename:
if fnmatch.fnmatchcase(entry.get_longname(), filename):
matching_entries.append(entry)
matching_entries = sorted(
@@ -1974,9 +1977,37 @@ class SMBSession(object):
if path in ["", ".", ".."]:
self.smb_cwd = ""
else:
if self.path_isdir(pathFromRoot=path.strip(ntpath.sep)):
# Path exists on the remote
path_from_root = path.strip(ntpath.sep)
if self.path_isdir(pathFromRoot=path_from_root):
self.smb_cwd = ntpath.normpath(path)
else:
# Path does not exists or is not a directory on the remote
self.logger.error("Remote directory '%s' does not exist." % path)
def _can_open_directory(self, pathFromRoot: str) -> bool:
"""
Returns True when the given remote path can be opened as a directory,
without requiring the parent or target directory to be listable.
"""
path = pathFromRoot.replace("*", "").replace("/", ntpath.sep)
path = ntpath.normpath(path).lstrip(ntpath.sep)
if not path or path in [".", ".."]:
return True
file_id = None
try:
file_id = self.smbClient.createFile(
treeId=self.smb_tree_id,
pathName=path,
desiredAccess=FILE_TRAVERSE,
creationOption=FILE_DIRECTORY_FILE,
creationDisposition=FILE_OPEN,
)
return True
except Exception:
return False
finally:
if file_id is not None:
try:
self.smbClient.closeFile(self.smb_tree_id, file_id)
except Exception:
pass
+5 -6
View File
@@ -35,17 +35,16 @@ class SessionsManager(object):
sessions (dict): A dictionary of all active sessions, keyed by their session ID.
"""
sessions = {}
next_session_id: int = 1
current_session: Optional[SMBSession] = None
current_session_id: Optional[int]
config: Config
logger: Logger
def __init__(self, config: Config, logger: Logger):
self.config = config
self.logger = logger
self.sessions: dict = {}
self.next_session_id: int = 1
self.current_session: Optional[SMBSession] = None
self.current_session_id: Optional[int] = None
def create_new_session(
self,
@@ -214,7 +213,7 @@ class SessionsManager(object):
dest="auth_username",
metavar="USER",
action="store",
help="User to authenticate with.",
help=r"User to authenticate with. Supports DOMAIN\user.",
)
secret = mode_create.add_argument_group()
cred = secret.add_mutually_exclusive_group()
+9 -9
View File
@@ -157,7 +157,7 @@ class Find(Module):
for item in exclude_dirs:
parts = item.split(":")
dirname = parts[0]
depth = 0 # Default depth
depth = 0 # Default depth
case_sensitive = False # Default to case-insensitive
# Parse depth if provided
@@ -165,7 +165,7 @@ class Find(Module):
try:
depth = int(parts[1])
except ValueError:
depth = 0 # Default if depth is invalid
depth = 0 # Default if depth is invalid
# Parse case sensitivity if provided
if len(parts) > 2 and parts[2]:
@@ -200,14 +200,14 @@ class Find(Module):
if not case_sensitive:
filename = filename.lower()
for pattern in flat_patterns:
pattern = pattern.strip()
if not pattern:
continue
if not case_sensitive:
pattern = pattern.lower()
if fnmatch.fnmatch(filename, pattern):
return True
return False
@@ -264,17 +264,17 @@ class Find(Module):
# 1. Check Exclusions (--exclude)
if self.options.exclude_name:
if self._match_pattern(filename, self.options.exclude_name, case_sensitive=True):
continue # Skip excluded file
continue # Skip excluded file
# 2. Check Inclusions (-name)
if self.options.name:
if not self._match_pattern(filename, self.options.name, case_sensitive=True):
continue # Skip if it doesn't match name pattern
continue # Skip if it doesn't match name pattern
# 3. Check Case-Insensitive Inclusions (-iname)
if self.options.iname:
if not self._match_pattern(filename, self.options.iname, case_sensitive=False):
continue # Skip if it doesn't match iname pattern
continue # Skip if it doesn't match iname pattern
# Actions on matches
if self.options.download:
@@ -289,9 +289,9 @@ class Find(Module):
rel_path = rel_path.lstrip(ntpath.sep).lstrip('/')
if rel_path:
path_to_download = rel_path
self.smbSession.get_file(path=path_to_download, keepRemotePath=True)
# Output formats
output_str = ""
if self.options.ls:
+18 -2
View File
@@ -61,8 +61,9 @@ class Credentials(object):
):
super(Credentials, self).__init__()
# Identity
self.domain = domain
self.username = username
self.domain, self.username = self.parse_domain_username(
domain=domain, username=username
)
self.password = password
# Hashes
@@ -74,6 +75,21 @@ class Credentials(object):
self.aesKey = aesKey
self.ccacheFile = ccacheFile
@staticmethod
def parse_domain_username(
domain: str, username: Optional[str]
) -> tuple[str, Optional[str]]:
if username is None:
return domain, username
for separator in ("\\", "/"):
if separator in username:
user_domain, user_name = username.split(separator, 1)
if user_domain and user_name:
return user_domain, user_name
return domain, username
def set_hashes(self, hashes: Optional[str]):
"""
Sets the LM and NT hashes for the credentials.
+4
View File
@@ -61,6 +61,10 @@ def parse_lm_nt_hashes(lm_nt_hashes_string: str) -> tuple[str, str]:
elif m_lm_hash is not None and m_nt_hash is None:
lm_hash_value = m_lm_hash
nt_hash_value = "31d6cfe0d16ae931b73c59d7e0c089c0"
else:
lm_hash_value = m_lm_hash
nt_hash_value = m_nt_hash
return lm_hash_value, nt_hash_value