nfswolf 1.0.0

Pure-Rust NFS (v2/v3/v4) security toolkit: recon, analysis, export-escape, FUSE mount, and an interactive shell for authorized red-team and pentest work.
//! UID/GID brute-force as a last-resort credential discovery tool.
//!
//! Most of the time you should NOT need this: `shell` and `mount` already
//! run an automatic credential ladder (owner -> root -> common service
//! UIDs) on every NFS3ERR_ACCES, and `escape` produces a root handle
//! that bypasses export-level access checks entirely. Reach for
//! `uid-spray` only when both of those have failed and you genuinely need
//! to enumerate the UID/GID space against a single path -- typically to
//! confirm whether an export is truly inaccessible or to map which
//! identities the server happens to recognise.

use clap::Parser;
use colored::Colorize as _;

use crate::cli::probe::{lookup_path, make_client_with_hostname, make_mount_client, parse_addr_with_port};
use crate::cli::{GlobalOpts, H_BEHAVIOR, H_IDENTITY, H_STEALTH, H_TARGET};
use crate::engine::uid_sprayer::{SprayConfig, UidSprayer, access_bits};
use crate::util::stealth::StealthConfig;

/// Spray UID/GID combinations to find which identities can access a path.
///
/// Requires NFSv3 -- the permission oracle is the NFSv3 ACCESS procedure
/// (RFC 1813 sec. 3.3.4), which has no equivalent in NFSv2.
///
/// Last-resort credential discovery. This should not normally be needed:
/// the auto-UID ladder built into `shell` and `mount` already tries owner,
/// root, and common service UIDs on every ACCES, and `escape` bypasses
/// export-level access checks entirely. `uid-spray` is included as a
/// fallback when those don't pin down a working credential.
///
/// Examples:
///   nfswolf uid-spray 192.168.1.10:/srv --uid-start 0 --uid-end 5000
///   nfswolf uid-spray 192.168.1.10:/srv --path /etc/shadow --aux-gids 42,15
#[derive(Parser)]
pub(crate) struct UidSprayArgs {
    /// Target host with optional :/export suffix (e.g. 10.0.0.5:/srv)
    #[arg(help_heading = H_TARGET, value_name = "TARGET")]
    pub target: String,

    /// Export path (alternative to host:/export in the positional target)
    #[arg(short = 'e', long, value_name = "PATH", help_heading = H_TARGET)]
    pub export: Option<String>,

    /// UID range start. (uid-spray sweeps this range; the global -u/--uid is not
    /// used here -- use --uid-start/--uid-end to choose the identities tried.)
    #[arg(long, default_value = "0", value_name = "UID", help_heading = H_IDENTITY)]
    pub uid_start: u32,

    /// UID range end
    #[arg(long, default_value = "65535", value_name = "UID", help_heading = H_IDENTITY)]
    pub uid_end: u32,

    /// GID range start. Unset, each UID is tried with the matching GID.
    ///
    /// Setting a range sweeps the full UID x GID cross product, which grows
    /// fast -- see `--max-attempts`.
    #[arg(long, value_name = "GID", help_heading = H_IDENTITY)]
    pub gid_start: Option<u32>,

    /// GID range end. Unset, each UID is tried with the matching GID.
    #[arg(long, value_name = "GID", help_heading = H_IDENTITY)]
    pub gid_end: Option<u32>,

    /// Refuse to start a sweep larger than this many credential probes.
    ///
    /// A cross-product sweep is easy to request by accident: the full 16-bit
    /// space in both dimensions is 4.3 billion probes, which will not finish
    /// and would be extraordinarily loud. This is a guard against asking for
    /// that unintentionally, not a limit on what the tool can do -- raise it
    /// deliberately if the sweep is what you want.
    #[arg(long, default_value = "100000", value_name = "N", help_heading = H_BEHAVIOR)]
    pub max_attempts: u64,

    /// Path to check access against
    #[arg(long, default_value = "/", value_name = "PATH", help_heading = H_BEHAVIOR)]
    pub path: String,

