Skip to main content

rusthound_ce/modules/webclient/
mod.rs

1//! WebClient / WebDAV service probe for RustHound-CE (issue #72 - IsWebClientRunning)
2//! <https://github.com/g0h4n/IsWebClientRunning-rs>
3//!
4//! Runs AFTER the LDAP phase, from `modules::run_modules`, and only when the
5//! collection method contacts machines (NOT DCOnly / LdapOnly).
6//!
7//!   SMB / CREATE \PIPE\DAV RPC SERVICE on IPC$ -> Computer.IsWebClientRunning
8//!
9//! A host with the WebClient (WebDAV) service running can be coerced to
10//! authenticate over HTTP and relayed to AD CS web enrollment: it is an ESC8
11//! relay candidate. Combined with the HttpEnrollmentEndpoints probe, this closes
12//! the two preconditions BloodHound needs to light up the coercion/ESC8 path.
13//!
14//! Same SharpHound-style behaviour as the sessions and local-group modules:
15//! 445 pre-check (`transport::smb::is_reachable`), active-computer filter
16//! (`Computer::is_active`), bounded concurrency, no machine contact under
17//! DCOnly. Authentication reuses the SMB transport (password, pass the hash,
18//! pass the ticket), so nothing new is needed there. The detection stops one
19//! step earlier than LocalGroups: no DCE/RPC bind, no opnum, just the CREATE.
20
21pub mod scanner;
22pub mod types;
23
24use std::collections::HashMap;
25use std::error::Error;
26use std::sync::Arc;
27
28use futures::stream::{self, StreamExt};
29use log::{debug, info, trace};
30use tokio::sync::Semaphore;
31use tokio::time::{timeout, Duration};
32
33use crate::args::Options;
34use crate::modules::webclient::types::Outcome;
35use crate::objects::common::WebClientRunning;
36use crate::objects::computer::Computer;
37use crate::transport::smb::{connect_ipc, is_reachable, nt_hash_from_str, smb_user, SmbAuth};
38
39const DEFAULT_CONCURRENCY: usize = 10;      // ~ SharpHound --Throttle
40const DEFAULT_PORT_TIMEOUT_MS: u64 = 1_500; // 445 pre-check budget
41const DEFAULT_HOST_TIMEOUT_MS: u64 = 8_000; // whole per-host budget
42const DEFAULT_EXPIRY_DAYS: i64 = 60;        // ~ SharpHound --ComputerExpiryDays
43
44/// Entry point called by run_modules.
45pub async fn run(
46    args: &Options,
47    computers: &mut Vec<Computer>,
48) -> Result<(), Box<dyn Error>> {
49    // Hard guard: DCOnly / LdapOnly must never touch a machine.
50    if !args.collection_method.does_web_client() {
51        debug!("[webclient] collection method does not contact hosts - skipping");
52        return Ok(());
53    }
54
55    // Select ACTIVE targets only (enabled + pwdLastSet within the expiry window).
56    // The host is the computer FQDN (properties.name); no fqdn->ip lookup.
57    let targets: Vec<(String, String)> = computers
58        .iter()
59        .filter(|c| c.is_active(DEFAULT_EXPIRY_DAYS))
60        .map(|c| (c.properties().name().clone(), c.object_identifier().clone()))
61        .collect();
62
63    info!("[webclient] {} active target(s) after expiry/enabled filter", targets.len());
64    if targets.is_empty() {
65        return Ok(());
66    }
67
68    // Auth material, computed once and cloned per host.
69    let sem = Arc::new(Semaphore::new(DEFAULT_CONCURRENCY));
70    let domain = args.domain.clone();
71    let user = smb_user(args.username.as_deref().unwrap_or_default());
72    let password = args.password.clone().unwrap_or_default();
73    let nt_hash = nt_hash_from_str(args.hashes.as_deref().unwrap_or_default());
74
75    // Kerberos: ccache path from KRB5CCNAME when --kerberos is set.
76    let kerberos_ccache: Option<String> = if args.kerberos {
77        std::env::var("KRB5CCNAME").ok()
78    } else {
79        None
80    };
81    // KDC (the DC) to request cifs/<host> service tickets from.
82    let kdc: String = args
83        .ldapfqdn
84        .clone()
85        .filter(|s| !s.is_empty())
86        .or_else(|| args.ip.clone())
87        .unwrap_or_else(|| args.domain.clone());
88
89    // Probe with bounded concurrency.
90    let results: Vec<(String, Outcome)> = stream::iter(targets)
91        .map(|(host, oid)| {
92            let (sem, domain, user, password) =
93                (sem.clone(), domain.clone(), user.clone(), password.clone());
94            let nt_hash = nt_hash;
95            let kerberos_ccache = kerberos_ccache.clone();
96            let kdc = kdc.clone();
97            async move {
98                let _permit = sem.acquire().await.unwrap();
99                let outcome = probe_host(
100                    &host, &domain, &user, &password,
101                    nt_hash.as_ref(), kerberos_ccache.as_deref(), &kdc,
102                ).await;
103                (oid, outcome)
104            }
105        })
106        .buffer_unordered(DEFAULT_CONCURRENCY)
107        .collect()
108        .await;
109
110    // Fold results back onto the matching Computer objects.
111    let mut running = 0usize;
112    let mut map: HashMap<String, WebClientRunning> = HashMap::with_capacity(results.len());
113    for (oid, outcome) in &results {
114        if outcome.is_running() {
115            running += 1;
116        }
117        if let Some(reason) = outcome.failure_reason() {
118            debug!("[webclient] {oid}: {reason}");
119        }
120        map.insert(oid.clone(), api_result(outcome));
121    }
122    for c in computers.iter_mut() {
123        if let Some(v) = map.remove(c.object_identifier()) {
124            c.set_is_web_client_running(v);
125        }
126    }
127
128    info!("[webclient] WebClient running on {running}/{} probed host(s)",results.len());
129    Ok(())
130}
131
132/// Convert a probe [`Outcome`] into the serialisable `IsWebClientRunning` wrapper.
133fn api_result(o: &Outcome) -> WebClientRunning {
134    WebClientRunning {
135        result: o.is_running(),
136        collected: o.collected(),
137        failure_reason: o.failure_reason(),
138    }
139}
140
141/// Probe one host: 445 pre-check, connect IPC$ (password / hash / ticket), then
142/// the WebDAV pipe CREATE, under a whole-host timeout.
143#[allow(clippy::too_many_arguments)]
144async fn probe_host(
145    host: &str,
146    domain: &str,
147    user: &str,
148    password: &str,
149    nt_hash: Option<&[u8; 16]>,
150    kerberos_ccache: Option<&str>,
151    kdc: &str,
152) -> Outcome {
153    // SharpHound-style reachability pre-check: 445 open within budget.
154    if !is_reachable(host, DEFAULT_PORT_TIMEOUT_MS).await {
155        trace!("[{host}] 445/tcp unreachable - skip");
156        return Outcome::Unreachable(format!("{host}: 445/tcp unreachable"));
157    }
158
159    let work = async {
160        // connect + SESSION_SETUP + tree-connect IPC$: Kerberos, or password / PtH.
161        let mut smb = if let Some(ccache) = kerberos_ccache {
162            let spn = format!("cifs/{host}");
163            let (gss_blob, session_key) =
164                match crate::transport::kerberos::kerberos_material_for(ccache, &spn, kdc).await {
165                    Ok(m) => m,
166                    Err(e) => return Outcome::AuthFailed(format!("{host} krb: {e}")),
167                };
168            let auth = SmbAuth::Kerberos { gss_blob: &gss_blob, session_key: &session_key };
169            match connect_ipc(host, domain, user, auth).await {
170                Ok(c) => c,
171                Err(e) => return connect_error(host, &e),
172            }
173        } else {
174            let auth = match nt_hash {
175                Some(h) => SmbAuth::Hash(h),
176                None => SmbAuth::Password(password),
177            };
178            match connect_ipc(host, domain, user, auth).await {
179                Ok(c) => c,
180                Err(e) => return connect_error(host, &e),
181            }
182        };
183
184        debug!("[{host}] IPC$ ready, probing WebClient pipe");
185        scanner::probe(&mut smb, host).await
186    };
187
188    // Whole-host budget so a slow-but-open host can't stall a worker.
189    match timeout(Duration::from_millis(DEFAULT_HOST_TIMEOUT_MS), work).await {
190        Ok(outcome) => outcome,
191        Err(_) => Outcome::Unreachable(format!("{host}: per-host timeout")),
192    }
193}
194
195/// Map a `connect_ipc` failure (anyhow with a stable prefix set by
196/// `transport::smb`) onto the right [`Outcome`] bucket.
197fn connect_error(host: &str, e: &anyhow::Error) -> Outcome {
198    let msg = format!("{host}: {e}");
199    let s = e.to_string();
200    if s.starts_with("auth") {
201        Outcome::AuthFailed(msg)
202    } else if s.starts_with("connect:") {
203        Outcome::Unreachable(msg)
204    } else if s.contains("tree connect") {
205        // IPC$ refused: usually a hardened host, not a credential problem.
206        Outcome::AccessDenied(msg)
207    } else {
208        Outcome::Error(msg)
209    }
210}