Skip to main content

rusthound_ce/modules/gpo/
local_group.rs

1//! GPO local group and privilege resolution.
2//!
3//! Turns the SYSVOL directives collected per GPO into the two BloodHound
4//! outputs:
5//!
6//!   [Group Membership] / Groups.xml -> GPOChanges on OU and Domain objects
7//!       Administrators (544)        -> LocalAdmins        -> AdminTo
8//!       Remote Desktop Users (555)  -> RemoteDesktopUsers -> CanRDP
9//!       Distributed COM Users (562) -> DcomUsers          -> ExecuteDCOM
10//!       Remote Management Users(580)-> PSRemoteUsers      -> CanPSRemote
11//!
12//!   [Privilege Rights] (SeXxx)      -> UserRights on the affected Computers
13//!
14//! Mirrors SharpHound GPOLocalGroupProcessor for the merge semantics. It reuses
15//! the checker's work: AffectedComputers is already set (add_affected_computers*)
16//! and links already carry the GPO SID (replace_guid_gplink), so this layer only
17//! fills the four local-group vecs and the computers' UserRights.
18
19use std::collections::HashMap;
20
21use crate::objects::common::{GPOChange, LdapObject, Link, Member, UserRight};
22use crate::objects::computer::Computer;
23use crate::objects::domain::Domain;
24use crate::objects::group::Group;
25use crate::objects::ou::Ou;
26use crate::objects::user::User;
27
28use super::sysvol::SysvolGpo;
29use super::types::{
30    GppGroupAction, GppMemberAction, RestrictedGroupDirective,
31    RestrictedGroupOperation,
32};
33
34// ---- group identity ----------------------------------------------------------
35
36/// One of the four built-in local groups tracked for lateral movement.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum TargetGroup {
39    LocalAdmins,
40    RemoteDesktopUsers,
41    DcomUsers,
42    PsRemote,
43}
44
45impl TargetGroup {
46    fn from_rid(rid: u32) -> Option<Self> {
47        match rid {
48            544 => Some(Self::LocalAdmins),
49            555 => Some(Self::RemoteDesktopUsers),
50            562 => Some(Self::DcomUsers),
51            580 => Some(Self::PsRemote),
52            _ => None,
53        }
54    }
55
56    fn from_group_name(name: &str) -> Option<Self> {
57        match name.trim().to_ascii_lowercase().as_str() {
58            "administrators" => Some(Self::LocalAdmins),
59            "remote desktop users" => Some(Self::RemoteDesktopUsers),
60            "distributed com users" => Some(Self::DcomUsers),
61            "remote management users" => Some(Self::PsRemote),
62            _ => None,
63        }
64    }
65}
66
67/// A resolved principal: SID plus BloodHound object type.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct TypedPrincipal {
70    pub sid: String,
71    pub object_type: String,
72}
73
74// ---- resolution --------------------------------------------------------------
75
76/// Name/SID -> (SID, type) resolution, backed by the collected LDAP objects.
77pub trait Resolver {
78    fn resolve_name(&self, name: &str) -> Option<(String, String)>;
79    fn type_of_sid(&self, sid: &str) -> Option<String>;
80}
81
82/// Resolver built from the users / groups / computers collected over LDAP.
83pub struct ObjectResolver {
84    by_name: HashMap<String, (String, String)>, // UPPER bare SAM -> (sid, type)
85    by_sid: HashMap<String, String>,            // UPPER sid -> type
86}
87
88fn index_object(
89    sid: &str,
90    display: &str,
91    ty: &str,
92    by_name: &mut HashMap<String, (String, String)>,
93    by_sid: &mut HashMap<String, String>,
94) {
95    if sid.is_empty() {
96        return;
97    }
98    by_sid.insert(sid.to_uppercase(), ty.to_string());
99    let bare = display
100        .rsplit(['\\', '/'])
101        .next()
102        .unwrap_or(display)
103        .split('@')
104        .next()
105        .unwrap_or(display)
106        .trim()
107        .to_uppercase();
108    if !bare.is_empty() {
109        by_name
110            .entry(bare)
111            .or_insert_with(|| (sid.to_string(), ty.to_string()));
112    }
113}
114
115impl ObjectResolver {
116    pub fn build(users: &[User], groups: &[Group], computers: &[Computer]) -> Self {
117        let mut by_name = HashMap::new();
118        let mut by_sid = HashMap::new();
119
120        for u in users {
121            index_object(u.object_identifier(), u.properties().name(), "User", &mut by_name, &mut by_sid);
122        }
123        for g in groups {
124            index_object(g.object_identifier(), g.properties().name(), "Group", &mut by_name, &mut by_sid);
125        }
126        for c in computers {
127            let sid = c.object_identifier();
128            let name = c.properties().name();
129            index_object(sid, name, "Computer", &mut by_name, &mut by_sid);
130            let short = name.split('.').next().unwrap_or(name).to_uppercase();
131            if !short.is_empty() {
132                by_name
133                    .entry(format!("{short}$"))
134                    .or_insert_with(|| (sid.to_string(), "Computer".to_string()));
135            }
136        }
137
138        ObjectResolver { by_name, by_sid }
139    }
140}
141
142impl Resolver for ObjectResolver {
143    fn resolve_name(&self, name: &str) -> Option<(String, String)> {
144        self.by_name.get(&name.to_uppercase()).cloned()
145    }
146    fn type_of_sid(&self, sid: &str) -> Option<String> {
147        self.by_sid.get(&sid.to_uppercase()).cloned()
148    }
149}
150
151/// Extract the RID from an "S-1-5-32-XXX" built-in SID (any leading '*' ok).
152fn builtin_rid(s: &str) -> Option<u32> {
153    let up = s.trim().trim_start_matches('*').to_uppercase();
154    let rest = up.strip_prefix("S-1-5-32-")?;
155    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
156    digits.parse().ok()
157}
158
159/// Resolve a raw principal string (SID or name) to a typed principal.
160fn resolve_principal(raw: &str, resolver: &impl Resolver) -> Option<TypedPrincipal> {
161    let p = raw.trim().trim_start_matches('*').trim();
162    if p.is_empty() {
163        return None;
164    }
165    if p.len() >= 4 && p[..4].eq_ignore_ascii_case("S-1-") {
166        let sid = p.to_uppercase();
167        let object_type = resolver.type_of_sid(&sid).unwrap_or_else(|| "Base".to_string());
168        return Some(TypedPrincipal { sid, object_type });
169    }
170    let bare = p.rsplit(['\\', '/']).next().unwrap_or(p);
171    resolver
172        .resolve_name(bare)
173        .map(|(sid, object_type)| TypedPrincipal { sid, object_type })
174}
175
176// ---- action model (internal), mirrors SharpHound GroupAction -----------------
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179enum Op {
180    Add,
181    Delete,
182    DeleteUsers,
183    DeleteGroups,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187enum Kind {
188    RestrictedMember,
189    RestrictedMemberOf,
190    LocalGroup,
191}
192
193#[derive(Debug, Clone)]
194struct Action {
195    group: TargetGroup,
196    kind: Kind,
197    op: Op,
198    principal: Option<TypedPrincipal>,
199}
200
201/// Build the ordered action list for a single GPO (GPP first, then GptTmpl).
202fn actions_for_gpo(gpo: &SysvolGpo, resolver: &impl Resolver) -> Vec<Action> {
203    let mut out = Vec::new();
204
205    for grp in &gpo.gpp_local_groups {
206        if grp.action() != GppGroupAction::Update {
207            continue;
208        }
209        let target = grp
210            .sid()
211            .and_then(builtin_rid)
212            .and_then(TargetGroup::from_rid)
213            .or_else(|| grp.name().and_then(TargetGroup::from_group_name));
214        let Some(group) = target else { continue };
215
216        if grp.delete_all_users() {
217            out.push(Action { group, kind: Kind::LocalGroup, op: Op::DeleteUsers, principal: None });
218        }
219        if grp.delete_all_groups() {
220            out.push(Action { group, kind: Kind::LocalGroup, op: Op::DeleteGroups, principal: None });
221        }
222        for m in grp.members() {
223            let op = match m.action() {
224                GppMemberAction::Add => Op::Add,
225                GppMemberAction::Remove => Op::Delete,
226            };
227            let principal = m
228                .sid()
229                .and_then(|s| resolve_principal(s, resolver))
230                .or_else(|| m.name().and_then(|n| resolve_principal(n, resolver)));
231            if let Some(p) = principal {
232                out.push(Action { group, kind: Kind::LocalGroup, op, principal: Some(p) });
233            }
234        }
235    }
236
237    for dir in &gpo.restricted_groups {
238        push_restricted(dir, resolver, &mut out);
239    }
240
241    out
242}
243
244fn push_restricted(dir: &RestrictedGroupDirective, resolver: &impl Resolver, out: &mut Vec<Action>) {
245    match dir.operation() {
246        RestrictedGroupOperation::ReplaceMembers => {
247            let Some(group) = builtin_rid(dir.target()).and_then(TargetGroup::from_rid) else {
248                return;
249            };
250            for raw in dir.principals() {
251                if let Some(p) = resolve_principal(raw, resolver) {
252                    out.push(Action { group, kind: Kind::RestrictedMember, op: Op::Add, principal: Some(p) });
253                }
254            }
255        }
256        RestrictedGroupOperation::AddToParentGroups => {
257            let Some(member) = resolve_principal(dir.target(), resolver) else {
258                return;
259            };
260            for raw in dir.principals() {
261                if let Some(group) = builtin_rid(raw).and_then(TargetGroup::from_rid) {
262                    out.push(Action {
263                        group,
264                        kind: Kind::RestrictedMemberOf,
265                        op: Op::Add,
266                        principal: Some(member.clone()),
267                    });
268                }
269            }
270        }
271    }
272}
273
274// ---- merge -------------------------------------------------------------------
275
276/// The four resulting member sets after merging all linked GPOs.
277#[derive(Debug, Default)]
278pub struct MergedGroups {
279    pub local_admins: Vec<TypedPrincipal>,
280    pub remote_desktop_users: Vec<TypedPrincipal>,
281    pub dcom_users: Vec<TypedPrincipal>,
282    pub psremote_users: Vec<TypedPrincipal>,
283}
284
285fn dedup_by_sid(v: &mut Vec<TypedPrincipal>) {
286    let mut seen = std::collections::HashSet::new();
287    v.retain(|p| seen.insert(p.sid.clone()));
288}
289
290/// final = RestrictedMemberOf + (RestrictedMember if any, else LocalGroups), distinct.
291fn merge(actions: &[Action]) -> MergedGroups {
292    let mut merged = MergedGroups::default();
293    for group in [
294        TargetGroup::LocalAdmins,
295        TargetGroup::RemoteDesktopUsers,
296        TargetGroup::DcomUsers,
297        TargetGroup::PsRemote,
298    ] {
299        let mut restricted_member: Vec<TypedPrincipal> = Vec::new();
300        let mut restricted_memberof: Vec<TypedPrincipal> = Vec::new();
301        let mut local_groups: Vec<TypedPrincipal> = Vec::new();
302
303        for a in actions.iter().filter(|a| a.group == group) {
304            match (a.kind, a.op) {
305                (Kind::RestrictedMember, _) => {
306                    if let Some(p) = &a.principal { restricted_member.push(p.clone()); }
307                }
308                (Kind::RestrictedMemberOf, _) => {
309                    if let Some(p) = &a.principal { restricted_memberof.push(p.clone()); }
310                }
311                (Kind::LocalGroup, Op::Add) => {
312                    if let Some(p) = &a.principal { local_groups.push(p.clone()); }
313                }
314                (Kind::LocalGroup, Op::Delete) => {
315                    if let Some(p) = &a.principal { local_groups.retain(|x| x.sid != p.sid); }
316                }
317                (Kind::LocalGroup, Op::DeleteUsers) => {
318                    local_groups.retain(|x| x.object_type != "User");
319                }
320                (Kind::LocalGroup, Op::DeleteGroups) => {
321                    local_groups.retain(|x| x.object_type != "Group");
322                }
323            }
324        }
325
326        let mut final_set = restricted_memberof;
327        if restricted_member.is_empty() {
328            final_set.extend(local_groups);
329        } else {
330            final_set.extend(restricted_member);
331        }
332        dedup_by_sid(&mut final_set);
333
334        match group {
335            TargetGroup::LocalAdmins => merged.local_admins = final_set,
336            TargetGroup::RemoteDesktopUsers => merged.remote_desktop_users = final_set,
337            TargetGroup::DcomUsers => merged.dcom_users = final_set,
338            TargetGroup::PsRemote => merged.psremote_users = final_set,
339        }
340    }
341    merged
342}
343
344/// Compute the merged local groups for a container from its linked GPOs
345/// (already ordered: unenforced first, then enforced).
346pub fn compute_merged(ordered_gpos: &[&SysvolGpo], resolver: &impl Resolver) -> MergedGroups {
347    let mut actions = Vec::new();
348    for gpo in ordered_gpos {
349        actions.extend(actions_for_gpo(gpo, resolver));
350    }
351    merge(&actions)
352}
353
354/// Merge privilege assignments across linked GPOs (union per privilege, dedup by SID).
355pub fn resolve_privileges(
356    ordered_gpos: &[&SysvolGpo],
357    resolver: &impl Resolver,
358) -> Vec<(String, Vec<TypedPrincipal>)> {
359    let mut order: Vec<String> = Vec::new();
360    let mut map: HashMap<String, Vec<TypedPrincipal>> = HashMap::new();
361
362    for gpo in ordered_gpos {
363        for pa in &gpo.privileges {
364            let key = pa.privilege().to_string();
365            if !map.contains_key(&key) {
366                order.push(key.clone());
367                map.insert(key.clone(), Vec::new());
368            }
369            let entry = map.get_mut(&key).unwrap();
370            for raw in pa.principals() {
371                if let Some(p) = resolve_principal(raw, resolver) {
372                    if !entry.iter().any(|x| x.sid == p.sid) {
373                        entry.push(p);
374                    }
375                }
376            }
377        }
378    }
379    order.into_iter().map(|k| { let v = map.remove(&k).unwrap(); (k, v) }).collect()
380}
381
382// ---- output helpers ----------------------------------------------------------
383
384fn to_member(p: &TypedPrincipal) -> Member {
385    let mut m = Member::new();
386    *m.object_identifier_mut() = p.sid.clone();
387    *m.object_type_mut() = p.object_type.clone();
388    m
389}
390
391fn to_members(list: &[TypedPrincipal]) -> Vec<Member> {
392    list.iter().map(to_member).collect()
393}
394
395// ---- driver (reuses the checker: no affected-computers recompute, no GUID
396//      extraction; links already carry the GPO SID) -----------------------------
397
398/// Fill GPOChanges (local groups) on OU/Domain and UserRights (privileges) on
399/// the affected computers already resolved by the checker.
400///
401/// `dn_sid` (ad.mappings.dn_sid) bridges each GPO SYSVOL folder GUID to its SID,
402/// so links (whose GUID was replaced with the GPO SID by replace_guid_gplink)
403/// can be matched back to the collected directives.
404pub fn apply_gpo(
405    ous: &mut [Ou],
406    domains: &mut [Domain],
407    users: &[User],
408    groups: &[Group],
409    computers: &mut Vec<Computer>,
410    sysvol: &[SysvolGpo],
411    dn_sid: &HashMap<String, String>,
412) {
413    let resolver = ObjectResolver::build(users, groups, computers);
414
415    // folder GUID -> GPO SID, the same way replace_guid_gplink matched links.
416    let mut by_sid: HashMap<String, &SysvolGpo> = HashMap::new();
417    for g in sysvol {
418        let needle = g.guid.to_uppercase();
419        if let Some((_, sid)) = dn_sid.iter().find(|(dn, _)| dn.to_uppercase().contains(&needle)) {
420            by_sid.insert(sid.to_uppercase(), g);
421        }
422    }
423
424    log::debug!("[gpo] {} SYSVOL GPO(s) bridged to a SID via dn_sid", by_sid.len());
425    for (sid, g) in &by_sid {
426        log::debug!("[gpo]   {} -> {}", g.guid, sid);
427    }
428
429    // computer SID -> (privilege -> members), accumulated across containers.
430    let mut priv_acc: HashMap<String, HashMap<String, Vec<TypedPrincipal>>> = HashMap::new();
431
432    for ou in ous.iter_mut() {
433        let label = ou.properties().distinguishedname().clone();
434        let links: Vec<Link> = ou.get_links().to_vec();
435        fill_container("OU", &label, &links, ou.gpo_changes_mut(), &by_sid, &resolver, &mut priv_acc);
436    }
437    for dom in domains.iter_mut() {
438        let label = dom.properties().distinguishedname().clone();
439        let links: Vec<Link> = dom.get_links().to_vec();
440        fill_container("Domain", &label, &links, dom.gpo_changes_mut(), &by_sid, &resolver, &mut priv_acc);
441    }
442
443    for c in computers.iter_mut() {
444        let Some(per) = priv_acc.get(c.object_identifier()) else { continue };
445        let ur = c.users_rights_mut();
446        for (privilege, members) in per {
447            let mut right = UserRight::new();
448            *right.privilege_mut() = privilege.clone();
449            *right.results_mut() = to_members(members);
450            *right.collected_mut() = true;
451            ur.push(right);
452        }
453        log::trace!("[gpo] Computer {}: {} UserRight(s) set", c.object_identifier(), per.len());
454    }
455    
456}
457
458fn fill_container(
459    kind: &str,
460    label: &str,
461    links_src: &[Link],
462    changes: &mut GPOChange,
463    by_sid: &HashMap<String, &SysvolGpo>,
464    resolver: &impl Resolver,
465    priv_acc: &mut HashMap<String, HashMap<String, Vec<TypedPrincipal>>>,
466) {
467    // Links now hold (is_enforced, GPO SID). Order unenforced first, then enforced.
468    let mut links: Vec<(bool, String)> = links_src
469        .iter()
470        .map(|l| (*l.is_enforced(), l.guid().clone()))
471        .collect();
472    if links.is_empty() {
473        return;
474    }
475    links.sort_by_key(|(enforced, _)| *enforced);
476
477    let ordered: Vec<&SysvolGpo> = links
478        .iter()
479        .filter_map(|(_, sid)| by_sid.get(&sid.to_uppercase()).copied())
480        .collect();
481    if ordered.is_empty() {
482        return;
483    }
484
485    // Local groups -> this container's GPOChanges (AffectedComputers preserved).
486    let merged = compute_merged(&ordered, resolver);
487    *changes.local_admins_mut() = to_members(&merged.local_admins);
488    *changes.remote_desktop_users_mut() = to_members(&merged.remote_desktop_users);
489    *changes.dcom_users_mut() = to_members(&merged.dcom_users);
490    *changes.psremote_users_mut() = to_members(&merged.psremote_users);
491
492    log::trace!(
493        "[gpo] {kind} {label}: GPOChanges set from {} linked GPO(s) -> {} local admin(s), {} RDP, {} DCOM, {} PSRemote",
494        ordered.len(),
495        merged.local_admins.len(),
496        merged.remote_desktop_users.len(),
497        merged.dcom_users.len(),
498        merged.psremote_users.len(),
499    );
500
501
502    // Privileges -> accumulate onto the computers the checker already resolved.
503    let privs = resolve_privileges(&ordered, resolver);
504    if privs.is_empty() {
505        return;
506    }
507    log::debug!(
508        "[gpo] {kind} {label}: {} privilege(s) resolved, {} affected computer(s)",
509        privs.len(), changes.affected_computers().len()
510    );
511    for m in changes.affected_computers() {
512        let per = priv_acc.entry(m.object_identifier().clone()).or_default();
513        for (privilege, members) in &privs {
514            let e = per.entry(privilege.clone()).or_default();
515            for p in members {
516                if !e.iter().any(|x| x.sid == p.sid) {
517                    e.push(p.clone());
518                }
519            }
520        }
521    }
522}
523
524// ---- tests -------------------------------------------------------------------
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    struct FakeResolver;
531    impl Resolver for FakeResolver {
532        fn resolve_name(&self, name: &str) -> Option<(String, String)> {
533            match name.to_uppercase().as_str() {
534                "ALICE" => Some(("S-1-5-21-1-1-1105".into(), "User".into())),
535                "HELPDESK" => Some(("S-1-5-21-1-1-1200".into(), "Group".into())),
536                _ => None,
537            }
538        }
539        fn type_of_sid(&self, sid: &str) -> Option<String> {
540            match sid {
541                "S-1-5-21-1-1-512" => Some("Group".into()),
542                _ => None,
543            }
544        }
545    }
546
547    fn gpo_with_restricted(target: &str, op: RestrictedGroupOperation, principals: &[&str]) -> SysvolGpo {
548        let mut g = SysvolGpo::default();
549        g.guid = "{G}".into();
550        g.restricted_groups = vec![RestrictedGroupDirective::new(
551            target,
552            op,
553            principals.iter().map(|s| s.to_string()).collect(),
554        )];
555        g
556    }
557
558    #[test]
559    fn replace_members_fills_local_admins() {
560        let g = gpo_with_restricted(
561            "S-1-5-32-544",
562            RestrictedGroupOperation::ReplaceMembers,
563            &["*S-1-5-21-1-1-512", "*ALICE"],
564        );
565        let merged = compute_merged(&[&g], &FakeResolver);
566        let sids: Vec<&str> = merged.local_admins.iter().map(|p| p.sid.as_str()).collect();
567        assert_eq!(sids, vec!["S-1-5-21-1-1-512", "S-1-5-21-1-1-1105"]);
568        assert_eq!(merged.local_admins[0].object_type, "Group");
569        assert_eq!(merged.local_admins[1].object_type, "User");
570        assert!(merged.remote_desktop_users.is_empty());
571    }
572
573    #[test]
574    fn memberof_targets_the_parent_group_rid() {
575        let g = gpo_with_restricted(
576            "HELPDESK",
577            RestrictedGroupOperation::AddToParentGroups,
578            &["S-1-5-32-555"],
579        );
580        let merged = compute_merged(&[&g], &FakeResolver);
581        assert_eq!(merged.remote_desktop_users.len(), 1);
582        assert_eq!(merged.remote_desktop_users[0].sid, "S-1-5-21-1-1-1200");
583        assert!(merged.local_admins.is_empty());
584    }
585
586    #[test]
587    fn restricted_member_overrides_local_groups() {
588        let g = gpo_with_restricted(
589            "S-1-5-32-544",
590            RestrictedGroupOperation::ReplaceMembers,
591            &["*ALICE"],
592        );
593        let merged = compute_merged(&[&g], &FakeResolver);
594        assert_eq!(merged.local_admins.len(), 1);
595        assert_eq!(merged.local_admins[0].sid, "S-1-5-21-1-1-1105");
596    }
597
598    // #[test]
599    // fn privileges_resolve_and_union_by_sid() {
600    //     let mut g = SysvolGpo::default();
601    //     g.guid = "{G}".into();
602    //     g.privileges = vec![PrivilegeAssignment::new(
603    //         "SeRemoteInteractiveLogonRight",
604    //         vec!["*S-1-5-21-1-1-512".into(), "*ALICE".into(), "*S-1-5-21-1-1-512".into()],
605    //     )];
606    //     let out = resolve_privileges(&[&g], &FakeResolver);
607    //     assert_eq!(out.len(), 1);
608    //     assert_eq!(out[0].0, "SeRemoteInteractiveLogonRight");
609    //     let sids: Vec<&str> = out[0].1.iter().map(|p| p.sid.as_str()).collect();
610    //     assert_eq!(sids, vec!["S-1-5-21-1-1-512", "S-1-5-21-1-1-1105"]);
611    // }
612}