Skip to content

F-2.7: NFS Daemon Export ACL Blindness (Bearer Token Property)

Classification

  • Severity: Critical
  • CVSS Vector: Network / Low Complexity / Requires Handle Possession
  • Affected Versions: NFSv2, NFSv3 (all implementations)
  • RFC Reference: RFC 2623 §2.6, RFC 2055 §6.3, RFC 1094 §2.3.3
  • Prerequisite: Possession of a valid file handle (obtained by any means)

Summary

The NFS daemon (port 2049) never verifies that the requesting client was authorized to receive the file handle it presents. Once a handle exists (obtained legitimately, via sibling export escape, via network sniffing, via UDP MOUNT spoof, or via brute-force), it functions as a permanent bearer token from any source IP, any UID, over any transport. There is no session binding, no MAC, no expiry, and no revocation mechanism.

This is the architectural root cause behind F-2.1 (export escape), F-2.5 (stale handle persistence), F-3.6 (UDP MOUNT handle theft), and F-5.2 (READDIRPLUS handle harvesting). All of these are specific instances of this fundamental property.

Technical detail

The decoupled authorization model

MOUNT daemon (userspace):
  - Checks source IP against /etc/exports ACL
  - If allowed: computes file handle, returns it to client
  - Writes advisory entry to /proc/net/rpc/... kernel caches
  - NEVER contacted again after issuing the handle

NFS daemon (kernel):
  - Receives RPC with file handle
  - Extracts fsid + fileid from handle
  - Looks up (client_domain, fsid) in export cache
  - Resolves fileid to inode via exportfs_decode_fh()
  - Serves the request if inode is valid
  - Does NOT call back to mountd to verify "was this client authorized for THIS handle?"

"Many NFS server implementations rely on the MOUNT protocol for checking access to exported filesystems and the NFS server itself does no access checking. The NFS server assumes that the filehandle does double duty: identifying a file as well as being a security token." — RFC 2055 §6.3

Kernel code path (Linux 6.8)

Every NFS RPC call flows through fh_verify() (fs/nfsd/nfsfh.c:328):

fh_verify(struct svc_rqst *rqstp, struct svc_fh *fhp, umode_t type, int access)
{
    // Step 1: set_fh_dentry -> rqst_exp_find(rqstp, fsid_type, fsid)
    //   This maps (client_auth_domain, fsid) -> svc_export
    //   client_auth_domain comes from ip_map (source IP -> domain name)
    //   The domain is typically "*" for any IP that has mounted ANY export

    // Step 2: check_nfsd_access(exp, rqstp)
    //   Checks auth flavor (AUTH_SYS allowed?) -- NOT "was this handle
    //   originally issued to this client"

    // Step 3: nfsd_permission(rqstp, exp, dentry, access)
    //   Standard Unix permission check using the asserted UID/GID
}

The critical gap: rqst_exp_find() looks up the export by fsid, not by the specific export path. If the client's IP maps to domain * (which happens automatically when ANY wildcard export exists on the same filesystem), then handles for ALL directories on that filesystem resolve successfully, including directories that are IP-restricted exports.

The ip_map cache

The kernel maintains an IP-to-domain cache at /proc/net/rpc/auth.unix.ip/content:

#class IP domain
nfsd 192.168.1.100 *

Once a client mounts ANY *-accessible export, their IP is cached as domain *. Subsequent NFS calls with handles pointing to restricted exports on the same filesystem will find a valid (domain=*, fsid) entry in the export cache and succeed.

What "same filesystem" means

Two exports share a filesystem if they have the same fsid (device major:minor, or UUID if fsid= is used). The handle encodes the fsid; the kernel uses it to find the export, not the original mount path.

/etc/exports:
  /srv/nfs/public     *(rw)              # fsid from /dev/sda1
  /srv/nfs/restricted 10.0.0.50(rw)      # same /dev/sda1, same fsid!

Both exports produce handles with the same fsid bytes.
A handle from /public can reference inodes in /restricted.

Proof

Step 1: Verify MOUNT is denied for restricted export

$ nfswolf shell target:/srv/nfs/restricted --uid 0
Error: MNT3ERR_ACCES  # IP not in ACL

Step 2: Obtain handle via sibling export escape

$ nfswolf shell target:/srv/nfs/public --uid 0
nfs> escape-root
[+] escaped to filesystem root (Ext4)
nfs> cd /srv/nfs/restricted
nfs> handle
0100070116002a00...  # handle for restricted dir
nfs> cat data.txt
restricted content   # accessed without MOUNT authorization

Step 3: Use handle from any IP (including non-authorized IPs)

$ nfswolf shell target --handle 0100070116002a00... --uid 0
# Works from ANY source IP -- nfsd never checks

Impact

  • Export ACLs are security theater when any wildcard export shares a filesystem with a restricted export
  • Handle possession = permanent access regardless of subsequent ACL changes
  • No audit trail -- the server cannot distinguish legitimate handle use from stolen handles
  • No revocation short of reformatting the filesystem or restarting nfsd (disrupts all clients)

Detection (nfswolf)

The analyzer should: 1. Identify restricted exports that share a filesystem with wildcard exports (same fsid) 2. Flag these as "ACL bypassable via sibling export escape" 3. Test by obtaining a handle from the wildcard export and confirming GETATTR succeeds on the restricted directory's inode

Remediation

  1. Separate filesystems per trust boundary -- each restricted export on its own partition/volume so no sibling escape is possible

  2. Enable subtree_check on restricted exports, forcing the kernel to verify the handle's inode is within the exported subtree (performance cost, stale handle risk):

    /srv/restricted  10.0.0.50(rw,subtree_check)
    

  3. Use Kerberos -- RPCSEC_GSS binds access to a principal, not a bearer token

  4. Use NFSv4 with lease-based state -- server can revoke delegations

  5. Monitor for cross-export handle use -- detect GETATTR/READ on inodes outside the client's mounted export path (requires custom kernel tracing, not standard)

Relationship to other findings

  • Root cause for: F-2.1, F-2.5, F-3.6, F-5.2
  • Amplified by: F-7.1 (wildcard exports make domain=* trivial to obtain)
  • Mitigated by: subtree_check (F-2.6), separate filesystems, Kerberos