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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
//! MOUNT protocol client  --  wraps `nfs_v3::MountClient`
//! (RFC 1813 Appendix I).
//!
//! `nfswolf-nfs3` provides MNT (get root handle), UMNT, UMNTALL, DUMP, EXPORT.
//! This module adds:
//! - Auth flavor extraction from MNT response (F-1.1)
//! - Export ACL parsing (wildcards, subnets) (F-7.1)
//! - Connected client enumeration via DUMP
//! - Stealth unmount after handle acquisition (F-2.5)

// Toolkit API  --  not all items are used in currently-implemented phases.
use onc_rpc_client::transport::DirectTransport;
use std::net::SocketAddr;

use anyhow::Context as _;
use nfs_v3::MountClient;
use nfs_v3::wire::mount::{dirpath, export_node};
use onc_xdr::Opaque;

use crate::proto::auth::{AuthFlavor, Credential};
use crate::proto::nfs3::types::FileHandle;

/// Result of a successful MNT call.
#[derive(Debug, Clone)]
pub(crate) struct MountResult {
    /// Root file handle for the exported filesystem.
    pub handle: FileHandle,
    /// Authentication flavors advertised by the server.
    ///
    /// Raw u32 values from the MNT response. Known values:
    /// `AUTH_NONE=0`, `AUTH_SYS=1`, `AUTH_SHORT=2`, `RPCSEC_GSS=6`.
    pub auth_flavors: Vec<u32>,
    /// Parsed auth flavor enum values (best-effort; stored for future ACL reporting).
    pub _parsed_flavors: Vec<AuthFlavor>,
}

/// One export with its access control list.
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub(crate) struct ExportEntry {
    /// Exported filesystem path on the server.
    pub path: String,
    /// Hostnames, IP addresses, subnets, or wildcards allowed to mount.
    ///
    /// An empty list means the export is open to all (`*`).
    pub allowed_hosts: Vec<String>,
    /// Authentication flavors from MNT response (RFC 1813 Appendix III).
    ///
    /// Populated by the scanner's MNT probe if the export is mountable.
    /// Empty if the export was enumerated via EXPORT only (no MNT attempted).
    /// Raw u32 values: `AUTH_NONE=0`, `AUTH_SYS=1`, `AUTH_SHORT=2`,
    /// `AUTH_DH=3`, `RPCSEC_GSS=6`, krb5 pseudo-flavors `390003-390005`.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub auth_flavors: Vec<u32>,
    /// File handle hex from MNT response (v3 or v1).
    ///
    /// Populated by the scanner's per-export MNT probe. Empty string if MNT
    /// was not attempted or failed.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub handle_hex: String,
}

/// A client that currently has an export mounted (from MNTPROC_DUMP).
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct MountedClient {
    /// Client hostname (as reported by the server).
    pub hostname: String,
    /// Mount point on the server.
    pub directory: String,
}

/// MOUNT protocol client.
#[derive(Debug, Clone)]
pub(crate) struct NfsMountClient {
    mount_port: Option<u16>,
    /// When true, refuse to fall back to an ephemeral source port. Set when
    /// `--privileged-port` is in effect or when retrying after the server
    /// returned MNT3ERR_ACCES from an ephemeral source port.
    privileged_required: bool,
    /// Optional SOCKS5 proxy for all TCP connections.
    proxy: Option<String>,
    /// Credential presented on MNT.
    ///
    /// Not decorative: a mountd configured `sec=sys` answers `MNT3ERR_ACCES`
    /// to an AUTH_NONE request, so an unauthenticated MNT fails against
    /// exports that would otherwise mount. It is also what puts the spoofed
    /// `--hostname` into the server's rmtab and logs.
    credential: Credential,
}

impl NfsMountClient {
    /// Create a mount client that resolves the mount port via portmapper.
    #[must_use]
    pub(crate) const fn new() -> Self {
        Self { mount_port: None, privileged_required: false, proxy: None, credential: Credential::None }
    }

    /// Create a mount client with a fixed mount port (bypasses portmapper).
    #[must_use]
    pub(crate) const fn with_port(port: u16) -> Self {
        Self { mount_port: Some(port), privileged_required: false, proxy: None, credential: Credential::None }
    }

    /// Force this client to bind a privileged source port (<1024) only.
    /// `connect()` will return an error rather than fall back to ephemeral.
    #[must_use]
    pub(crate) const fn require_privileged(mut self) -> Self {
        self.privileged_required = true;
        self
    }

