diff --git a/src/CommonLib/Enums/CollectionMethod.cs b/src/CommonLib/Enums/CollectionMethod.cs index 19191d126..ffb891e27 100644 --- a/src/CommonLib/Enums/CollectionMethod.cs +++ b/src/CommonLib/Enums/CollectionMethod.cs @@ -27,16 +27,17 @@ public enum CollectionMethod { WebClientService = 1 << 21, SmbInfo = 1 << 22, NTLMRegistry = 1 << 23, + GPOUserRights = 1 << 24, //TODO: Re-introduce this when we're ready for Event Log collection //EventLogs = 1 << 23, LocalGroups = DCOM | RDP | LocalAdmin | PSRemote, ComputerOnly = LocalGroups | Session | UserRights | CARegistry | DCRegistry | WebClientService | SmbInfo | NTLMRegistry, - DCOnly = ACL | Container | Group | ObjectProps | Trusts | GPOLocalGroup | CertServices, + DCOnly = ACL | Container | Group | ObjectProps | Trusts | GPOLocalGroup | GPOUserRights | CertServices, Default = Group | Session | Trusts | ACL | ObjectProps | LocalGroups | SPNTargets | Container | CertServices | LdapServices | SmbInfo | WebClientService, - All = Default | LoggedOn | GPOLocalGroup | UserRights | CARegistry | DCRegistry | WebClientService | + All = Default | LoggedOn | GPOLocalGroup | GPOUserRights | UserRights | CARegistry | DCRegistry | WebClientService | LdapServices | NTLMRegistry } } \ No newline at end of file diff --git a/src/CommonLib/Enums/LSAPrivileges.cs b/src/CommonLib/Enums/LSAPrivileges.cs index c924966c1..65e895a99 100644 --- a/src/CommonLib/Enums/LSAPrivileges.cs +++ b/src/CommonLib/Enums/LSAPrivileges.cs @@ -47,6 +47,6 @@ public class LSAPrivileges public const string TrustedCredManAccess = "SeTrustedCredManAccessPrivilege"; public const string Undock = "SeUndockPrivilege"; - public static readonly string[] DesiredPrivileges = {RemoteInteractiveLogon}; + public static readonly string[] DesiredPrivileges = {InteractiveLogon, RemoteInteractiveLogon, AssignPrimaryToken, Backup, CreateToken, Debug, Impersonate, LoadDriver, ManageVolume, Restore, TakeOwnership, Tcb}; } } \ No newline at end of file diff --git a/src/CommonLib/LdapProducerQueryGenerator.cs b/src/CommonLib/LdapProducerQueryGenerator.cs index 25c6cb9bf..c7e089e34 100644 --- a/src/CommonLib/LdapProducerQueryGenerator.cs +++ b/src/CommonLib/LdapProducerQueryGenerator.cs @@ -44,6 +44,9 @@ public static GeneratedLdapParameters GenerateDefaultPartitionParameters(Collect if (methods.HasFlag(CollectionMethod.GPOLocalGroup)) properties.AddRange(CommonProperties.GPOLocalGroupProps); + if (methods.HasFlag(CollectionMethod.GPOUserRights)) + properties.AddRange(CommonProperties.GPOUserRights); + if (methods.HasFlag(CollectionMethod.SPNTargets)) properties.AddRange(CommonProperties.SPNTargetProps); @@ -80,6 +83,11 @@ public static GeneratedLdapParameters GenerateDefaultPartitionParameters(Collect properties.AddRange(CommonProperties.GPOLocalGroupProps); } + if (methods.HasFlag(CollectionMethod.GPOUserRights)) { + filter = filter.AddOUs(); + properties.AddRange(CommonProperties.GPOUserRights); + } + if (methods.HasFlag(CollectionMethod.DCRegistry) || methods.HasFlag(CollectionMethod.LdapServices)) { filter = filter.AddComputers(CommonFilters.DomainControllers); properties.AddRange(CommonProperties.ComputerMethodProps); diff --git a/src/CommonLib/LdapQueries/CommonProperties.cs b/src/CommonLib/LdapQueries/CommonProperties.cs index 508b5490c..902209eee 100644 --- a/src/CommonLib/LdapQueries/CommonProperties.cs +++ b/src/CommonLib/LdapQueries/CommonProperties.cs @@ -86,6 +86,10 @@ public static class CommonProperties LDAPProperties.GPLink, LDAPProperties.Name }; + public static readonly string[] GPOUserRights = { + LDAPProperties.GPLink, LDAPProperties.Name + }; + public static readonly string[] CertAbuseProps = { LDAPProperties.CertificateTemplates, LDAPProperties.Flags, LDAPProperties.DNSHostName, LDAPProperties.CACertificate, LDAPProperties.PKINameFlag, diff --git a/src/CommonLib/OutputTypes/OU.cs b/src/CommonLib/OutputTypes/OU.cs index 87f1f9d8c..db721e52e 100644 --- a/src/CommonLib/OutputTypes/OU.cs +++ b/src/CommonLib/OutputTypes/OU.cs @@ -1,10 +1,12 @@ -using System; +using SharpHoundCommonLib.Processors; +using System; namespace SharpHoundCommonLib.OutputTypes { public class OU : OutputBase { public ResultingGPOChanges GPOChanges = new(); + public ResultingGPOUserRights GPOUserRights = new(); public GPLink[] Links { get; set; } = Array.Empty(); public TypedPrincipal[] ChildObjects { get; set; } = Array.Empty(); public string[] InheritanceHashes { get; set; } = Array.Empty(); diff --git a/src/CommonLib/OutputTypes/ResultingGPOUserRights.cs b/src/CommonLib/OutputTypes/ResultingGPOUserRights.cs new file mode 100644 index 000000000..467604688 --- /dev/null +++ b/src/CommonLib/OutputTypes/ResultingGPOUserRights.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; + +namespace SharpHoundCommonLib.OutputTypes +{ + public class ResultingGPOUserRights + { + public TypedPrincipal[] AffectedComputers { get; set; } = Array.Empty(); + + // Dictionary mapping privilege name to array of principals that have that privilege + public Dictionary UserRightAssignments { get; set; } = + new Dictionary(); + } +} \ No newline at end of file diff --git a/src/CommonLib/Processors/GPOLocalGroupProcessor.cs b/src/CommonLib/Processors/GPOLocalGroupProcessor.cs index 28a6996f6..4846111a0 100644 --- a/src/CommonLib/Processors/GPOLocalGroupProcessor.cs +++ b/src/CommonLib/Processors/GPOLocalGroupProcessor.cs @@ -57,7 +57,7 @@ public Task ReadGPOLocalGroups(IDirectoryObject entry) { return Task.FromResult(new ResultingGPOChanges()); } - public async Task ReadGPOLocalGroups(string gpLink, string distinguishedName) { + public async Task ReadGPOLocalGroups(string gpLink, string distinguishedName) { var ret = new ResultingGPOChanges(); //If the gplink property is null, we don't need to process anything if (gpLink == null) diff --git a/src/CommonLib/Processors/GPOUserRightsProcessor.cs b/src/CommonLib/Processors/GPOUserRightsProcessor.cs new file mode 100644 index 000000000..4de74a67c --- /dev/null +++ b/src/CommonLib/Processors/GPOUserRightsProcessor.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SharpHoundCommonLib.Enums; +using SharpHoundCommonLib.LDAPQueries; +using SharpHoundCommonLib.OutputTypes; +using System.DirectoryServices.Protocols; +using System.Drawing.Printing; + +namespace SharpHoundCommonLib.Processors +{ + public class GPOUserRightsAssignmentProcessor + { + // Regex to parse key=value lines in the INI file + private static readonly Regex KeyRegex = new(@"(.+?)\s*=(.*)", RegexOptions.Compiled); + + // Regex to extract the [Privilege Rights] section + private static readonly Regex PrivilegeRightsRegex = + new(@"\[Privilege Rights\](.*)(?:\[|$)", RegexOptions.Compiled | RegexOptions.Singleline); + + // Cache to store processed GPO privilege actions + private static readonly ConcurrentDictionary> GpoPrivilegeCache = new(); + + private readonly ILogger _log; + private readonly ILdapUtils _utils; + + public GPOUserRightsAssignmentProcessor(ILdapUtils utils, ILogger log = null) + { + _utils = utils; + _log = log ?? Logging.LogProvider.CreateLogger("GPOUserRightsProc"); + } + + public Task ReadGPOUserRights(IDirectoryObject entry) + { + if (entry.TryGetProperty(LDAPProperties.GPLink, out var links) && + entry.TryGetDistinguishedName(out var dn)) + { + return ReadGPOUserRights(links, dn); + } + + return Task.FromResult(new ResultingGPOUserRights()); + } + + public async Task ReadGPOUserRights(string gpLink, string distinguishedName) + { + var ret = new ResultingGPOUserRights(); + + // If the gplink property is null, we don't need to process anything + if (gpLink == null) + return ret; + + string domain; + // If our dn is null, use our default domain + if (string.IsNullOrEmpty(distinguishedName)) + { + if (!_utils.GetDomain(out var domainResult)) + { + return ret; + } + domain = domainResult.Name; + } + else + { + domain = Helpers.DistinguishedNameToDomain(distinguishedName); + } + + // First check if this OU has computers + var affectedComputers = new List(); + await foreach (var result in _utils.Query(new LdapQueryParameters() + { + LDAPFilter = new LdapFilter().AddComputersNoMSAs().GetFilter(), + Attributes = CommonProperties.ObjectSID, + SearchBase = distinguishedName, + DomainName = domain + })) + { + if (!result.IsSuccess) + { + break; + } + + var entry = result.Value; + if (!entry.TryGetSecurityIdentifier(out var sid)) + { + continue; + } + + affectedComputers.Add(new TypedPrincipal(sid, Label.Computer)); + } + + // If there's no computers then we don't care about this OU + if (affectedComputers.Count == 0) + return ret; + + var enforced = new List(); + var unenforced = new List(); + + // Split our link property up and remove disabled links + foreach (var link in Helpers.SplitGPLinkProperty(gpLink)) + switch (link.Status) + { + case "0": + unenforced.Add(link.DistinguishedName); + break; + case "2": + enforced.Add(link.DistinguishedName); + break; + } + + // Set up our links in the correct order + var orderedLinks = new List(); + orderedLinks.AddRange(unenforced); + orderedLinks.AddRange(enforced); + + // Dictionary to store principals for each privilege + var privilegeData = new Dictionary>(); + + foreach (var linkDn in orderedLinks) + { + if (!GpoPrivilegeCache.TryGetValue(linkDn.ToLower(), out var actions)) + { + actions = new List(); + + var gpoDomain = Helpers.DistinguishedNameToDomain(linkDn); + var result = await _utils.Query(new LdapQueryParameters() + { + LDAPFilter = new LdapFilter().AddAllObjects().GetFilter(), + SearchScope = SearchScope.Base, + Attributes = new[] { LDAPProperties.GPCFileSYSPath, LDAPProperties.Flags }, + SearchBase = linkDn, + DomainName = gpoDomain + }).DefaultIfEmpty(LdapResult.Fail()).FirstOrDefaultAsync(); + + if (!result.IsSuccess) + { + continue; + } + + if (!result.Value.TryGetProperty(LDAPProperties.GPCFileSYSPath, out var filePath) || + // Filter out GPOs that are disabled or the computer configuration is disabled + (result.Value.TryGetProperty(LDAPProperties.Flags, out var flags) && flags is "2" or "3")) + { + GpoPrivilegeCache.TryAdd(linkDn.ToLower(), actions); + continue; + } + + // Process the GPO template file for privilege rights + await foreach (var item in ProcessGPOTemplateFilePrivileges(filePath, gpoDomain)) + { + actions.Add(item); + } + + GpoPrivilegeCache.TryAdd(linkDn.ToLower(), actions); + } + + // If there are no actions, move to next GPO + if (actions.Count == 0) + continue; + + // Process each privilege action + // Later GPOs override earlier ones (last write wins) + foreach (var action in actions) + { + if (!privilegeData.ContainsKey(action.PrivilegeName)) + { + privilegeData[action.PrivilegeName] = new List(); + } + + // Replace the entire list (GPO privileges are absolute, not additive) + privilegeData[action.PrivilegeName] = action.Principals.ToList(); + } + } + + ret.AffectedComputers = affectedComputers.ToArray(); + + // Convert to the final format + ret.UserRightAssignments = privilegeData.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.Distinct().ToArray() + ); + + return ret; + } + + /// + /// Parses a GPO GptTmpl.inf file and extracts privilege rights assignments + /// + /// Base path to the GPO + /// Domain of the GPO + /// Enumerable of privilege actions + internal async IAsyncEnumerable ProcessGPOTemplateFilePrivileges(string basePath, string gpoDomain) + { + var templatePath = Path.Combine(basePath, "MACHINE", "Microsoft", "Windows NT", "SecEdit", "GptTmpl.inf"); + + if (!File.Exists(templatePath)) + yield break; + + FileStream fs; + try + { + fs = new FileStream(templatePath, FileMode.Open, FileAccess.Read); + } + catch (Exception e) + { + _log.LogWarning(e, "Failed to open template file {Path}", templatePath); + yield break; + } + + using var reader = new StreamReader(fs); + var content = await reader.ReadToEndAsync(); + var privilegeMatch = PrivilegeRightsRegex.Match(content); + + if (!privilegeMatch.Success) + yield break; + + // Extract the [Privilege Rights] section + var privilegeText = privilegeMatch.Groups[1].Value.Trim(); + var privilegeLines = Regex.Split(privilegeText, @"\r\n|\r|\n"); + + foreach (var line in privilegeLines) + { + var keyMatch = KeyRegex.Match(line); + + if (!keyMatch.Success) + continue; + + var privilegeName = keyMatch.Groups[1].Value.Trim(); + var membersText = keyMatch.Groups[2].Value.Trim(); + + // Skip empty privilege assignments + if (string.IsNullOrWhiteSpace(membersText)) + continue; + + var principals = new List(); + + // Parse each member (comma-separated SIDs with * prefix) + foreach (var member in membersText.Split(',')) + { + var cleanMember = member.Trim().TrimStart('*'); + + if (string.IsNullOrWhiteSpace(cleanMember)) + continue; + + // Resolve the SID to a typed principal + if (await GetSid(cleanMember, gpoDomain) is (true, var principal)) + { + principals.Add(principal); + } + } + + // Only yield if we successfully resolved at least one principal + if (principals.Count > 0) + { + yield return new PrivilegeAction + { + PrivilegeName = privilegeName, + Principals = principals.ToArray() + }; + } + } + } + + /// + /// Resolves a SID or account name to a TypedPrincipal + /// + private async Task<(bool Success, TypedPrincipal Principal)> GetSid(string account, string domainName) + { + if (!account.StartsWith("S-1-", StringComparison.CurrentCulture)) + { + string user; + string domain; + if (account.Contains('\\')) + { + // The account is in the format DOMAIN\username + var split = account.Split('\\'); + domain = split[0]; + user = split[1]; + } + else + { + // The account is just a username, so try with the current domain + domain = domainName; + user = account; + } + + user = user.ToUpper(); + + // Try to resolve as a user object first + var (success, res) = await _utils.ResolveAccountName(user, domain); + if (success) + return (true, res); + + // Try as a computer account + return await _utils.ResolveAccountName($"{user}$", domain); + } + + // The element is just a SID, so resolve it + return await _utils.ResolveIDAndType(account, domainName); + } + + /// + /// Represents a privilege assignment action from a GPO + /// + internal class PrivilegeAction + { + internal string PrivilegeName { get; set; } + internal TypedPrincipal[] Principals { get; set; } + + public override string ToString() + { + return $"{nameof(PrivilegeName)}: {PrivilegeName}, Principals: {Principals?.Length ?? 0}"; + } + } + } +} \ No newline at end of file diff --git a/src/CommonLib/Processors/LdapPropertyProcessor.cs b/src/CommonLib/Processors/LdapPropertyProcessor.cs index 4899b390f..0c972ec27 100644 --- a/src/CommonLib/Processors/LdapPropertyProcessor.cs +++ b/src/CommonLib/Processors/LdapPropertyProcessor.cs @@ -31,6 +31,7 @@ static LdapPropertyProcessor() { ReservedAttributes.UnionWith(CommonProperties.SPNTargetProps); ReservedAttributes.UnionWith(CommonProperties.DomainTrustProps); ReservedAttributes.UnionWith(CommonProperties.GPOLocalGroupProps); + ReservedAttributes.UnionWith(CommonProperties.GPOUserRights); ReservedAttributes.UnionWith(CommonProperties.CertAbuseProps); ReservedAttributes.Add(LDAPProperties.DSASignature); } diff --git a/src/CommonLib/Processors/UserRightsAssignmentProcessor.cs b/src/CommonLib/Processors/UserRightsAssignmentProcessor.cs index d8f8a8a11..0d2a38bc0 100644 --- a/src/CommonLib/Processors/UserRightsAssignmentProcessor.cs +++ b/src/CommonLib/Processors/UserRightsAssignmentProcessor.cs @@ -98,7 +98,6 @@ await SendComputerStatus(new CSVComputerStatus { Collected = false, Privilege = privilege }; - //Ask for all principals with the specified privilege. var enumerateAccountsResult = await _getResolvedPrincipalWithPriviledgeAdaptiveTimeout.ExecuteRPCWithTimeout((_) => server.GetResolvedPrincipalsWithPrivilege(privilege)); if (enumerateAccountsResult.IsFailed) { @@ -214,4 +213,4 @@ private async Task SendComputerStatus(CSVComputerStatus status) { if (ComputerStatusEvent is not null) await ComputerStatusEvent.Invoke(status); } } -} \ No newline at end of file +}