1pub mod samr;
21pub mod types;
22
23use std::error::Error;
24use std::sync::Arc;
25
26use futures::stream::{self, StreamExt};
27use log::{debug, info, trace, warn};
28use tokio::net::TcpStream;
29use tokio::sync::Semaphore;
30use tokio::time::{timeout, Duration};
31
32use smb2_client::SmbClient;
33use windows_sddl::sid::Sid;
34
35use crate::args::Options;
36use crate::objects::common::{LocalGroup, Member, UserRight};
37use crate::objects::computer::Computer;
38use crate::objects::user::User;
39use crate::transport::smb::{connect_ipc, open_rpc_pipe, smb_user, SmbAuth};
40
41use self::samr::{is_domain_controller, is_under, sid_to_string, SamrAliasClient};
42use self::types::{group_display_name, group_object_id, Alias, AliasFinding, HostFindings, ALIASES};
43
44const DEFAULT_CONCURRENCY: usize = 10;
45const DEFAULT_PORT_TIMEOUT_MS: u64 = 1_500;
46const DEFAULT_HOST_TIMEOUT_MS: u64 = 8_000;
47const DEFAULT_EXPIRY_DAYS: i64 = 60;
48
49pub async fn run(
51 args: &Options,
52 _users: &[User], computers: &mut Vec<Computer>,
54 sid_type: &std::collections::HashMap<String, String>,
55) -> Result<(), Box<dyn Error>> {
56 if !args.collection_method.does_local_group() {
57 debug!("[localgroups] collection method does not contact hosts - skipping");
58 return Ok(());
59 }
60
61 let targets: Vec<(String, String)> = computers
62 .iter()
63 .filter(|c| is_active(c, DEFAULT_EXPIRY_DAYS))
64 .map(|c| (c.properties().name().clone(), c.object_identifier().clone()))
65 .collect();
66
67 info!("[localgroups] {} active target(s) after expiry/enabled filter", targets.len());
68
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 = parse_hash(args.hashes.as_deref());
74
75 let kerberos_ccache: Option<String> =
76 if args.kerberos { std::env::var("KRB5CCNAME").ok() } else { None };
77 let kdc: String = args
78 .ldapfqdn
79 .clone()
80 .filter(|s| !s.is_empty())
81 .or_else(|| args.ip.clone())
82 .unwrap_or_else(|| args.domain.clone());
83
84 let findings: Vec<HostFindings> = stream::iter(targets)
85 .map(|(host, computer_sid)| {
86 let (sem, domain, user, password) =
87 (sem.clone(), domain.clone(), user.clone(), password.clone());
88 let nt_hash = nt_hash;
89 let kerberos_ccache = kerberos_ccache.clone();
90 let kdc = kdc.clone();
91 async move {
92 let _permit = sem.acquire().await.unwrap();
93 enumerate_host(
94 &host, computer_sid, &domain, &user, &password,
95 nt_hash.as_ref(), kerberos_ccache.as_deref(), &kdc,
96 )
97 .await
98 }
99 })
100 .buffer_unordered(DEFAULT_CONCURRENCY)
101 .collect()
102 .await;
103
104 let mut total_edges = 0usize;
105 for hf in &findings {
106 total_edges += apply_findings(computers, hf, sid_type, &domain);
107 for e in &hf.errors {
108 warn!("{e}");
109 }
110 }
111 info!("[localgroups] {total_edges} edge(s) across {} host(s)", findings.len());
112
113 Ok(())
114}
115
116#[allow(clippy::too_many_arguments)]
119async fn enumerate_host(
120 host: &str,
121 computer_sid: String,
122 domain: &str,
123 user: &str,
124 password: &str,
125 nt_hash: Option<&[u8; 16]>,
126 kerberos_ccache: Option<&str>,
127 kdc: &str,
128) -> HostFindings {
129 if !is_reachable(host, DEFAULT_PORT_TIMEOUT_MS).await {
130 trace!("[{host}] 445/tcp unreachable - skip");
131 return HostFindings {
132 computer_sid,
133 host: host.to_string(),
134 is_dc: false,
135 aliases: Vec::new(),
136 errors: vec![format!("{host}: 445/tcp unreachable")],
137 };
138 }
139
140 let work = async {
141 let mut aliases: Vec<AliasFinding> = Vec::new();
142 let mut errors: Vec<String> = Vec::new();
143 let mut is_dc = false;
144
145 let fatal: Result<(), String> = async {
146 let mut smb = if let Some(ccache) = kerberos_ccache {
147 let spn = format!("cifs/{host}");
148 let (gss_blob, session_key) =
149 crate::transport::kerberos::kerberos_material_for(ccache, &spn, kdc)
150 .await
151 .map_err(|e| format!("{host} krb: {e}"))?;
152 let auth = SmbAuth::Kerberos { gss_blob: &gss_blob, session_key: &session_key };
153 connect_ipc(host, domain, user, auth).await.map_err(|e| format!("{host}: {e}"))?
154 } else {
155 let auth = match nt_hash {
156 Some(h) => SmbAuth::Hash(h),
157 None => SmbAuth::Password(password),
158 };
159 connect_ipc(host, domain, user, auth).await.map_err(|e| format!("{host}: {e}"))?
160 };
161
162 let (host_aliases, host_errors, dc) =
163 samr_local_groups(&mut smb, host, domain, &computer_sid).await;
164 aliases = host_aliases;
165 errors.extend(host_errors);
166 is_dc = dc;
167 Ok(())
168 }
169 .await;
170
171 if let Err(e) = fatal {
172 errors.push(e);
173 }
174 (aliases, errors, is_dc)
175 };
176
177 match timeout(Duration::from_millis(DEFAULT_HOST_TIMEOUT_MS), work).await {
178 Ok((aliases, errors, is_dc)) => HostFindings {
179 computer_sid, host: host.to_string(), is_dc, aliases, errors,
180 },
181 Err(_elapsed) => HostFindings {
182 computer_sid,
183 host: host.to_string(),
184 is_dc: false,
185 aliases: Vec::new(),
186 errors: vec![format!("{host}: per-host timeout")],
187 },
188 }
189}
190
191async fn samr_local_groups(
193 smb: &mut SmbClient,
194 host: &str,
195 domain: &str,
196 computer_object_id: &str,
197) -> (Vec<AliasFinding>, Vec<String>, bool) {
198 let mut out = Vec::new();
199 let mut errors = Vec::new();
200
201 let pipe = match open_rpc_pipe(smb, host, "samr").await {
202 Ok(p) => p,
203 Err(e) => {
204 errors.push(format!("{host} \\samr pipe: {e}"));
205 return (out, errors, false);
206 }
207 };
208 let mut samr = match SamrAliasClient::bind(smb, pipe).await {
209 Ok(c) => c,
210 Err(e) => {
211 errors.push(format!("{host} SAMR bind: {e}"));
212 return (out, errors, false);
213 }
214 };
215
216 let server = match samr.connect(&format!("\\\\{host}")).await {
217 Ok(h) => h,
218 Err(e) => {
219 errors.push(format!("{host} SamrConnect2: {e}"));
220 return (out, errors, false);
221 }
222 };
223
224 let mut machine_sid: Option<Sid> = None;
225 let mut is_dc = false;
226 match samr.machine_sid(&server).await {
227 Ok(Some((name, sid))) => {
228 if is_domain_controller(&name, domain) {
229 is_dc = true;
230 } else {
231 machine_sid = Some(sid);
232 }
233 }
234 Ok(None) => {}
235 Err(e) => errors.push(format!("{host} machine SID: {e}")),
236 }
237
238 let builtin = match samr.open_builtin(&server).await {
239 Ok(h) => h,
240 Err(e) => {
241 errors.push(format!("{host} SamrOpenDomain(BUILTIN): {e}"));
242 let _ = samr.close_handle(&server).await;
243 return (out, errors, is_dc);
244 }
245 };
246
247 for Alias { rid, name: alias_name, .. } in ALIASES {
248 let group_sid = group_object_id(*rid, domain, is_dc, computer_object_id);
249
250 let handle = match samr.open_alias(&builtin, *rid).await {
251 Ok(h) => h,
252 Err(e) => {
253 out.push(AliasFinding {
254 group_sid, group_name: alias_name, members: Vec::new(),
255 collected: false, failure: Some(e.to_string()),
256 });
257 continue;
258 }
259 };
260 let members = match samr.get_members_in_alias(&handle).await {
261 Ok(m) => m,
262 Err(e) => {
263 let _ = samr.close_handle(&handle).await;
264 out.push(AliasFinding {
265 group_sid, group_name: alias_name, members: Vec::new(),
266 collected: false, failure: Some(e.to_string()),
267 });
268 continue;
269 }
270 };
271
272 let kept: Vec<String> = members
275 .iter()
276 .filter(|m| match &machine_sid {
277 Some(mach) => !is_under(m, mach),
278 None => true,
279 })
280 .map(sid_to_string)
281 .collect();
282
283 out.push(AliasFinding { group_sid, group_name: alias_name, members: kept, collected: true, failure: None });
284 let _ = samr.close_handle(&handle).await;
285 }
286
287 let _ = samr.close_handle(&builtin).await;
288 let _ = samr.close_handle(&server).await;
289 (out, errors, is_dc)
290}
291
292fn apply_findings(
297 computers: &mut [Computer],
298 hf: &HostFindings,
299 sid_type: &std::collections::HashMap<String, String>,
300 domain: &str,
301) -> usize {
302 let computer = match computers
303 .iter_mut()
304 .find(|c| c.object_identifier() == &hf.computer_sid)
305 {
306 Some(c) => c,
307 None => {
308 warn!("[localgroups] no computer object for SID {}", hf.computer_sid);
309 return 0;
310 }
311 };
312
313 let mut count = 0usize;
314 let groups = computer.local_groups_mut();
315 for a in &hf.aliases {
316 let mut lg = LocalGroup::new();
317 *lg.object_identifier_mut() = a.group_sid.clone();
318 *lg.name_mut() = group_display_name(a.group_name, &hf.host, hf.is_dc);
319 *lg.collected_mut() = a.collected;
320 *lg.failure_reason_mut() = a.failure.clone();
321 for sid in &a.members {
322 let mut m = Member::new();
323 *m.object_identifier_mut() = sid.clone();
324 *m.object_type_mut() = sid_type
325 .get(sid)
326 .cloned()
327 .unwrap_or_else(|| "Base".to_string());
328 lg.results_mut().push(m);
329 count += 1;
330 }
331 groups.push(lg);
332 }
333 computer.users_rights_mut().push(
334 synth_rdp_userright(&hf.computer_sid, domain, hf.is_dc)
335 );
336 count
337}
338
339fn synth_rdp_userright(
344 computer_sid: &str,
345 domain: &str,
346 is_dc: bool,
347) -> UserRight {
348 let (ty, id544, id555) = if is_dc {
349 (
350 "Group",
351 format!("{}-S-1-5-32-544", domain.to_uppercase()),
352 format!("{}-S-1-5-32-555", domain.to_uppercase()),
353 )
354 } else {
355 (
356 "ADLocalGroup",
357 format!("{computer_sid}-544"),
358 format!("{computer_sid}-555"),
359 )
360 };
361
362 let mut right = UserRight::new();
363 *right.privilege_mut() = "SeRemoteInteractiveLogonRight".to_string();
364 *right.collected_mut() = true;
365 for id in [id544, id555] {
366 let mut m = Member::new();
367 *m.object_identifier_mut() = id;
368 *m.object_type_mut() = ty.to_string();
369 right.results_mut().push(m);
370 }
371 right
372}
373
374async fn is_reachable(host: &str, port_timeout_ms: u64) -> bool {
377 matches!(
378 timeout(
379 Duration::from_millis(port_timeout_ms),
380 TcpStream::connect(format!("{host}:445"))
381 )
382 .await,
383 Ok(Ok(_))
384 )
385}
386
387fn is_active(c: &Computer, expiry_days: i64) -> bool {
388 if !*c.properties().enabled() {
389 return false;
390 }
391 let pls = c.properties().pwdlastset();
392 if pls <= 0 {
393 return false;
394 }
395 let now = chrono::Utc::now().timestamp();
396 now - pls < expiry_days * 86_400
397}
398
399fn parse_hash(h: Option<&str>) -> Option<[u8; 16]> {
400 let raw = h?.trim();
401 let nt = raw.rsplit(':').next().unwrap_or(raw).trim();
402 if nt.len() != 32 || !nt.bytes().all(|b| b.is_ascii_hexdigit()) {
403 return None;
404 }
405 let mut out = [0u8; 16];
406 for (i, byte) in out.iter_mut().enumerate() {
407 *byte = u8::from_str_radix(&nt[i * 2..i * 2 + 2], 16).ok()?;
408 }
409 Some(out)
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn apply_findings_skips_unknown_host() {
418 let mut none: Vec<Computer> = vec![];
419 let hf = HostFindings {
420 computer_sid: "S-1-5-21-1-2-3-1001".to_string(),
421 host: "FS01.ESSOS.LOCAL".to_string(),
422 is_dc: false,
423 aliases: vec![AliasFinding {
424 group_sid: "S-1-5-21-1-2-3-544".to_string(),
425 group_name: "Administrators",
426 members: vec!["S-1-5-21-1-2-3-512".to_string()],
427 collected: true,
428 failure: None,
429 }],
430 errors: vec![],
431 };
432 let domain = "ESSOS.LOCAL";
433 assert_eq!(apply_findings(&mut none, &hf, &std::collections::HashMap::new(), domain), 0);
434 }
435
436 #[test]
437 fn parse_hash_forms() {
438 let want = [0xaa, 0xd3, 0xb4, 0x35, 0xb5, 0x14, 0x04, 0xee,
439 0xaa, 0xd3, 0xb4, 0x35, 0xb5, 0x14, 0x04, 0xee];
440 assert_eq!(parse_hash(Some("aad3b435b51404eeaad3b435b51404ee")), Some(want));
441 assert_eq!(parse_hash(Some(":aad3b435b51404eeaad3b435b51404ee")), Some(want));
442 assert_eq!(parse_hash(Some("bad")), None);
443 assert_eq!(parse_hash(None), None);
444 }
445}