Compare commits

...
4 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
5 changed files with 769 additions and 299 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
+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")
+27 -5
View File
@@ -334,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
+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