    /// Present `credential` on MNT rather than mounting anonymously.
    #[must_use]
    pub(crate) fn with_credential(mut self, credential: Credential) -> Self {
        self.credential = credential;
        self
    }

    pub(crate) fn with_proxy(mut self, proxy: String) -> Self {
        self.proxy = Some(proxy);
        self
    }

    /// Mount an export and return the root file handle + auth flavors.
    ///
    /// Calls MNTPROC_MNT. Auth flavors reveal whether the server supports
    /// Kerberos or only AUTH_SYS (F-1.1).
    ///
    /// On `MNT3ERR_ACCES` (13) from an ephemeral source port, retries once
    /// with `privileged_required = true`. Most servers enforce the `secure`
    /// option (RFC 1813 Appendix I) by rejecting MNT calls from ports at
    /// or above 1024; if the privileged-port pool was exhausted on the
    /// first connect (TIME_WAIT pile-up, transient bind contention) we
    /// silently fall back to ephemeral and the server then rejects us.
    /// The retry path runs the privileged scan again with the
    /// eager-fallback path disabled, which is the same behaviour that
    /// `--privileged-port` requests explicitly.
    pub(crate) async fn mount(&self, addr: SocketAddr, export: &str) -> anyhow::Result<MountResult> {
        match self.mount_once(addr, export).await {
            Ok(r) => Ok(r),
            Err(e) if self.privileged_required => Err(e),
            Err(e) => {
                if downcast_mnt_acces(&e) {
                    tracing::warn!(%addr, %export, "MNT returned ACCES from ephemeral source port; retrying with privileged-only");
                    let priv_client = Self { mount_port: self.mount_port, privileged_required: true, proxy: self.proxy.clone(), credential: self.credential.clone() };
                    priv_client.mount_once(addr, export).await.with_context(|| format!("MNT {export} (privileged retry)"))
                } else {
                    Err(e)
                }
            },
        }
    }

    /// Single MNT attempt without the auto-retry wrapper.
    async fn mount_once(&self, addr: SocketAddr, export: &str) -> anyhow::Result<MountResult> {
        let client = self.connect(addr).await?;
        let path = dirpath(Opaque::owned(export.as_bytes().to_vec()));
        let res = client.v3_mnt(path).await.with_context(|| format!("MNT {export}"))?;
        let handle = FileHandle::from_bytes(res.fhandle.0.as_ref());
        let parsed_flavors = res.auth_flavors.iter().map(|&f| parse_flavor(f)).collect();
        Ok(MountResult { handle, auth_flavors: res.auth_flavors, _parsed_flavors: parsed_flavors })
    }

    /// Mount an NFSv2 export via MOUNT v1 MNT (program 100005, version 1, proc 1).
    ///
    /// Returns a fixed 32-byte file handle (RFC 1094 Appendix A). MOUNT v1 has
    /// no auth flavors in the response, so we return AUTH_SYS as the assumed
    /// flavor (v2 servers predate auth flavor negotiation).
    pub(crate) async fn mount_v1(&self, addr: SocketAddr, export: &str) -> anyhow::Result<MountResult> {
        match self.mount_v1_once(addr, export).await {
            Ok(r) => Ok(r),
            Err(e) if self.privileged_required => Err(e),
            Err(e) => {
                if downcast_mnt_acces(&e) {
                    tracing::warn!(%addr, %export, "MNT v1 returned ACCES; retrying with privileged-only");
                    let priv_client = Self { mount_port: self.mount_port, privileged_required: true, proxy: self.proxy.clone(), credential: self.credential.clone() };
                    priv_client.mount_v1_once(addr, export).await.with_context(|| format!("MNT v1 {export} (privileged retry)"))
                } else {
                    Err(e)
                }
            },
        }
    }

