Skip to main content

rusthound_ce/modules/localgroup/
mod.rs

1//! Local-group collection module for RustHound-CE  (issue #69 - LocalGroups)
2//! <https://bloodhound.specterops.io/resources/edges/admin-to>
3//! <https://github.com/g0h4n/LocalGroups-rs>
4//!
5//! Runs AFTER the LDAP phase, from `modules::run_modules`, and only when the
6//! collection method contacts machines (NOT DCOnly / LdapOnly).
7//!
8//!   SAMR / SamrOpenAlias + SamrGetMembersInAlias -> Computer.LocalGroups
9//!
10//!   RID 544 Administrators          -> AdminTo
11//!   RID 555 Remote Desktop Users    -> CanRDP
12//!   RID 562 Distributed COM Users   -> ExecuteDCOM
13//!   RID 580 Remote Management Users -> CanPSRemote
14//!
15//! Same SharpHound-style behaviour as the sessions module: 445 pre-check,
16//! active-computer filter, 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.
19
20pub 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
49/// Entry point, called from `modules::run_modules`.
50pub async fn run(
51    args: &Options,
52    _users: &[User], // signature parity with sessions; SAMR returns SIDs
53    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// Per-host enumeration
117
118#[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
191/// The SAMR alias walk over an open IPC$ session.
192async 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        // Local accounts have no BloodHound node, so keeping them would create a
273        // dangling edge; skipped on a DC where machine_sid is None.
274        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
292/// Write each host's aliases onto the matching Computer. Returns edge count.
293/// Member ObjectType comes from `sid_type` (built during the LDAP phase, the
294/// same source SharpHound resolves against); "Base" when the SID has no AD
295/// object, typically a purely local principal.
296fn 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
339// SharpHound synthesizes SeRemoteInteractiveLogonRight = {Administrators, Remote
340// Desktop Users} on every machine, because both hold that right by default on
341// Windows. No RPC call: the two group ids are the ones we already build for
342// LocalGroups. This is what drives CanRDP, on a DC especially.
343fn 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
374// Helpers, identical to the sessions module
375
376async 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}