rusthound_ce/modules/webclient/
mod.rs1pub 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; const DEFAULT_PORT_TIMEOUT_MS: u64 = 1_500; const DEFAULT_HOST_TIMEOUT_MS: u64 = 8_000; const DEFAULT_EXPIRY_DAYS: i64 = 60; pub async fn run(
46 args: &Options,
47 computers: &mut Vec<Computer>,
48) -> Result<(), Box<dyn Error>> {
49 if !args.collection_method.does_web_client() {
51 debug!("[webclient] collection method does not contact hosts - skipping");
52 return Ok(());
53 }
54
55 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 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 let kerberos_ccache: Option<String> = if args.kerberos {
77 std::env::var("KRB5CCNAME").ok()
78 } else {
79 None
80 };
81 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 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 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
132fn 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#[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 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 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 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
195fn 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 Outcome::AccessDenied(msg)
207 } else {
208 Outcome::Error(msg)
209 }
210}