    /// Single v1 MNT attempt.
    async fn mount_v1_once(&self, addr: SocketAddr, export: &str) -> anyhow::Result<MountResult> {
        use onc_rpc_client::rpc::RpcClient;

        let portmap = match &self.proxy {
            Some(p) => crate::proto::portmap::PortmapClient::default_port().with_proxy(p.clone()),
            None => crate::proto::portmap::PortmapClient::default_port(),
        };
        let port = match self.mount_port {
            Some(p) => p,
            None => portmap.query_port(addr, 100_005, 1).await.with_context(|| "GETPORT for MOUNT v1")?,
        };
        let mount_addr = SocketAddr::new(addr.ip(), port);
        let io = if let Some(ref p) = self.proxy {
            let proxy_addr = crate::proto::conn::parse_proxy_addr(p)?;
            let stream = crate::proto::conn::socks5_connect(proxy_addr, mount_addr).await.with_context(|| format!("SOCKS5 connect to mountd v1 at {mount_addr}"))?;
            onc_rpc_client::transport::tokio::TokioIo::new(stream)
        } else if self.privileged_required {
            connect_privileged_only(mount_addr).await.with_context(|| format!("connect to mountd v1 at {mount_addr} (privileged-only)"))?
        } else {
            connect_privileged_or_fallback(mount_addr).await.with_context(|| format!("connect to mountd v1 at {mount_addr}"))?
        };
        let opaque = self.credential.to_opaque_auth();
        let mut rpc = RpcClient::new_with_auth(io, opaque, onc_rpc_client::rpc::opaque_auth::default());

        // MOUNT v1 MNT: program=100005, version=1, proc=1
        // args = dirpath (XDR string), result = fhstatus (u32 status + opaque[32])
        let path = dirpath(Opaque::owned(export.as_bytes().to_vec()));
        let result: FhStatus = rpc.call(100_005, 1, 1, &path).await.with_context(|| format!("MNT v1 {export}"))?;
        if result.status != 0 {
            anyhow::bail!("MNT v1 {export}: status {}", result.status);
        }
        let handle = FileHandle::from_bytes(&result.fhandle);
        Ok(MountResult { handle, auth_flavors: vec![1], _parsed_flavors: vec![AuthFlavor::Sys] })
    }

    /// Unmount an export (MNTPROC_UMNT) for stealth cleanup (F-2.5).
    pub(crate) async fn unmount(&self, addr: SocketAddr, export: &str) -> anyhow::Result<()> {
        let client = self.connect(addr).await?;
        let path = dirpath(Opaque::owned(export.as_bytes().to_vec()));
        client.umnt(path).await.with_context(|| format!("UMNT {export}"))
    }

    /// List all exports with their ACLs via MOUNT v3 EXPORT (MNTPROC_EXPORT).
    ///
    /// A wildcard or empty `allowed_hosts` list means the export is world-accessible (F-7.1).
    /// This queries MOUNT version 3 -- for NFSv2 exports, use `list_exports_v1()`.
    pub(crate) async fn list_exports(&self, addr: SocketAddr) -> anyhow::Result<Vec<ExportEntry>> {
        let client = self.connect(addr).await?;
        let exports = client.export().await.context("MNTPROC_EXPORT v3")?;
        Ok(exports.into_inner().into_iter().map(export_entry_from).collect())
    }

    /// List NFSv2 exports via MOUNT v1 EXPORT (program 100005, version 1, proc 5).
    ///
    /// `MountClient` hardcodes version 3.  This method issues a
    /// raw RPC call with version 1 and deserializes using the same `exports` XDR
    /// type (the wire format is identical between v1 and v3 EXPORT -- only the
    /// version number in the RPC header differs).
    ///
    /// MOUNT v1 EXPORT returns the NFSv2 export list; MOUNT v3 EXPORT returns
    /// the NFSv3 export list.  These are usually identical but CAN differ.
    pub(crate) async fn list_exports_v1(&self, addr: SocketAddr) -> anyhow::Result<Vec<ExportEntry>> {
        use nfs_mount::wire::exports;
        use onc_rpc_client::rpc::RpcClient;
        use onc_xdr::Void;

        let portmap = match &self.proxy {
            Some(p) => crate::proto::portmap::PortmapClient::default_port().with_proxy(p.clone()),
            None => crate::proto::portmap::PortmapClient::default_port(),
        };
        let port = match self.mount_port {
            Some(p) => p,
            None => portmap.query_port(addr, 100_005, 1).await.with_context(|| "GETPORT for MOUNT v1")?,
        };
        let mount_addr = SocketAddr::new(addr.ip(), port);
        // Honour the secure-port logic the rest of NfsMountClient uses: privileged
        // source port (<1024) first, no fallback when `privileged_required` is set;
        // the proxy controls its own outbound port so it bypasses privileged binding.
        let io = if let Some(ref p) = self.proxy {
            let proxy_addr = crate::proto::conn::parse_proxy_addr(p)?;
            let stream = crate::proto::conn::socks5_connect(proxy_addr, mount_addr).await.with_context(|| format!("SOCKS5 connect to mountd v1 at {mount_addr}"))?;
            onc_rpc_client::transport::tokio::TokioIo::new(stream)
        } else if self.privileged_required {
            connect_privileged_only(mount_addr).await.with_context(|| format!("connect to mountd v1 at {mount_addr} (privileged-only)"))?
        } else {
            connect_privileged_or_fallback(mount_addr).await.with_context(|| format!("connect to mountd v1 at {mount_addr}"))?
        };
        // RPC call: program=100005, version=1, proc=5 (EXPORT), args=Void
        let mut rpc = RpcClient::new(io);
        let result: exports<'_, '_> = rpc.call(100_005, 1, 5, &Void).await.context("MOUNT v1 EXPORT")?;
        Ok(result.into_inner().into_iter().map(export_entry_from).collect())
    }

