Skip to main content

rusthound_ce/modules/webclient/
types.rs

1//! What the WebClient scanner collects: the WebDAV pipe name, the NTSTATUS
2//! values we branch on, the per-host [`Outcome`], and the [`classify`] that maps
3//! one onto the other. Kept free of any SMB types so it unit-tests with no
4//! Domain Controller (like LocalGroups-rs tests its NDR decoders).
5//!
6//! Ported from <https://github.com/g0h4n/IsWebClientRunning-rs> for issue #72.
7
8/// SMB2 CREATE filename for the WebClient pipe (no leading separator; the tree
9/// is already IPC$). Its presence is the whole signal: a host running the
10/// WebClient (WebDAV) service registers this pipe, which makes it an ESC8 /
11/// coercion relay candidate.
12pub const PIPE_NAME: &str = "DAV RPC SERVICE";
13
14/// Human-facing full path, used in logs.
15pub const PIPE_NAME_DISPLAY: &str = r"\PIPE\DAV RPC SERVICE";
16
17/// NTSTATUS values we care about when opening the pipe.
18pub mod status {
19    pub const SUCCESS: u32 = 0x0000_0000;
20    pub const OBJECT_NAME_NOT_FOUND: u32 = 0xC000_0034;
21    pub const OBJECT_PATH_NOT_FOUND: u32 = 0xC000_003A;
22    pub const PIPE_NOT_AVAILABLE: u32 = 0xC000_00AC;
23    pub const ACCESS_DENIED: u32 = 0xC000_0022;
24    pub const BAD_NETWORK_NAME: u32 = 0xC000_00CC;
25    pub const LOGON_FAILURE: u32 = 0xC000_006D;
26}
27
28/// Outcome of a single host probe, before it becomes a serialisable row.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Outcome {
31    /// The `DAV RPC SERVICE` pipe opened: WebClient is running.
32    Running,
33    /// The pipe does not exist (STATUS_OBJECT_NAME_NOT_FOUND): service stopped.
34    NotRunning,
35    /// IPC$ or the pipe was refused (STATUS_ACCESS_DENIED and friends).
36    AccessDenied(String),
37    /// TCP / NEGOTIATE / SESSION_SETUP never completed (or timed out).
38    Unreachable(String),
39    /// Credentials were rejected.
40    AuthFailed(String),
41    /// Anything else, carried verbatim for the trace.
42    Error(String),
43}
44
45impl Outcome {
46    pub fn is_running(&self) -> bool {
47        matches!(self, Outcome::Running)
48    }
49
50    /// Whether the probe produced a trustworthy running/not-running verdict.
51    pub fn collected(&self) -> bool {
52        matches!(self, Outcome::Running | Outcome::NotRunning)
53    }
54
55    pub fn failure_reason(&self) -> Option<String> {
56        match self {
57            Outcome::Running | Outcome::NotRunning => None,
58            Outcome::AccessDenied(m)
59            | Outcome::Unreachable(m)
60            | Outcome::AuthFailed(m)
61            | Outcome::Error(m) => Some(m.clone()),
62        }
63    }
64}
65
66/// Map the NTSTATUS returned by the pipe CREATE into an [`Outcome`].
67/// Isolated from the network so it unit-tests with no Domain Controller.
68pub fn classify(nt_status: u32) -> Outcome {
69    match nt_status {
70        status::SUCCESS => Outcome::Running,
71        status::OBJECT_NAME_NOT_FOUND
72        | status::OBJECT_PATH_NOT_FOUND
73        | status::PIPE_NOT_AVAILABLE => Outcome::NotRunning,
74        status::ACCESS_DENIED => Outcome::AccessDenied(format!(
75            "CREATE {PIPE_NAME_DISPLAY} refused (0x{nt_status:08X})"
76        )),
77        status::BAD_NETWORK_NAME => {
78            Outcome::Error(format!("IPC$ tree connect failed (0x{nt_status:08X})"))
79        }
80        status::LOGON_FAILURE => {
81            Outcome::AuthFailed(format!("session setup rejected (0x{nt_status:08X})"))
82        }
83        other => Outcome::Error(format!("unexpected NTSTATUS 0x{other:08X}")),
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn success_is_running() {
93        assert_eq!(classify(status::SUCCESS), Outcome::Running);
94        assert!(classify(status::SUCCESS).is_running());
95    }
96
97    #[test]
98    fn missing_pipe_is_not_running() {
99        assert_eq!(classify(status::OBJECT_NAME_NOT_FOUND), Outcome::NotRunning);
100        assert_eq!(classify(status::OBJECT_PATH_NOT_FOUND), Outcome::NotRunning);
101        assert_eq!(classify(status::PIPE_NOT_AVAILABLE), Outcome::NotRunning);
102        assert!(classify(status::OBJECT_NAME_NOT_FOUND).collected());
103    }
104
105    #[test]
106    fn denied_is_uncollected() {
107        let o = classify(status::ACCESS_DENIED);
108        assert!(matches!(o, Outcome::AccessDenied(_)));
109        assert!(!o.collected());
110        assert!(!o.is_running());
111    }
112
113    #[test]
114    fn auth_and_network_map_through() {
115        assert!(matches!(classify(status::LOGON_FAILURE), Outcome::AuthFailed(_)));
116        assert!(matches!(classify(status::BAD_NETWORK_NAME), Outcome::Error(_)));
117    }
118
119    #[test]
120    fn unknown_status_is_error() {
121        assert!(matches!(classify(0xC000_0001), Outcome::Error(_)));
122    }
123}