    /// Delay between attempts in ms (independent of global --delay)
    #[arg(long, default_value = "0", value_name = "MS", help_heading = H_STEALTH)]
    pub attempt_delay: u64,
}

/// Run the uid-spray command.
pub(crate) async fn run(args: UidSprayArgs, globals: &GlobalOpts) -> anyhow::Result<()> {
    let target = crate::cli::target::parse(&args.target, args.export.as_deref(), None, true)?;
    let host = target.host.to_string();
    let export = target.export().unwrap_or("/").to_owned();

    let stealth = StealthConfig::new(globals.delay, globals.jitter);

    // Unset GID bounds mean "gid follows uid"; a set bound sweeps the cross
    // product. Compute the size up front so an unrunnable sweep is refused
    // before the first packet rather than discovered by it hanging.
    let paired_gid = args.gid_start.is_none() && args.gid_end.is_none();
    let gid_range = args.gid_start.unwrap_or(0)..=args.gid_end.unwrap_or(65535);
    let uid_count = u64::from(args.uid_end.saturating_sub(args.uid_start)) + 1;
    let attempts = if paired_gid { uid_count } else { uid_count.saturating_mul(u64::from(gid_range.end().saturating_sub(*gid_range.start())) + 1) };

    if attempts > args.max_attempts {
        anyhow::bail!(
            "sweep would issue {attempts} credential probes, over the {} limit.\n\
             Narrow --uid-start/--uid-end or --gid-start/--gid-end, or raise --max-attempts if this is intended.",
            args.max_attempts
        );
    }

    if paired_gid {
        eprintln!("{}", format!("[*] Spraying UIDs {}-{} (gid = uid) on {host}:{export}  --  {attempts} probes", args.uid_start, args.uid_end).yellow());
    } else {
        eprintln!("{}", format!("[*] Spraying UIDs {}-{} x GIDs {}-{} on {host}:{export}  --  {attempts} probes", args.uid_start, args.uid_end, gid_range.start(), gid_range.end()).yellow());
    }

    let addr = parse_addr_with_port(&host, globals.nfs_port)?;
    let (_, circuit, client) = make_client_with_hostname(addr, &export, 0, 0, &globals.aux_gids, stealth.clone(), globals.proxy.as_deref(), globals.nfs_port, &globals.hostname);

    // Mount to get the root handle, then walk to the target path.
    // lookup_path reuses the same pool-backed client the sprayer will use --
    // no reason to stand up a second pool and circuit breaker for one walk.
    let mount = make_mount_client(globals);
    let mnt = mount.mount(addr, &export).await?;
    let target_fh = if args.path == "/" { mnt.handle } else { lookup_path(&client, &mnt.handle, &args.path).await? };

    let sprayer = UidSprayer::new(client, circuit, stealth.clone());
    let config = SprayConfig { uid_range: args.uid_start..=args.uid_end, gid_range, paired_gid, auxiliary_gids: globals.aux_gids.clone(), _target_path: args.path, _concurrency: 1, required_access: access_bits::ALL, per_attempt_delay_ms: args.attempt_delay };

    let results = sprayer.spray(&config, &target_fh).await;
    eprintln!("{}", format!("[+] {} credential(s) granted access", results.len()).green());
    for r in &results {
        let flags = access_summary(r.access);
        eprintln!("    uid={} gid={} [{flags}]", r.uid, r.gid);
    }

    crate::cli::emit_replay(globals);
    Ok(())
}

/// Produce a compact access summary string from an access bitmask.
fn access_summary(access: u32) -> String {
    let mut flags = Vec::with_capacity(6);
    if access & access_bits::READ != 0 {
        flags.push("READ");
    }
    if access & access_bits::LOOKUP != 0 {
        flags.push("LOOKUP");
    }
    if access & access_bits::MODIFY != 0 {
        flags.push("MODIFY");
    }
    if access & access_bits::EXTEND != 0 {
        flags.push("EXTEND");
    }
    if access & access_bits::DELETE != 0 {
        flags.push("DELETE");
    }
    if access & access_bits::EXECUTE != 0 {
        flags.push("EXECUTE");
    }
    flags.join("|")
}