    /// List connected clients via MNTPROC_DUMP.
    ///
    /// Returns hosts that currently have an export mounted.
    pub(crate) async fn dump_clients(&self, addr: SocketAddr) -> anyhow::Result<Vec<MountedClient>> {
        let client = self.connect(addr).await?;
        let dump = client.dump().await.context("MNTPROC_DUMP")?;
        Ok(dump.into_inner().into_iter().map(|b| MountedClient { hostname: bytes_to_string(b.ml_hostname.0.as_ref()), directory: bytes_to_string(b.ml_directory.0.as_ref()) }).collect())
    }

    /// Open a TCP connection to the mount daemon.
    ///
    /// When a SOCKS5 proxy is configured, the portmapper query and mount
    /// connection are both tunnelled through it. Privileged source port
    /// binding is impossible via proxy (the proxy controls outbound ports),
    /// so the privileged port logic is bypassed in that case.
    ///
    /// Without a proxy, tries privileged source ports (300-1023) first since
    /// most NFS servers require `secure` (source port < 1024). Falls back to
    /// an ephemeral port if privileged binding fails AND `privileged_required`
    /// is unset (e.g., not running as root).
    async fn connect(&self, addr: SocketAddr) -> anyhow::Result<MountClient<DirectTransport<crate::proto::conn::NfsIo>>> {
        let portmap = match &self.proxy {
            Some(p) => crate::proto::portmap::PortmapClient::default_port().with_proxy(p.clone()),
            None => crate::proto::portmap::PortmapClient::default_port(),
        };
        let port = match self.mount_port {
            Some(p) => p,
            None => match portmap.query_port(addr, 100_005, 3).await {
                Ok(p) => p,
                Err(e) => {
                    tracing::warn!(%addr, error = %e, "portmapper unavailable, trying mountd on well-known fallback ports");
                    self.probe_mountd_fallback(addr).await.with_context(|| format!("portmapper unavailable and mountd not found on fallback ports (2049, 20048) -- use --mount-port to specify manually\n  portmapper error: {e}"))?
                },
            },
        };
        let mount_addr = SocketAddr::new(addr.ip(), port);
        let io = if let Some(ref p) = self.proxy {
            let proxy_addr = crate::proto::conn::parse_proxy_addr(p)?;
            let stream = crate::proto::conn::socks5_connect(proxy_addr, mount_addr).await.with_context(|| format!("SOCKS5 connect to mountd at {mount_addr} via {p}"))?;
            onc_rpc_client::transport::tokio::TokioIo::new(stream)
        } else if self.privileged_required {
            connect_privileged_only(mount_addr).await.with_context(|| format!("connect to mountd at {mount_addr} (privileged-only)"))?
        } else {
            connect_privileged_or_fallback(mount_addr).await.with_context(|| format!("connect to mountd at {mount_addr}"))?
        };
        Ok(MountClient::v3(DirectTransport::with_auth(io, self.credential.to_opaque_auth(), onc_rpc_client::rpc::opaque_auth::default())))
    }

    /// Try well-known mountd ports when portmapper is unavailable.
    ///
    /// Linux mountd typically uses random high ports (requires portmapper), but
    /// Windows NFS multiplexes mountd on port 2049 and some Linux installations
    /// use the fixed port 20048 (common in RHEL/Fedora nfs-utils configs).
    /// We attempt a MNT NULL call on each candidate to confirm it speaks MOUNT.
    async fn probe_mountd_fallback(&self, addr: SocketAddr) -> anyhow::Result<u16> {
        const FALLBACK_PORTS: &[u16] = &[2049, 20048];
        for &port in FALLBACK_PORTS {
            let mount_addr = SocketAddr::new(addr.ip(), port);
            let io_result = if let Some(ref p) = self.proxy {
                let proxy_addr = crate::proto::conn::parse_proxy_addr(p)?;
                crate::proto::conn::socks5_connect(proxy_addr, mount_addr).await.map(onc_rpc_client::transport::tokio::TokioIo::new)
            } else {
                use onc_rpc_client::transport::net::Connector as _;
                onc_rpc_client::transport::tokio::TokioConnector.connect(mount_addr).await
            };
            let Ok(io) = io_result else { continue };
            let mc = MountClient::v3(DirectTransport::new(io));
            if mc.export().await.is_ok() {
                tracing::info!(%addr, port, "mountd found on fallback port");
                return Ok(port);
            }
        }
        anyhow::bail!("mountd not reachable on fallback ports {FALLBACK_PORTS:?}")
    }
}

