| | 1 | | using System; |
| | 2 | | using System.Collections.Concurrent; |
| | 3 | | using System.Collections.Generic; |
| | 4 | | using System.DirectoryServices.Protocols; |
| | 5 | | using System.IO; |
| | 6 | | using System.Linq; |
| | 7 | | using System.Text.RegularExpressions; |
| | 8 | | using System.Threading.Tasks; |
| | 9 | | using System.Xml.XPath; |
| | 10 | | using Microsoft.Extensions.Logging; |
| | 11 | | using SharpHoundCommonLib.Enums; |
| | 12 | | using SharpHoundCommonLib.LDAPQueries; |
| | 13 | | using SharpHoundCommonLib.OutputTypes; |
| | 14 | |
|
| | 15 | | namespace SharpHoundCommonLib.Processors |
| | 16 | | { |
| | 17 | | public class GPOLocalGroupProcessor |
| | 18 | | { |
| 1 | 19 | | private static readonly Regex KeyRegex = new(@"(.+?)\s*=(.*)", RegexOptions.Compiled); |
| | 20 | |
|
| 1 | 21 | | private static readonly Regex MemberRegex = |
| 1 | 22 | | new(@"\[Group Membership\](.*)(?:\[|$)", RegexOptions.Compiled | RegexOptions.Singleline); |
| | 23 | |
|
| 1 | 24 | | private static readonly Regex MemberLeftRegex = |
| 1 | 25 | | new(@"(.*(?:S-1-5-32-544|S-1-5-32-555|S-1-5-32-562|S-1-5-32-580)__Members)", RegexOptions.Compiled | |
| 1 | 26 | | RegexOptions.IgnoreCase); |
| | 27 | |
|
| 1 | 28 | | private static readonly Regex MemberRightRegex = |
| 1 | 29 | | new(@"(S-1-5-32-544|S-1-5-32-555|S-1-5-32-562|S-1-5-32-580)", RegexOptions.Compiled | |
| 1 | 30 | | RegexOptions.IgnoreCase); |
| | 31 | |
|
| 1 | 32 | | private static readonly Regex ExtractRid = |
| 1 | 33 | | new(@"S-1-5-32-([0-9]{3})", RegexOptions.Compiled | RegexOptions.IgnoreCase); |
| | 34 | |
|
| 1 | 35 | | private static readonly ConcurrentDictionary<string, List<GroupAction>> GpoActionCache = new(); |
| | 36 | |
|
| 1 | 37 | | private static readonly Dictionary<string, LocalGroupRids> ValidGroupNames = |
| 1 | 38 | | new(StringComparer.OrdinalIgnoreCase) |
| 1 | 39 | | { |
| 1 | 40 | | {"Administrators", LocalGroupRids.Administrators}, |
| 1 | 41 | | {"Remote Desktop Users", LocalGroupRids.RemoteDesktopUsers}, |
| 1 | 42 | | {"Remote Management Users", LocalGroupRids.PSRemote}, |
| 1 | 43 | | {"Distributed COM Users", LocalGroupRids.DcomUsers} |
| 1 | 44 | | }; |
| | 45 | |
|
| | 46 | | private readonly ILogger _log; |
| | 47 | |
|
| | 48 | | private readonly ILDAPUtils _utils; |
| | 49 | |
|
| 11 | 50 | | public GPOLocalGroupProcessor(ILDAPUtils utils, ILogger log = null) |
| 11 | 51 | | { |
| 11 | 52 | | _utils = utils; |
| 11 | 53 | | _log = log ?? Logging.LogProvider.CreateLogger("GPOLocalGroupProc"); |
| 11 | 54 | | } |
| | 55 | |
|
| | 56 | | public Task<ResultingGPOChanges> ReadGPOLocalGroups(ISearchResultEntry entry) |
| 0 | 57 | | { |
| 0 | 58 | | var links = entry.GetProperty(LDAPProperties.GPLink); |
| 0 | 59 | | var dn = entry.DistinguishedName; |
| 0 | 60 | | return ReadGPOLocalGroups(links, dn); |
| 0 | 61 | | } |
| | 62 | |
|
| | 63 | | public async Task<ResultingGPOChanges> ReadGPOLocalGroups(string gpLink, string distinguishedName) |
| 4 | 64 | | { |
| 4 | 65 | | var ret = new ResultingGPOChanges(); |
| | 66 | | //If the gplink property is null, we don't need to process anything |
| 4 | 67 | | if (gpLink == null) |
| 1 | 68 | | return ret; |
| | 69 | |
|
| | 70 | | // First lets check if this OU actually has computers that it contains. If not, then we'll ignore it. |
| | 71 | | // Its cheaper to fetch the affected computers from LDAP first and then process the GPLinks |
| 3 | 72 | | var options = new LDAPQueryOptions |
| 3 | 73 | | { |
| 3 | 74 | | Filter = new LDAPFilter().AddComputersNoMSAs().GetFilter(), |
| 3 | 75 | | Scope = SearchScope.Subtree, |
| 3 | 76 | | Properties = CommonProperties.ObjectSID, |
| 3 | 77 | | AdsPath = distinguishedName |
| 3 | 78 | | }; |
| | 79 | |
|
| 3 | 80 | | var affectedComputers = _utils.QueryLDAP(options) |
| 2 | 81 | | .Select(x => x.GetSid()) |
| 2 | 82 | | .Where(x => x != null) |
| 5 | 83 | | .Select(x => new TypedPrincipal |
| 5 | 84 | | { |
| 5 | 85 | | ObjectIdentifier = x, |
| 5 | 86 | | ObjectType = Label.Computer |
| 5 | 87 | | }).ToArray(); |
| | 88 | |
|
| | 89 | | //If there's no computers then we don't care about this OU |
| 3 | 90 | | if (affectedComputers.Length == 0) |
| 1 | 91 | | return ret; |
| | 92 | |
|
| 2 | 93 | | var enforced = new List<string>(); |
| 2 | 94 | | var unenforced = new List<string>(); |
| | 95 | |
|
| | 96 | | // Split our link property up and remove disabled links |
| 14 | 97 | | foreach (var link in Helpers.SplitGPLinkProperty(gpLink)) |
| 4 | 98 | | switch (link.Status) |
| | 99 | | { |
| | 100 | | case "0": |
| 2 | 101 | | unenforced.Add(link.DistinguishedName); |
| 2 | 102 | | break; |
| | 103 | | case "2": |
| 2 | 104 | | enforced.Add(link.DistinguishedName); |
| 2 | 105 | | break; |
| | 106 | | } |
| | 107 | |
|
| | 108 | | //Set up our links in the correct order. |
| | 109 | | // Enforced links override unenforced, and also respect the order in which they are placed in the GPLink pro |
| 2 | 110 | | var orderedLinks = new List<string>(); |
| 2 | 111 | | orderedLinks.AddRange(unenforced); |
| 2 | 112 | | orderedLinks.AddRange(enforced); |
| | 113 | |
|
| 2 | 114 | | var data = new Dictionary<LocalGroupRids, GroupResults>(); |
| 36 | 115 | | foreach (var rid in Enum.GetValues(typeof(LocalGroupRids))) data[(LocalGroupRids) rid] = new GroupResults(); |
| | 116 | |
|
| 14 | 117 | | foreach (var linkDn in orderedLinks) |
| 4 | 118 | | { |
| 4 | 119 | | if (!GpoActionCache.TryGetValue(linkDn.ToLower(), out var actions)) |
| 2 | 120 | | { |
| 2 | 121 | | actions = new List<GroupAction>(); |
| | 122 | |
|
| 2 | 123 | | var gpoDomain = Helpers.DistinguishedNameToDomain(linkDn); |
| | 124 | |
|
| 2 | 125 | | var opts = new LDAPQueryOptions |
| 2 | 126 | | { |
| 2 | 127 | | Filter = new LDAPFilter().AddAllObjects().GetFilter(), |
| 2 | 128 | | Scope = SearchScope.Base, |
| 2 | 129 | | Properties = CommonProperties.GPCFileSysPath, |
| 2 | 130 | | AdsPath = linkDn |
| 2 | 131 | | }; |
| 2 | 132 | | var filePath = _utils.QueryLDAP(opts).FirstOrDefault()? |
| 2 | 133 | | .GetProperty(LDAPProperties.GPCFileSYSPath); |
| | 134 | |
|
| 2 | 135 | | if (filePath == null) |
| 2 | 136 | | { |
| 2 | 137 | | GpoActionCache.TryAdd(linkDn, actions); |
| 2 | 138 | | continue; |
| | 139 | | } |
| | 140 | |
|
| | 141 | | //Add the actions for each file. The GPO template file actions will override the XML file actions |
| 0 | 142 | | actions.AddRange(ProcessGPOXmlFile(filePath, gpoDomain).ToList()); |
| 0 | 143 | | await foreach (var item in ProcessGPOTemplateFile(filePath, gpoDomain)) actions.Add(item); |
| 0 | 144 | | } |
| | 145 | |
|
| | 146 | | //Cache the actions for this GPO for later |
| 2 | 147 | | GpoActionCache.TryAdd(linkDn.ToLower(), actions); |
| | 148 | |
|
| | 149 | | //If there are no actions, then we can move on from this GPO |
| 2 | 150 | | if (actions.Count == 0) |
| 2 | 151 | | continue; |
| | 152 | |
|
| | 153 | | //First lets process restricted members |
| 0 | 154 | | var restrictedMemberSets = actions.Where(x => x.Target == GroupActionTarget.RestrictedMember) |
| 0 | 155 | | .GroupBy(x => x.TargetRid); |
| | 156 | |
|
| 0 | 157 | | foreach (var set in restrictedMemberSets) |
| 0 | 158 | | { |
| 0 | 159 | | var results = data[set.Key]; |
| 0 | 160 | | var members = set.Select(x => x.ToTypedPrincipal()).ToList(); |
| 0 | 161 | | results.RestrictedMember = members; |
| 0 | 162 | | data[set.Key] = results; |
| 0 | 163 | | } |
| | 164 | |
|
| | 165 | | //Next add in our restricted MemberOf sets |
| 0 | 166 | | var restrictedMemberOfSets = actions.Where(x => x.Target == GroupActionTarget.RestrictedMemberOf) |
| 0 | 167 | | .GroupBy(x => x.TargetRid); |
| | 168 | |
|
| 0 | 169 | | foreach (var set in restrictedMemberOfSets) |
| 0 | 170 | | { |
| 0 | 171 | | var results = data[set.Key]; |
| 0 | 172 | | var members = set.Select(x => x.ToTypedPrincipal()).ToList(); |
| 0 | 173 | | results.RestrictedMemberOf = members; |
| 0 | 174 | | data[set.Key] = results; |
| 0 | 175 | | } |
| | 176 | |
|
| | 177 | | // Now work through the LocalGroup targets |
| 0 | 178 | | var localGroupSets = actions.Where(x => x.Target == GroupActionTarget.LocalGroup) |
| 0 | 179 | | .GroupBy(x => x.TargetRid); |
| | 180 | |
|
| 0 | 181 | | foreach (var set in localGroupSets) |
| 0 | 182 | | { |
| 0 | 183 | | var results = data[set.Key]; |
| 0 | 184 | | foreach (var temp in set) |
| 0 | 185 | | { |
| 0 | 186 | | var res = temp.ToTypedPrincipal(); |
| 0 | 187 | | var newMembers = results.LocalGroups; |
| 0 | 188 | | switch (temp.Action) |
| | 189 | | { |
| | 190 | | case GroupActionOperation.Add: |
| 0 | 191 | | newMembers.Add(res); |
| 0 | 192 | | break; |
| | 193 | | case GroupActionOperation.Delete: |
| 0 | 194 | | newMembers.RemoveAll(x => x.ObjectIdentifier == res.ObjectIdentifier); |
| 0 | 195 | | break; |
| | 196 | | case GroupActionOperation.DeleteUsers: |
| 0 | 197 | | newMembers.RemoveAll(x => x.ObjectType == Label.User); |
| 0 | 198 | | break; |
| | 199 | | case GroupActionOperation.DeleteGroups: |
| 0 | 200 | | newMembers.RemoveAll(x => x.ObjectType == Label.Group); |
| 0 | 201 | | break; |
| | 202 | | } |
| | 203 | |
|
| 0 | 204 | | data[set.Key].LocalGroups = newMembers; |
| 0 | 205 | | } |
| 0 | 206 | | } |
| 0 | 207 | | } |
| | 208 | |
|
| 2 | 209 | | ret.AffectedComputers = affectedComputers; |
| | 210 | |
|
| | 211 | | //At this point, we've resolved individual add/substract methods for each linked GPO. |
| | 212 | | //Now we need to actually squish them together into the resulting set of changes |
| 26 | 213 | | foreach (var kvp in data) |
| 10 | 214 | | { |
| 10 | 215 | | var key = kvp.Key; |
| 10 | 216 | | var val = kvp.Value; |
| 10 | 217 | | var rm = val.RestrictedMember; |
| 10 | 218 | | var rmo = val.RestrictedMemberOf; |
| 10 | 219 | | var gm = val.LocalGroups; |
| | 220 | |
|
| 10 | 221 | | var final = new List<TypedPrincipal>(); |
| | 222 | |
|
| | 223 | | // If we're setting RestrictedMembers, it overrides LocalGroups due to order of operations. Restricted M |
| 10 | 224 | | final.AddRange(rmo); |
| 10 | 225 | | final.AddRange(rm.Count > 0 ? rm : gm); |
| | 226 | |
|
| 10 | 227 | | var finalArr = final.Distinct().ToArray(); |
| | 228 | |
|
| 10 | 229 | | switch (key) |
| | 230 | | { |
| | 231 | | case LocalGroupRids.Administrators: |
| 2 | 232 | | ret.LocalAdmins = finalArr; |
| 2 | 233 | | break; |
| | 234 | | case LocalGroupRids.RemoteDesktopUsers: |
| 2 | 235 | | ret.RemoteDesktopUsers = finalArr; |
| 2 | 236 | | break; |
| | 237 | | case LocalGroupRids.DcomUsers: |
| 2 | 238 | | ret.DcomUsers = finalArr; |
| 2 | 239 | | break; |
| | 240 | | case LocalGroupRids.PSRemote: |
| 2 | 241 | | ret.PSRemoteUsers = finalArr; |
| 2 | 242 | | break; |
| | 243 | | } |
| 10 | 244 | | } |
| | 245 | |
|
| 2 | 246 | | return ret; |
| 4 | 247 | | } |
| | 248 | |
|
| | 249 | | /// <summary> |
| | 250 | | /// Parses a GPO GptTmpl.inf file and pulls group membership changes out |
| | 251 | | /// </summary> |
| | 252 | | /// <param name="basePath"></param> |
| | 253 | | /// <param name="gpoDomain"></param> |
| | 254 | | /// <returns></returns> |
| | 255 | | internal async IAsyncEnumerable<GroupAction> ProcessGPOTemplateFile(string basePath, string gpoDomain) |
| 4 | 256 | | { |
| 4 | 257 | | var templatePath = Path.Combine(basePath, "MACHINE", "Microsoft", "Windows NT", "SecEdit", "GptTmpl.inf"); |
| | 258 | |
|
| 4 | 259 | | if (!File.Exists(templatePath)) |
| 1 | 260 | | yield break; |
| | 261 | |
|
| | 262 | | FileStream fs; |
| | 263 | | try |
| 3 | 264 | | { |
| 3 | 265 | | fs = new FileStream(templatePath, FileMode.Open, FileAccess.Read); |
| 3 | 266 | | } |
| 0 | 267 | | catch |
| 0 | 268 | | { |
| 0 | 269 | | yield break; |
| | 270 | | } |
| | 271 | |
|
| 3 | 272 | | using var reader = new StreamReader(fs); |
| 3 | 273 | | var content = await reader.ReadToEndAsync(); |
| 3 | 274 | | var memberMatch = MemberRegex.Match(content); |
| | 275 | |
|
| 3 | 276 | | if (!memberMatch.Success) |
| 1 | 277 | | yield break; |
| | 278 | |
|
| | 279 | | //We've got a match! Lets figure out whats going on |
| 2 | 280 | | var memberText = memberMatch.Groups[1].Value.Trim(); |
| | 281 | | //Split our text into individual lines |
| 2 | 282 | | var memberLines = Regex.Split(memberText, @"\r\n|\r|\n"); |
| | 283 | |
|
| 18 | 284 | | foreach (var memberLine in memberLines) |
| 6 | 285 | | { |
| | 286 | | //Check if the Key regex matches (S-1-5.*_memberof=blah) |
| 6 | 287 | | var keyMatch = KeyRegex.Match(memberLine); |
| | 288 | |
|
| 6 | 289 | | if (!keyMatch.Success) |
| 0 | 290 | | continue; |
| | 291 | |
|
| 6 | 292 | | var key = keyMatch.Groups[1].Value.Trim(); |
| 6 | 293 | | var val = keyMatch.Groups[2].Value.Trim(); |
| | 294 | |
|
| 6 | 295 | | var leftMatch = MemberLeftRegex.Match(key); |
| 6 | 296 | | var rightMatches = MemberRightRegex.Matches(val); |
| | 297 | |
|
| | 298 | | //If leftmatch is a success, the members of a group are being explicitly set |
| 6 | 299 | | if (leftMatch.Success) |
| 2 | 300 | | { |
| 2 | 301 | | var extracted = ExtractRid.Match(leftMatch.Value); |
| 2 | 302 | | var rid = int.Parse(extracted.Groups[1].Value); |
| | 303 | |
|
| 2 | 304 | | if (Enum.IsDefined(typeof(LocalGroupRids), rid)) |
| | 305 | | //Loop over the members in the match, and try to convert them to SIDs |
| 10 | 306 | | foreach (var member in val.Split(',')) |
| 2 | 307 | | { |
| 2 | 308 | | var res = GetSid(member.Trim('*'), gpoDomain); |
| 2 | 309 | | if (res == null) |
| 1 | 310 | | continue; |
| 1 | 311 | | yield return new GroupAction |
| 1 | 312 | | { |
| 1 | 313 | | Target = GroupActionTarget.RestrictedMember, |
| 1 | 314 | | Action = GroupActionOperation.Add, |
| 1 | 315 | | TargetSid = res.ObjectIdentifier, |
| 1 | 316 | | TargetType = res.ObjectType, |
| 1 | 317 | | TargetRid = (LocalGroupRids) rid |
| 1 | 318 | | }; |
| 1 | 319 | | } |
| 2 | 320 | | } |
| | 321 | |
|
| | 322 | | //If right match is a success, a group has been set as a member of one of our local groups |
| 6 | 323 | | var index = key.IndexOf("MemberOf", StringComparison.CurrentCultureIgnoreCase); |
| 6 | 324 | | if (rightMatches.Count > 0 && index > 0) |
| 2 | 325 | | { |
| 2 | 326 | | var account = key.Trim('*').Substring(0, index - 3).ToUpper(); |
| | 327 | |
|
| 2 | 328 | | var res = GetSid(account, gpoDomain); |
| 2 | 329 | | if (res == null) |
| 0 | 330 | | continue; |
| | 331 | |
|
| 10 | 332 | | foreach (var match in rightMatches) |
| 2 | 333 | | { |
| 2 | 334 | | var rid = int.Parse(ExtractRid.Match(match.ToString()).Groups[1].Value); |
| 2 | 335 | | if (!Enum.IsDefined(typeof(LocalGroupRids), rid)) continue; |
| | 336 | |
|
| 2 | 337 | | var targetGroup = (LocalGroupRids) rid; |
| 2 | 338 | | yield return new GroupAction |
| 2 | 339 | | { |
| 2 | 340 | | Target = GroupActionTarget.RestrictedMemberOf, |
| 2 | 341 | | Action = GroupActionOperation.Add, |
| 2 | 342 | | TargetRid = targetGroup, |
| 2 | 343 | | TargetSid = res.ObjectIdentifier, |
| 2 | 344 | | TargetType = res.ObjectType |
| 2 | 345 | | }; |
| 2 | 346 | | } |
| 2 | 347 | | } |
| 6 | 348 | | } |
| 4 | 349 | | } |
| | 350 | |
|
| | 351 | | /// <summary> |
| | 352 | | /// Resolves a SID to its type |
| | 353 | | /// </summary> |
| | 354 | | /// <param name="account"></param> |
| | 355 | | /// <param name="domainName"></param> |
| | 356 | | /// <returns></returns> |
| | 357 | | private TypedPrincipal GetSid(string account, string domainName) |
| 4 | 358 | | { |
| 4 | 359 | | if (!account.StartsWith("S-1-", StringComparison.CurrentCulture)) |
| 2 | 360 | | { |
| | 361 | | string user; |
| | 362 | | string domain; |
| 2 | 363 | | if (account.Contains('\\')) |
| 0 | 364 | | { |
| | 365 | | //The account is in the format DOMAIN\\username |
| 0 | 366 | | var split = account.Split('\\'); |
| 0 | 367 | | domain = split[0]; |
| 0 | 368 | | user = split[1]; |
| 0 | 369 | | } |
| | 370 | | else |
| 2 | 371 | | { |
| | 372 | | //The account is just a username, so try with the current domain |
| 2 | 373 | | domain = domainName; |
| 2 | 374 | | user = account; |
| 2 | 375 | | } |
| | 376 | |
|
| 2 | 377 | | user = user.ToUpper(); |
| | 378 | |
|
| | 379 | | //Try to resolve as a user object first |
| 2 | 380 | | var res = _utils.ResolveAccountName(user, domain); |
| 2 | 381 | | if (res != null) |
| 1 | 382 | | return res; |
| | 383 | |
|
| 1 | 384 | | res = _utils.ResolveAccountName($"{user}$", domain); |
| 1 | 385 | | return res; |
| | 386 | | } |
| | 387 | |
|
| | 388 | | //The element is just a sid, so return it straight |
| 2 | 389 | | var lType = _utils.LookupSidType(account, domainName); |
| 2 | 390 | | return new TypedPrincipal |
| 2 | 391 | | { |
| 2 | 392 | | ObjectIdentifier = account, |
| 2 | 393 | | ObjectType = lType |
| 2 | 394 | | }; |
| 4 | 395 | | } |
| | 396 | |
|
| | 397 | | /// <summary> |
| | 398 | | /// Parses a GPO Groups.xml file and pulls group membership changes out |
| | 399 | | /// </summary> |
| | 400 | | /// <param name="basePath"></param> |
| | 401 | | /// <param name="gpoDomain"></param> |
| | 402 | | /// <returns>A list of GPO "Actions"</returns> |
| | 403 | | internal IEnumerable<GroupAction> ProcessGPOXmlFile(string basePath, string gpoDomain) |
| 3 | 404 | | { |
| 3 | 405 | | var xmlPath = Path.Combine(basePath, "MACHINE", "Preferences", "Groups", "Groups.xml"); |
| | 406 | |
|
| | 407 | | //If the file doesn't exist, then just return |
| 3 | 408 | | if (!File.Exists(xmlPath)) |
| 1 | 409 | | yield break; |
| | 410 | |
|
| | 411 | | //Create an XPathDocument to let us navigate the XML |
| | 412 | | XPathDocument doc; |
| | 413 | | try |
| 2 | 414 | | { |
| 2 | 415 | | doc = new XPathDocument(xmlPath); |
| 2 | 416 | | } |
| 0 | 417 | | catch (Exception e) |
| 0 | 418 | | { |
| 0 | 419 | | _log.LogError(e, "error reading GPO XML file {File}", xmlPath); |
| 0 | 420 | | yield break; |
| | 421 | | } |
| | 422 | |
|
| 2 | 423 | | var navigator = doc.CreateNavigator(); |
| | 424 | | //Grab all the Groups nodes |
| 2 | 425 | | var groupsNodes = navigator.Select("/Groups"); |
| | 426 | |
|
| 4 | 427 | | while (groupsNodes.MoveNext()) |
| 2 | 428 | | { |
| 2 | 429 | | var current = groupsNodes.Current; |
| | 430 | | //If disable is set to 1, then this Group wont apply |
| 2 | 431 | | if (current.GetAttribute("disabled", "") is "1") |
| 1 | 432 | | continue; |
| | 433 | |
|
| 1 | 434 | | var groupNodes = current.Select("Group"); |
| 6 | 435 | | while (groupNodes.MoveNext()) |
| 5 | 436 | | { |
| | 437 | | //Grab the properties for each Group node. Current path is /Groups/Group |
| 5 | 438 | | var groupProperties = groupNodes.Current.Select("Properties"); |
| 10 | 439 | | while (groupProperties.MoveNext()) |
| 5 | 440 | | { |
| 5 | 441 | | var currentProperties = groupProperties.Current; |
| 5 | 442 | | var action = currentProperties.GetAttribute("action", ""); |
| | 443 | |
|
| | 444 | | //The only action that works for built in groups is Update. |
| 5 | 445 | | if (!action.Equals("u", StringComparison.OrdinalIgnoreCase)) |
| 1 | 446 | | continue; |
| | 447 | |
|
| 4 | 448 | | var groupSid = currentProperties.GetAttribute("groupSid", "")?.Trim(); |
| 4 | 449 | | var groupName = currentProperties.GetAttribute("groupName", "")?.Trim(); |
| | 450 | |
|
| | 451 | | //Next is to determine what group is being updated. |
| | 452 | |
|
| 4 | 453 | | var targetGroup = LocalGroupRids.None; |
| 4 | 454 | | if (!string.IsNullOrWhiteSpace(groupSid)) |
| 2 | 455 | | { |
| | 456 | | //Use a regex to match and attempt to extract the RID |
| 2 | 457 | | var s = ExtractRid.Match(groupSid); |
| 2 | 458 | | if (s.Success) |
| 2 | 459 | | { |
| 2 | 460 | | var rid = int.Parse(s.Groups[1].Value); |
| 2 | 461 | | if (Enum.IsDefined(typeof(LocalGroupRids), rid)) |
| 2 | 462 | | targetGroup = (LocalGroupRids) rid; |
| 2 | 463 | | } |
| 2 | 464 | | } |
| | 465 | |
|
| 4 | 466 | | if (!string.IsNullOrWhiteSpace(groupName) && targetGroup == LocalGroupRids.None) |
| 2 | 467 | | ValidGroupNames.TryGetValue(groupName, out targetGroup); |
| | 468 | |
|
| | 469 | | //If targetGroup is still None, we've failed to resolve a group target. No point in continuing |
| 4 | 470 | | if (targetGroup == LocalGroupRids.None) |
| 1 | 471 | | continue; |
| | 472 | |
|
| 3 | 473 | | var deleteUsers = currentProperties.GetAttribute("deleteAllUsers", "") == "1"; |
| 3 | 474 | | var deleteGroups = currentProperties.GetAttribute("deleteAllGroups", "") == "1"; |
| | 475 | |
|
| 3 | 476 | | if (deleteUsers) |
| 1 | 477 | | yield return new GroupAction |
| 1 | 478 | | { |
| 1 | 479 | | Action = GroupActionOperation.DeleteUsers, |
| 1 | 480 | | Target = GroupActionTarget.LocalGroup, |
| 1 | 481 | | TargetRid = targetGroup |
| 1 | 482 | | }; |
| | 483 | |
|
| 3 | 484 | | if (deleteGroups) |
| 1 | 485 | | yield return new GroupAction |
| 1 | 486 | | { |
| 1 | 487 | | Action = GroupActionOperation.DeleteGroups, |
| 1 | 488 | | Target = GroupActionTarget.LocalGroup, |
| 1 | 489 | | TargetRid = targetGroup |
| 1 | 490 | | }; |
| | 491 | |
|
| | 492 | | //Get all the actual members being added |
| 3 | 493 | | var members = currentProperties.Select("Members/Member"); |
| 10 | 494 | | while (members.MoveNext()) |
| 7 | 495 | | { |
| 7 | 496 | | var memberAction = members.Current.GetAttribute("action", "") |
| 7 | 497 | | .Equals("ADD", StringComparison.OrdinalIgnoreCase) |
| 7 | 498 | | ? GroupActionOperation.Add |
| 7 | 499 | | : GroupActionOperation.Delete; |
| | 500 | |
|
| 7 | 501 | | var memberName = members.Current.GetAttribute("name", ""); |
| 7 | 502 | | var memberSid = members.Current.GetAttribute("sid", ""); |
| | 503 | |
|
| 7 | 504 | | var ga = new GroupAction |
| 7 | 505 | | { |
| 7 | 506 | | Action = memberAction |
| 7 | 507 | | }; |
| | 508 | |
|
| | 509 | | //If we have a memberSid, this is the best case scenario |
| 7 | 510 | | if (!string.IsNullOrWhiteSpace(memberSid)) |
| 5 | 511 | | { |
| 5 | 512 | | var memberType = |
| 5 | 513 | | _utils.LookupSidType(memberSid, _utils.GetDomainNameFromSid(memberSid)); |
| 5 | 514 | | ga.Target = GroupActionTarget.LocalGroup; |
| 5 | 515 | | ga.TargetSid = memberSid; |
| 5 | 516 | | ga.TargetType = memberType; |
| 5 | 517 | | ga.TargetRid = targetGroup; |
| | 518 | |
|
| 5 | 519 | | yield return ga; |
| 5 | 520 | | continue; |
| | 521 | | } |
| | 522 | |
|
| | 523 | | //If we have a memberName, we need to resolve it to a SID/Type |
| 2 | 524 | | if (!string.IsNullOrWhiteSpace(memberName)) |
| 2 | 525 | | { |
| | 526 | | //Check if the name is domain prefixed |
| 2 | 527 | | if (memberName.Contains("\\")) |
| 1 | 528 | | { |
| 1 | 529 | | var s = memberName.Split('\\'); |
| 1 | 530 | | var name = s[1]; |
| 1 | 531 | | var domain = s[0]; |
| | 532 | |
|
| 1 | 533 | | var res = _utils.ResolveAccountName(name, domain); |
| 1 | 534 | | if (res == null) |
| 0 | 535 | | { |
| 0 | 536 | | _log.LogWarning("Failed to resolve member {memberName}", memberName); |
| 0 | 537 | | continue; |
| | 538 | | } |
| 1 | 539 | | ga.Target = GroupActionTarget.LocalGroup; |
| 1 | 540 | | ga.TargetSid = res.ObjectIdentifier; |
| 1 | 541 | | ga.TargetType = res.ObjectType; |
| 1 | 542 | | ga.TargetRid = targetGroup; |
| 1 | 543 | | yield return ga; |
| 1 | 544 | | } |
| | 545 | | else |
| 1 | 546 | | { |
| 1 | 547 | | var res = _utils.ResolveAccountName(memberName, gpoDomain); |
| 1 | 548 | | if (res == null) |
| 0 | 549 | | { |
| 0 | 550 | | _log.LogWarning("Failed to resolve member {memberName}", memberName); |
| 0 | 551 | | continue; |
| | 552 | | } |
| 1 | 553 | | ga.Target = GroupActionTarget.LocalGroup; |
| 1 | 554 | | ga.TargetSid = res.ObjectIdentifier; |
| 1 | 555 | | ga.TargetType = res.ObjectType; |
| 1 | 556 | | ga.TargetRid = targetGroup; |
| 1 | 557 | | yield return ga; |
| 1 | 558 | | } |
| 2 | 559 | | } |
| 2 | 560 | | } |
| 3 | 561 | | } |
| 5 | 562 | | } |
| 1 | 563 | | } |
| 2 | 564 | | } |
| | 565 | |
|
| | 566 | | /// <summary> |
| | 567 | | /// Represents an action from a GPO |
| | 568 | | /// </summary> |
| | 569 | | internal class GroupAction |
| | 570 | | { |
| 13 | 571 | | internal GroupActionOperation Action { get; set; } |
| 13 | 572 | | internal GroupActionTarget Target { get; set; } |
| 12 | 573 | | internal string TargetSid { get; set; } |
| 12 | 574 | | internal Label TargetType { get; set; } |
| 13 | 575 | | internal LocalGroupRids TargetRid { get; set; } |
| | 576 | |
|
| | 577 | | public TypedPrincipal ToTypedPrincipal() |
| 1 | 578 | | { |
| 1 | 579 | | return new TypedPrincipal |
| 1 | 580 | | { |
| 1 | 581 | | ObjectIdentifier = TargetSid, |
| 1 | 582 | | ObjectType = TargetType |
| 1 | 583 | | }; |
| 1 | 584 | | } |
| | 585 | |
|
| | 586 | | public override string ToString() |
| 1 | 587 | | { |
| 1 | 588 | | return |
| 1 | 589 | | $"{nameof(Action)}: {Action}, {nameof(Target)}: {Target}, {nameof(TargetSid)}: {TargetSid}, {nameof( |
| 1 | 590 | | } |
| | 591 | | } |
| | 592 | |
|
| | 593 | | /// <summary> |
| | 594 | | /// Storage for each different group type |
| | 595 | | /// </summary> |
| | 596 | | public class GroupResults |
| | 597 | | { |
| 10 | 598 | | public List<TypedPrincipal> LocalGroups = new(); |
| 10 | 599 | | public List<TypedPrincipal> RestrictedMember = new(); |
| 10 | 600 | | public List<TypedPrincipal> RestrictedMemberOf = new(); |
| | 601 | | } |
| | 602 | |
|
| | 603 | | internal enum GroupActionOperation |
| | 604 | | { |
| | 605 | | Add, |
| | 606 | | Delete, |
| | 607 | | DeleteUsers, |
| | 608 | | DeleteGroups |
| | 609 | | } |
| | 610 | |
|
| | 611 | | internal enum GroupActionTarget |
| | 612 | | { |
| | 613 | | RestrictedMemberOf, |
| | 614 | | RestrictedMember, |
| | 615 | | LocalGroup |
| | 616 | | } |
| | 617 | |
|
| | 618 | | internal enum LocalGroupRids |
| | 619 | | { |
| | 620 | | None = 0, |
| | 621 | | Administrators = 544, |
| | 622 | | RemoteDesktopUsers = 555, |
| | 623 | | DcomUsers = 562, |
| | 624 | | PSRemote = 580 |
| | 625 | | } |
| | 626 | | } |
| | 627 | | } |