impl Default for NfsMountClient {
    fn default() -> Self {
        Self::new()
    }
}

/// Returns true when `err` was caused by `MNT3ERR_ACCES` (13) from the server.
///
/// Used to decide whether to retry with a privileged source port.
/// We walk the anyhow source chain because `mount_once` wraps the raw
/// `nfs_mount::MountError` with a `with_context`.
fn downcast_mnt_acces(err: &anyhow::Error) -> bool {
    use nfs_mount::MountError;
    use nfs_mount::wire::mountstat3;
    err.chain().any(|cause| matches!(cause.downcast_ref::<MountError<onc_rpc_client::RpcError>>(), Some(MountError::Status(mountstat3::MNT3ERR_ACCES))))
}

/// Connect to `addr` from a privileged source port (300-1023), falling back to ephemeral.
///
/// Only `PermissionDenied` (no `CAP_NET_BIND_SERVICE` and not root) breaks
/// out of the privileged pass; transient errors like `AddrInUse`, server
/// reset, or connection refused advance to the next port. This protects
/// against TIME_WAIT pile-up on busy hosts that would otherwise eat the
/// privileged range and silently slide us onto an ephemeral port.
async fn connect_privileged_or_fallback(addr: SocketAddr) -> std::io::Result<crate::proto::conn::NfsIo> {
    use onc_rpc_client::transport::net::Connector as _;
    use onc_rpc_client::transport::tokio::TokioConnector;

    for local_port in 300_u16..1024 {
        match TokioConnector.connect_with_port(addr, local_port).await {
            Ok(io) => return Ok(io),
            Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
                tracing::debug!(%addr, "no privilege to bind <1024, falling back to ephemeral");
                break;
            },
            Err(e) => {
                tracing::trace!(%addr, port = local_port, %e, "privileged mountd connect failed, trying next port");
            },
        }
    }
    tracing::warn!(%addr, "privileged port binding failed, falling back to ephemeral port -- server may reject with EACCES");
    TokioConnector.connect(addr).await
}

/// Connect to `addr` from a privileged source port only -- never falls back.
///
/// Used by `--privileged-port` and by the auto-retry path on
/// `MNT3ERR_ACCES`. Returns the last error if every port in 300-1023
/// fails to bind/connect.
async fn connect_privileged_only(addr: SocketAddr) -> std::io::Result<crate::proto::conn::NfsIo> {
    use onc_rpc_client::transport::net::Connector as _;
    use onc_rpc_client::transport::tokio::TokioConnector;

    let mut last_err: Option<std::io::Error> = None;
    for local_port in 300_u16..1024 {
        match TokioConnector.connect_with_port(addr, local_port).await {
            Ok(io) => return Ok(io),
            Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => return Err(e),
            Err(e) => {
                tracing::trace!(%addr, port = local_port, %e, "privileged-only mountd connect failed, trying next port");
                last_err = Some(e);
            },
        }
    }
    Err(last_err.unwrap_or_else(|| std::io::Error::other("privileged source port range 300-1023 exhausted")))
}

/// Convert raw auth flavor u32 to the `AuthFlavor` enum.
const fn parse_flavor(raw: u32) -> AuthFlavor {
    AuthFlavor::from_u32(raw)
}

/// Convert an `export_node` to our `ExportEntry`.
fn export_entry_from(node: export_node<'_, '_>) -> ExportEntry {
    let path = bytes_to_string(node.ex_dir.0.as_ref());
    let allowed_hosts = node.ex_groups.into_inner().into_iter().map(|n| bytes_to_string(n.0.as_ref())).collect();
    ExportEntry { path, allowed_hosts, auth_flavors: Vec::new(), handle_hex: String::new() }
}

/// Decode XDR bytes to a UTF-8 string, replacing invalid bytes with `?`.
fn bytes_to_string(bytes: &[u8]) -> String {
    String::from_utf8_lossy(bytes).into_owned()
}

/// Re-use the MOUNT v1 MNT response from nfswolf-mount.
use nfs_mount::wire::FhStatus;