diff --git a/docfx/Docfx.csproj b/docfx/Docfx.csproj index e8d74dd37..6133e54fe 100644 --- a/docfx/Docfx.csproj +++ b/docfx/Docfx.csproj @@ -2,6 +2,7 @@ net5.0 false + false $([MSBuild]::NormalizeDirectory($(MSBuildThisFileDirectory), `..`, `docs`)) 8002 $(MSBuildThisFileDirectory)docfx.log diff --git a/src/CommonLib/Enums/DirectoryPaths.cs b/src/CommonLib/Enums/DirectoryPaths.cs index 878feef23..c7421afac 100644 --- a/src/CommonLib/Enums/DirectoryPaths.cs +++ b/src/CommonLib/Enums/DirectoryPaths.cs @@ -8,7 +8,8 @@ public static class DirectoryPaths public const string CertTemplateLocation = "CN=Certificate Templates,CN=Public Key Services,CN=Services"; public const string NTAuthStoreLocation = "CN=NTAuthCertificates,CN=Public Key Services,CN=Services"; public const string PKILocation = "CN=Public Key Services,CN=Services"; + public const string ExchangeLocation = "CN=Microsoft Exchange,CN=Services,CN=Configuration"; public const string ConfigLocation = "CN=Configuration"; public const string OIDContainerLocation = "CN=OID,CN=Public Key Services,CN=Services"; } -} \ No newline at end of file +} diff --git a/src/CommonLib/ILdapUtils.cs b/src/CommonLib/ILdapUtils.cs index 753e53bd1..2fc7a6b32 100644 --- a/src/CommonLib/ILdapUtils.cs +++ b/src/CommonLib/ILdapUtils.cs @@ -157,6 +157,10 @@ IAsyncEnumerable> RangedRetrieval(string distinguishedName, /// The new ldap config void SetLdapConfig(LdapConfig config); /// + /// Gets whether custom deny ACE collection is disabled for this utils instance + /// + bool SkipDenyAces { get; } + /// /// Tests if a LDAP connection can be made successfully to a domain /// /// The domain to test @@ -175,4 +179,4 @@ IAsyncEnumerable> RangedRetrieval(string distinguishedName, /// void ResetUtils(); } -} \ No newline at end of file +} diff --git a/src/CommonLib/LdapConfig.cs b/src/CommonLib/LdapConfig.cs index 3f3e84e40..5242d02b2 100644 --- a/src/CommonLib/LdapConfig.cs +++ b/src/CommonLib/LdapConfig.cs @@ -13,6 +13,7 @@ public class LdapConfig public bool ForceSSL { get; set; } = false; public bool DisableSigning { get; set; } = false; public bool DisableCertVerification { get; set; } = false; + public bool SkipDenyAces { get; set; } = false; public AuthType AuthType { get; set; } = AuthType.Kerberos; public int MaxConcurrentQueries { get; set; } = 15; @@ -41,6 +42,7 @@ public override string ToString() { sb.AppendLine($"LdapPort: {GetPort(false)}"); sb.AppendLine($"LdapSSLPort: {GetPort(true)}"); sb.AppendLine($"ForceSSL: {ForceSSL}"); + sb.AppendLine($"SkipDenyAces: {SkipDenyAces}"); sb.AppendLine($"AuthType: {AuthType.ToString()}"); sb.AppendLine($"MaxConcurrentQueries: {MaxConcurrentQueries}"); if (!string.IsNullOrWhiteSpace(Username)) { @@ -53,4 +55,4 @@ public override string ToString() { return sb.ToString(); } } -} \ No newline at end of file +} diff --git a/src/CommonLib/LdapUtils.cs b/src/CommonLib/LdapUtils.cs index 9db6d7ec0..cd416823d 100644 --- a/src/CommonLib/LdapUtils.cs +++ b/src/CommonLib/LdapUtils.cs @@ -881,7 +881,8 @@ public ActiveDirectorySecurityDescriptor MakeSecurityDescriptor() { string computerDomainSid, string computerDomain) { if (!WellKnownPrincipal.GetWellKnownPrincipal(sid.Value, out var common)) return (false, null); //The "Everyone" and "Authenticated Users" principals are special and will be converted to the domain equivalent - if (sid.Value is "S-1-1-0" or "S-1-5-11") { + if (sid.Value is var sidValue && + (sidValue == WellKnownPrincipal.EveryoneSid || sidValue == "S-1-5-11")) { return await GetWellKnownPrincipal(sid.Value, computerDomain); } @@ -1076,6 +1077,8 @@ public void SetLdapConfig(LdapConfig config) { _connectionPool = new ConnectionPoolManager(_ldapConfig, scanner: _portScanner); } + public bool SkipDenyAces => _ldapConfig.SkipDenyAces; + public Task<(bool Success, string Message)> TestLdapConnection(string domain) { return _connectionPool.TestDomainConnection(domain, false); } @@ -1418,4 +1421,4 @@ private static string ComputeDisplayName(IDirectoryObject directoryObject, strin return displayName.ToUpper(); } } -} \ No newline at end of file +} diff --git a/src/CommonLib/Processors/ACLProcessor.cs b/src/CommonLib/Processors/ACLProcessor.cs index da3a615b4..44edeb9b4 100644 --- a/src/CommonLib/Processors/ACLProcessor.cs +++ b/src/CommonLib/Processors/ACLProcessor.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging; using SharpHoundCommonLib.DirectoryObjects; using SharpHoundCommonLib.Enums; +using SharpHoundCommonLib.LDAPQueries; using SharpHoundCommonLib.OutputTypes; using System.Linq; @@ -20,7 +21,15 @@ public class ACLProcessor { private readonly ILogger _log; private readonly ILdapUtils _utils; private readonly ConcurrentHashSet _builtDomainCaches = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _exchangeTrusteeSidCache = new(StringComparer.OrdinalIgnoreCase); private readonly object _lock = new(); + // These Exchange principals commonly carry product-added deny ACEs that we intentionally suppress. + private static readonly HashSet ExchangeTrusteeNames = new(StringComparer.OrdinalIgnoreCase) { + "Exchange Windows Permissions", + "Exchange Trusted Subsystem", + "Exchange Servers", + "Organization Management" + }; static ACLProcessor() { //Create a dictionary with the base GUIDs of each object type @@ -881,6 +890,175 @@ or Label.NTAuthStore } } + public Task GetCustomDenyAces(ResolvedSearchResult result, IDirectoryObject searchResult) { + if (!searchResult.TryGetByteProperty(LDAPProperties.SecurityDescriptor, out var descriptor)) { + return Task.FromResult(Array.Empty()); + } + + searchResult.TryGetDistinguishedName(out var distinguishedName); + return GetCustomDenyAces( + descriptor, + result.Domain, + result.ObjectType, + distinguishedName, + searchResult.IsMSA() || searchResult.IsGMSA(), + result.DisplayName); + } + + public async Task GetCustomDenyAces(byte[] ntSecurityDescriptor, string objectDomain, + Label objectType, string distinguishedName = null, bool isMSA = false, string objectName = "") { + if (ntSecurityDescriptor == null) { + return Array.Empty(); + } + + RawSecurityDescriptor descriptor; + try { + descriptor = new RawSecurityDescriptor(ntSecurityDescriptor, 0); + } + catch (Exception e) when (e is OverflowException or ArgumentException) { + _log.LogWarning( + "Security descriptor on object {Name} exceeds maximum allowable length. Unable to process custom deny ACEs", + objectName); + return Array.Empty(); + } + + if (descriptor.DiscretionaryAcl == null || descriptor.DiscretionaryAcl.Count == 0) { + return Array.Empty(); + } + + var results = new List(); + + // Walk the raw DACL so we can preserve deny ACE ordering and serialize each ACE back to SDDL verbatim. + foreach (GenericAce ace in descriptor.DiscretionaryAcl) { + if (!TryGetDenyAceData(ace, out var principalSid, out var rights, out var objectAceType)) { + continue; + } + + if (await ShouldExcludeCustomDenyAce(principalSid, rights, objectAceType, objectDomain, objectType, + distinguishedName, isMSA)) { + continue; + } + + var sddl = SerializeAceToSddl(ace); + if (!string.IsNullOrWhiteSpace(sddl)) { + results.Add(sddl); + } + } + + return results.Count == 0 ? Array.Empty() : results.ToArray(); + } + + public async Task AddCustomDenyAcesProperty(Dictionary props, byte[] ntSecurityDescriptor, + string objectDomain, Label objectType, string distinguishedName = null, bool isMSA = false, + string objectName = "") { + var customDenyAces = await GetCustomDenyAces(ntSecurityDescriptor, objectDomain, objectType, + distinguishedName, isMSA, objectName); + + if (customDenyAces.Length > 0) { + props["customdenyaces"] = customDenyAces; + } + } + + private static bool TryGetDenyAceData(GenericAce ace, out string principalSid, out ActiveDirectoryRights rights, + out Guid objectAceType) { + principalSid = null; + rights = 0; + objectAceType = Guid.Empty; + + switch (ace) { + case CommonAce commonAce when commonAce.AceQualifier == AceQualifier.AccessDenied: + principalSid = commonAce.SecurityIdentifier?.Value; + rights = (ActiveDirectoryRights)commonAce.AccessMask; + return !string.IsNullOrWhiteSpace(principalSid); + case ObjectAce objectAce when objectAce.AceQualifier == AceQualifier.AccessDenied: + principalSid = objectAce.SecurityIdentifier?.Value; + rights = (ActiveDirectoryRights)objectAce.AccessMask; + objectAceType = objectAce.ObjectAceType; + return !string.IsNullOrWhiteSpace(principalSid); + default: + return false; + } + } + + private async Task ShouldExcludeCustomDenyAce(string principalSid, ActiveDirectoryRights rights, + Guid objectAceType, string objectDomain, Label objectType, string distinguishedName, bool isMSA) { + // Filter Exchange Deny ACEs + if (!string.IsNullOrWhiteSpace(distinguishedName) && + distinguishedName.IndexOf(DirectoryPaths.ExchangeLocation, StringComparison.OrdinalIgnoreCase) >= 0) { + return true; + } + + if (await IsExchangeTrustee(principalSid, objectDomain)) { + return true; + } + + // Filter default Everyone Deny ACEs + if (principalSid.Equals(WellKnownPrincipal.EveryoneSid, StringComparison.OrdinalIgnoreCase)) { + if ((objectType is Label.OU or Label.Container) && + rights.HasFlag(ActiveDirectoryRights.Delete) && + rights.HasFlag(ActiveDirectoryRights.DeleteTree)) { + return true; + } + + if (isMSA && + rights.HasFlag(ActiveDirectoryRights.ExtendedRight) && + objectAceType.Equals(new Guid(ACEGuids.UserForceChangePassword))) { + return true; + } + + if (objectType == Label.Domain && rights.HasFlag(ActiveDirectoryRights.DeleteChild)) { + return true; + } + } + + return false; + } + + private async Task IsExchangeTrustee(string principalSid, string objectDomain) { + if (string.IsNullOrWhiteSpace(principalSid) || string.IsNullOrWhiteSpace(objectDomain)) { + return false; + } + + if (_exchangeTrusteeSidCache.TryGetValue(objectDomain, out var cachedSids)) { + return cachedSids.Contains(principalSid, StringComparer.OrdinalIgnoreCase); + } + + // Well-known principals never match the Exchange groups we are suppressing. + if (WellKnownPrincipal.GetWellKnownPrincipal(principalSid, out _)) { + return false; + } + + // Resolve the small fixed set of Exchange trustee names once per domain using the shared name -> ID cache path. + var resolvedSids = new List(); + foreach (var trusteeName in ExchangeTrusteeNames) { + if (await _utils.ResolveAccountName(trusteeName, objectDomain) is (true, var principal) && + !string.IsNullOrWhiteSpace(principal.ObjectIdentifier)) { + resolvedSids.Add(principal.ObjectIdentifier); + } + } + + var exchangeTrusteeSids = resolvedSids.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + _exchangeTrusteeSidCache.TryAdd(objectDomain, exchangeTrusteeSids); + return exchangeTrusteeSids.Contains(principalSid, StringComparer.OrdinalIgnoreCase); + } + + private static string SerializeAceToSddl(GenericAce ace) { + // Rehydrate the ACE inside a one-entry DACL and let the framework emit the canonical ACE SDDL for us. + var acl = new RawAcl(ace is ObjectAce ? GenericAcl.AclRevisionDS : GenericAcl.AclRevision, 1); + acl.InsertAce(0, CloneAce(ace)); + + var descriptor = new RawSecurityDescriptor(ControlFlags.DiscretionaryAclPresent, null, null, null, acl); + var sddl = descriptor.GetSddlForm(AccessControlSections.Access); + + return sddl.StartsWith("D:", StringComparison.OrdinalIgnoreCase) ? sddl.Substring(2) : sddl; + } + + private static GenericAce CloneAce(GenericAce ace) { + var buffer = new byte[ace.BinaryLength]; + ace.GetBinaryForm(buffer, 0); + return GenericAce.CreateFromBinaryForm(buffer, 0); + } + /// /// Helper function to use commonlib types and pass to ProcessGMSAReaders diff --git a/src/CommonLib/Processors/LdapPropertyProcessor.cs b/src/CommonLib/Processors/LdapPropertyProcessor.cs index 14cd0e6f4..11e11f84e 100644 --- a/src/CommonLib/Processors/LdapPropertyProcessor.cs +++ b/src/CommonLib/Processors/LdapPropertyProcessor.cs @@ -8,6 +8,7 @@ using System.Security.Principal; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using SharpHoundCommonLib.DirectoryObjects; using SharpHoundCommonLib.Enums; using SharpHoundCommonLib.LDAPQueries; using SharpHoundCommonLib.OutputTypes; @@ -37,10 +38,12 @@ static LdapPropertyProcessor() { private readonly ILdapUtils _utils; private readonly ILogger _log; + private readonly ACLProcessor _aclProcessor; public LdapPropertyProcessor(ILdapUtils utils, ILogger log = null) { _utils = utils; _log = log ?? Logging.LogProvider.CreateLogger(nameof(LdapPropertyProcessor)); + _aclProcessor = new ACLProcessor(utils, _log); } private static Dictionary GetCommonProps(IDirectoryObject entry) { @@ -78,6 +81,7 @@ private static Dictionary GetCommonProps(IDirectoryObject entry) /// public async Task> ReadDomainProperties(IDirectoryObject entry, string domain) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, domain, Label.Domain); if (entry.TryGetProperty(LDAPProperties.ExpirePasswordsOnSmartCardOnlyAccounts, out var expirePassword) && bool.TryParse(expirePassword, out var expirePasswordBool)) { @@ -178,8 +182,9 @@ public static string FunctionalLevelToString(int level) { /// /// /// - public static Dictionary ReadGPOProperties(IDirectoryObject entry) { + public async Task> ReadGPOProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.GPO); entry.TryGetProperty(LDAPProperties.GPCFileSYSPath, out var path); props.Add("gpcpath", path.ToUpper()); entry.TryGetProperty(LDAPProperties.Flags, out var flags); @@ -192,8 +197,9 @@ public static Dictionary ReadGPOProperties(IDirectoryObject entr /// /// /// - public static Dictionary ReadOUProperties(IDirectoryObject entry) { + public async Task> ReadOUProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.OU); return props; } @@ -212,6 +218,7 @@ public async Task ReadGroupPropertiesAsync(IDirectoryObject ent { var groupProperties = new GroupProperties(); var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, domain, Label.Group); entry.TryGetLongProperty(LDAPProperties.AdminCount, out var ac); props.Add("admincount", ac != 0); entry.TryGetLongProperty(LDAPProperties.GroupType, out var groupType); @@ -230,8 +237,10 @@ public async Task ReadGroupPropertiesAsync(IDirectoryObject ent /// /// /// - public static Dictionary ReadContainerProperties(IDirectoryObject entry) { + public async Task> ReadContainerProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + var objectType = entry.GetLabel(out var label) ? label : Label.Container; + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), objectType); return props; } @@ -249,6 +258,7 @@ public Task public async Task ReadUserProperties(IDirectoryObject entry, string domain) { var userProps = new UserProperties(); var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, domain, Label.User); if (entry.TryGetLongProperty(LDAPProperties.UserAccountControl, out var uac)) { var uacFlags = (UacFlags)uac; @@ -364,6 +374,7 @@ public Task ReadComputerProperties(IDirectoryObject entry, public async Task ReadComputerProperties(IDirectoryObject entry, string domain) { var compProps = new ComputerProperties(); var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, domain, Label.Computer); var flags = (UacFlags)0; if (entry.TryGetLongProperty(LDAPProperties.UserAccountControl, out var uac)) { @@ -468,8 +479,9 @@ await SendComputerStatus(new CSVComputerStatus { /// /// /// Returns a dictionary with the common properties of the RootCA - public static Dictionary ReadRootCAProperties(IDirectoryObject entry) { + public async Task> ReadRootCAProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.RootCA); // Certificate if (entry.TryGetByteProperty(LDAPProperties.CACertificate, out var rawCertificate) && HasBytes(rawCertificate)) { @@ -489,8 +501,9 @@ public static Dictionary ReadRootCAProperties(IDirectoryObject e /// /// /// Returns a dictionary with the common properties and the crosscertificatepair property of the AICA - public static Dictionary ReadAIACAProperties(IDirectoryObject entry) { + public async Task> ReadAIACAProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.AIACA); entry.TryGetByteArrayProperty(LDAPProperties.CrossCertificatePair, out var crossCertificatePair); var hasCrossCertificatePair = crossCertificatePair.Length > 0; @@ -515,8 +528,9 @@ public static Dictionary ReadAIACAProperties(IDirectoryObject en /// /// /// Returns a dictionary with the common properties and the caname, hostname, and flags properties of the EnterpriseCA - public static Dictionary ReadEnterpriseCAProperties(IDirectoryObject entry) { + public async Task> ReadEnterpriseCAProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.EnterpriseCA); if (entry.TryGetLongProperty("flags", out var flags)) props.Add("flags", (PKICertificateAuthorityFlags)flags); props.Add("caname", entry.GetProperty(LDAPProperties.Name)); @@ -540,8 +554,9 @@ public static Dictionary ReadEnterpriseCAProperties(IDirectoryOb /// /// /// Returns a dictionary with the common properties of the NTAuthStore - public static Dictionary ReadNTAuthStoreProperties(IDirectoryObject entry) { + public async Task> ReadNTAuthStoreProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.NTAuthStore); return props; } @@ -550,8 +565,9 @@ public static Dictionary ReadNTAuthStoreProperties(IDirectoryObj /// /// /// Returns a dictionary associated with the CertTemplate properties that were read - public static Dictionary ReadCertTemplateProperties(IDirectoryObject entry) { + public async Task> ReadCertTemplateProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.CertTemplate); props.Add("validityperiod", ConvertPKIPeriod(entry.GetByteProperty(LDAPProperties.PKIExpirationPeriod))); props.Add("renewalperiod", ConvertPKIPeriod(entry.GetByteProperty(LDAPProperties.PKIOverlappedPeriod))); @@ -636,6 +652,7 @@ public static Dictionary ReadCertTemplateProperties(IDirectoryOb public async Task ReadIssuancePolicyProperties(IDirectoryObject entry) { var ret = new IssuancePolicyProperties(); var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.IssuancePolicy); props.Add("displayname", entry.GetProperty(LDAPProperties.DisplayName)); props.Add("certtemplateoid", entry.GetProperty(LDAPProperties.CertTemplateOID)); @@ -650,6 +667,30 @@ public async Task ReadIssuancePolicyProperties(IDirect return ret; } + private async Task AddCustomDenyAceProperty(Dictionary props, IDirectoryObject entry, + string domain, Label objectType) { + if (_utils.SkipDenyAces) { + return; + } + + if (!entry.TryGetByteProperty(LDAPProperties.SecurityDescriptor, out var ntSecurityDescriptor)) { + return; + } + + var distinguishedName = entry.TryGetDistinguishedName(out var dn) ? dn : string.Empty; + var objectName = entry.TryGetProperty(LDAPProperties.SAMAccountName, out var samAccountName) + ? samAccountName + : distinguishedName; + await _aclProcessor.AddCustomDenyAcesProperty(props, ntSecurityDescriptor, domain, objectType, + distinguishedName, entry.IsMSA() || entry.IsGMSA(), objectName); + } + + private static string GetEntryDomain(IDirectoryObject entry) { + return entry.TryGetDistinguishedName(out var distinguishedName) + ? Helpers.DistinguishedNameToDomain(distinguishedName) + : string.Empty; + } + /// /// Attempts to parse all LDAP attributes outside of the ones already collected and converts them to a human readable /// format using a best guess diff --git a/src/CommonLib/WellKnownPrincipal.cs b/src/CommonLib/WellKnownPrincipal.cs index 4185dc8bb..5c8f661ea 100644 --- a/src/CommonLib/WellKnownPrincipal.cs +++ b/src/CommonLib/WellKnownPrincipal.cs @@ -5,6 +5,8 @@ namespace SharpHoundCommonLib { public static class WellKnownPrincipal { + public const string EveryoneSid = "S-1-1-0"; + /// /// Gets the principal associated with a well known SID /// @@ -18,7 +20,7 @@ public static bool GetWellKnownPrincipal(string sid, out TypedPrincipal commonPr "S-1-0" => new TypedPrincipal("Null Authority", Label.User), "S-1-0-0" => new TypedPrincipal("Nobody", Label.User), "S-1-1" => new TypedPrincipal("World Authority", Label.User), - "S-1-1-0" => new TypedPrincipal("Everyone", Label.Group), + EveryoneSid => new TypedPrincipal("Everyone", Label.Group), "S-1-2" => new TypedPrincipal("Local Authority", Label.User), "S-1-2-0" => new TypedPrincipal("Local", Label.Group), "S-1-2-1" => new TypedPrincipal("Console Logon", Label.Group), diff --git a/test/unit/ACLProcessorTest.cs b/test/unit/ACLProcessorTest.cs index f6907fd34..40f1f2b18 100644 --- a/test/unit/ACLProcessorTest.cs +++ b/test/unit/ACLProcessorTest.cs @@ -2,9 +2,11 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.DirectoryServices; +using System.Collections; using System.Linq; using System.Runtime.Versioning; using System.Security.AccessControl; +using System.Security.Principal; using System.Threading; using System.Threading.Tasks; using CommonLibTest.Facades; @@ -12,6 +14,7 @@ using Newtonsoft.Json; using SharpHoundCommonLib; using SharpHoundCommonLib.Enums; +using SharpHoundCommonLib.LDAPQueries; using SharpHoundCommonLib.OutputTypes; using SharpHoundCommonLib.Processors; using Xunit; @@ -2135,5 +2138,170 @@ public async Task ACLProcessor_ProcessACL_EnterpriseCA_Enroll() Assert.False(actual.IsInherited); Assert.Equal(actual.RightName, expectedRightName); } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_EmitsQualifyingDenyAce() { + var ace = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2500", + ActiveDirectoryRights.Delete); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + Assert.Single(result); + Assert.Equal(SerializeAce(ace), result[0]); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_SkipsExchangeTrusteeDenyAce() { + var ace = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2600", + ActiveDirectoryRights.Delete); + var processor = CreateCustomDenyAceProcessor(("S-1-5-21-3130019616-2776909439-2417379446-2600", + "Exchange Windows Permissions")); + + var result = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + Assert.Empty(result); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_SkipsOrganizationManagementDenyAce() { + var ace = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2601", + ActiveDirectoryRights.Delete); + var processor = CreateCustomDenyAceProcessor(("S-1-5-21-3130019616-2776909439-2417379446-2601", + "Organization Management")); + + var result = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + Assert.Empty(result); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_SkipsExchangeConfigurationPath() { + var ace = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2700", + ActiveDirectoryRights.Delete); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.Container, + "CN=Mailbox Database,CN=Microsoft Exchange,CN=Services,CN=Configuration,DC=TESTLAB,DC=LOCAL"); + + Assert.Empty(result); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_SkipsAccidentalDeletionProtection() { + var ace = CreateCommonDenyAce("S-1-1-0", + ActiveDirectoryRights.Delete | ActiveDirectoryRights.DeleteTree); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.OU, "OU=TEST,DC=TESTLAB,DC=LOCAL"); + + Assert.Empty(result); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_SkipsDefaultAdDenyPatterns() { + var msaAce = CreateObjectDenyAce("S-1-1-0", ActiveDirectoryRights.ExtendedRight, + new Guid(ACEGuids.UserForceChangePassword)); + var domainAce = CreateCommonDenyAce("S-1-1-0", ActiveDirectoryRights.DeleteChild); + var processor = CreateCustomDenyAceProcessor(); + + var msaResult = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(msaAce), _testDomainName, + Label.User, "CN=TEST MSA,CN=Managed Service Accounts,DC=TESTLAB,DC=LOCAL", true); + var domainResult = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(domainAce), + _testDomainName, Label.Domain, "DC=TESTLAB,DC=LOCAL"); + + Assert.Empty(msaResult); + Assert.Empty(domainResult); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_EmitsMultipleQualifyingAces() { + var ace1 = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2800", + ActiveDirectoryRights.Delete); + var ace2 = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2801", + ActiveDirectoryRights.DeleteChild); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace1, ace2), _testDomainName, + Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + Assert.Equal(2, result.Length); + Assert.Equal(SerializeAce(ace1), result[0]); + Assert.Equal(SerializeAce(ace2), result[1]); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_PreservesDeterministicOrdering() { + var ace1 = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2901", + ActiveDirectoryRights.DeleteChild); + var ace2 = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2900", + ActiveDirectoryRights.Delete); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace1, ace2), _testDomainName, + Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + Assert.Equal(new[] { SerializeAce(ace1), SerializeAce(ace2) }, result); + } + + [Fact] + public async Task ACLProcessor_AddCustomDenyAcesProperty_DoesNotEmitWhenEmpty() { + var props = new Dictionary(); + var ace = CreateCommonDenyAce("S-1-1-0", + ActiveDirectoryRights.Delete | ActiveDirectoryRights.DeleteTree); + var processor = CreateCustomDenyAceProcessor(); + + await processor.AddCustomDenyAcesProperty(props, CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.OU, "OU=TEST,DC=TESTLAB,DC=LOCAL"); + + Assert.DoesNotContain("customdenyaces", props.Keys); + } + + private ACLProcessor CreateCustomDenyAceProcessor(params (string Sid, string Name)[] principals) { + var mockLdapUtils = new Mock(MockBehavior.Strict); + mockLdapUtils.Setup(x => x.ResolveAccountName(It.IsAny(), It.IsAny())) + .ReturnsAsync((string name, string _) => { + var match = principals.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + return string.IsNullOrWhiteSpace(match.Sid) + ? (false, null) + : (true, new TypedPrincipal(match.Sid, Label.Group)); + }); + + return new ACLProcessor(mockLdapUtils.Object); + } + + private static byte[] CreateSecurityDescriptorBytes(params GenericAce[] aces) { + var acl = new RawAcl(GenericAcl.AclRevisionDS, aces.Length); + for (var i = 0; i < aces.Length; i++) { + acl.InsertAce(i, aces[i]); + } + + var descriptor = new RawSecurityDescriptor(ControlFlags.DiscretionaryAclPresent, null, null, null, acl); + var buffer = new byte[descriptor.BinaryLength]; + descriptor.GetBinaryForm(buffer, 0); + return buffer; + } + + private static CommonAce CreateCommonDenyAce(string sid, ActiveDirectoryRights rights) { + return new CommonAce(AceFlags.None, AceQualifier.AccessDenied, (int)rights, + new SecurityIdentifier(sid), false, null); + } + + private static ObjectAce CreateObjectDenyAce(string sid, ActiveDirectoryRights rights, Guid objectType) { + return new ObjectAce(AceFlags.None, AceQualifier.AccessDenied, (int)rights, + new SecurityIdentifier(sid), ObjectAceFlags.ObjectAceTypePresent, objectType, Guid.Empty, false, null); + } + + private static string SerializeAce(GenericAce ace) { + var acl = new RawAcl(GenericAcl.AclRevisionDS, 1); + acl.InsertAce(0, ace); + var descriptor = new RawSecurityDescriptor(ControlFlags.DiscretionaryAclPresent, null, null, null, acl); + return descriptor.GetSddlForm(AccessControlSections.Access).Substring(2); + } } -} \ No newline at end of file +} diff --git a/test/unit/Facades/MockLdapUtils.cs b/test/unit/Facades/MockLdapUtils.cs index 1e5f1194d..efe0c0518 100644 --- a/test/unit/Facades/MockLdapUtils.cs +++ b/test/unit/Facades/MockLdapUtils.cs @@ -20,6 +20,7 @@ namespace CommonLibTest.Facades [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] public class MockLdapUtils : ILdapUtils { + private LdapConfig _ldapConfig = new(); private readonly ConcurrentDictionary _domainControllers = new(); private readonly Forest _forest; private readonly ConcurrentDictionary _seenWellKnownPrincipals = new(); @@ -1007,9 +1008,11 @@ public IAsyncEnumerable GetWellKnownPrincipalOutput() { } public void SetLdapConfig(LdapConfig config) { - throw new NotImplementedException(); + _ldapConfig = config; } + public bool SkipDenyAces => _ldapConfig.SkipDenyAces; + public Task<(bool Success, string Message)> TestLdapConnection(string domain) { throw new NotImplementedException(); } @@ -1127,4 +1130,4 @@ public void Dispose() { return (true, "0"); } } -} \ No newline at end of file +} diff --git a/test/unit/LdapPropertyTests.cs b/test/unit/LdapPropertyTests.cs index 6fe094509..2f8372f16 100644 --- a/test/unit/LdapPropertyTests.cs +++ b/test/unit/LdapPropertyTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.DirectoryServices; using System.Runtime.Versioning; +using System.Security.AccessControl; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Security.Principal; @@ -30,6 +31,21 @@ public LdapPropertyTests(ITestOutputHelper testOutputHelper) _testOutputHelper = testOutputHelper; } + [SupportedOSPlatform("windows")] + private static byte[] CreateSecurityDescriptorBytes(params GenericAce[] aces) + { + var acl = new RawAcl(GenericAcl.AclRevisionDS, aces.Length); + for (var i = 0; i < aces.Length; i++) + { + acl.InsertAce(i, aces[i]); + } + + var descriptor = new RawSecurityDescriptor(ControlFlags.DiscretionaryAclPresent, null, null, null, acl); + var buffer = new byte[descriptor.BinaryLength]; + descriptor.GetBinaryForm(buffer, 0); + return buffer; + } + [Fact] public async void LDAPPropertyProcessor_ReadDomainProperties_TestGoodData() { @@ -82,7 +98,7 @@ public void LDAPPropertyProcessor_FunctionalLevelToString_TestFunctionalLevels() } [Fact] - public void LDAPPropertyProcessor_ReadGPOProperties_TestGoodData() + public async Task LDAPPropertyProcessor_ReadGPOProperties_TestGoodData() { var mock = new MockDirectoryObject( "CN\u003d{94DD0260-38B5-497E-8876-10E7A96E80D0},CN\u003dPolicies,CN\u003dSystem,DC\u003dtestlab,DC\u003dlocal", @@ -96,7 +112,8 @@ public void LDAPPropertyProcessor_ReadGPOProperties_TestGoodData() {"description", "Test"} }, "S-1-5-21-3130019616-2776909439-2417379446",""); - var test = LdapPropertyProcessor.ReadGPOProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadGPOProperties(mock); Assert.Contains("description", test.Keys); Assert.Equal("Test", test["description"] as string); @@ -106,7 +123,7 @@ public void LDAPPropertyProcessor_ReadGPOProperties_TestGoodData() } [Fact] - public void LDAPPropertyProcessor_ReadOUProperties_TestGoodData() + public async Task LDAPPropertyProcessor_ReadOUProperties_TestGoodData() { var mock = new MockDirectoryObject("OU\u003dTestOU,DC\u003dtestlab,DC\u003dlocal", new Dictionary @@ -114,11 +131,39 @@ public void LDAPPropertyProcessor_ReadOUProperties_TestGoodData() {"description", "Test"} },"", "2A374493-816A-4193-BEFD-D2F4132C6DCA"); - var test = LdapPropertyProcessor.ReadOUProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadOUProperties(mock); Assert.Contains("description", test.Keys); Assert.Equal("Test", test["description"] as string); } + [SupportedOSPlatform("windows")] + [WindowsOnlyFact] + public async Task LDAPPropertyProcessor_ReadOUProperties_SkipsCustomDenyAces_WhenLdapConfigRequestsIt() + { + var denyAce = new CommonAce(AceFlags.None, AceQualifier.AccessDenied, (int)ActiveDirectoryRights.Delete, + new SecurityIdentifier("S-1-5-21-3130019616-2776909439-2417379446-2500"), false, null); + var mock = new MockDirectoryObject("OU\u003dTestOU,DC\u003dtestlab,DC\u003dlocal", + new Dictionary + { + {LDAPProperties.SecurityDescriptor, CreateSecurityDescriptorBytes(denyAce)} + }, "", "2A374493-816A-4193-BEFD-D2F4132C6DCA"); + + var baselineProcessor = new LdapPropertyProcessor(new MockLdapUtils()); + var baseline = await baselineProcessor.ReadOUProperties(mock); + Assert.Contains("customdenyaces", baseline.Keys); + + var ldapUtils = new MockLdapUtils(); + ldapUtils.SetLdapConfig(new LdapConfig { + SkipDenyAces = true + }); + + var processor = new LdapPropertyProcessor(ldapUtils); + var test = await processor.ReadOUProperties(mock); + + Assert.DoesNotContain("customdenyaces", test.Keys); + } + [Fact] public async Task LDAPPropertyProcessor_ReadGroupProperties_TestGoodData() { @@ -681,7 +726,7 @@ public async Task LDAPPropertyProcessor_ReadComputerProperties_TestDumpSMSAPassw } [Fact] - public void LDAPPropertyProcessor_ReadRootCAProperties() { + public async Task LDAPPropertyProcessor_ReadRootCAProperties() { var ecdsa = ECDsa.Create(); var req = new CertificateRequest("cn=foobar", ecdsa, HashAlgorithmName.SHA256); var cert = req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); @@ -699,7 +744,8 @@ public void LDAPPropertyProcessor_ReadRootCAProperties() { {LDAPProperties.CACertificate, bytes} }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadRootCAProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadRootCAProperties(mock); var keys = test.Keys; //These are not common properties @@ -718,7 +764,7 @@ public void LDAPPropertyProcessor_ReadRootCAProperties() { [Theory] [MemberData(nameof(EmptyCertBytes))] - public void LDAPPropertyProcessor_ReadRootCAProperties_NoCACertificate(byte[] CACertBytes) { + public async Task LDAPPropertyProcessor_ReadRootCAProperties_NoCACertificate(byte[] CACertBytes) { var mock = new MockDirectoryObject( "CN\u003dDUMPSTER-DC01-CA,CN\u003dAIA,CN\u003dPUBLIC KEY SERVICES,CN\u003dSERVICES,CN\u003dCONFIGURATION,DC\u003dDUMPSTER,DC\u003dFIRE", new Dictionary @@ -731,7 +777,8 @@ public void LDAPPropertyProcessor_ReadRootCAProperties_NoCACertificate(byte[] CA {LDAPProperties.CACertificate, CACertBytes} }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadRootCAProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadRootCAProperties(mock); var keys = test.Keys; //These are cert derived properties @@ -745,7 +792,7 @@ public void LDAPPropertyProcessor_ReadRootCAProperties_NoCACertificate(byte[] CA } [Fact] - public void LDAPPropertyProcessor_ReadAIACAProperties() { + public async Task LDAPPropertyProcessor_ReadAIACAProperties() { var ecdsa = ECDsa.Create(); var req = new CertificateRequest("cn=foobar", ecdsa, HashAlgorithmName.SHA256); var cert = req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); @@ -764,7 +811,8 @@ public void LDAPPropertyProcessor_ReadAIACAProperties() { {LDAPProperties.CACertificate, bytes} }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadAIACAProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadAIACAProperties(mock); var keys = test.Keys; //These are not common properties @@ -787,7 +835,7 @@ public void LDAPPropertyProcessor_ReadAIACAProperties() { [Theory] [MemberData(nameof(EmptyCertBytes))] - public void LDAPPropertyProcessor_ReadAIACAProperties_NoCACertificate(byte[] CACertBytes) { + public async Task LDAPPropertyProcessor_ReadAIACAProperties_NoCACertificate(byte[] CACertBytes) { var mock = new MockDirectoryObject( "CN\u003dDUMPSTER-DC01-CA,CN\u003dAIA,CN\u003dPUBLIC KEY SERVICES,CN\u003dSERVICES,CN\u003dCONFIGURATION,DC\u003dDUMPSTER,DC\u003dFIRE", new Dictionary @@ -801,7 +849,8 @@ public void LDAPPropertyProcessor_ReadAIACAProperties_NoCACertificate(byte[] CAC {LDAPProperties.CACertificate, CACertBytes} }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadAIACAProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadAIACAProperties(mock); var keys = test.Keys; //These are cert derived properties @@ -817,7 +866,7 @@ public void LDAPPropertyProcessor_ReadAIACAProperties_NoCACertificate(byte[] CAC } [Fact] - public void LDAPPropertyProcessor_ReadEnterpriseCAProperties() { + public async Task LDAPPropertyProcessor_ReadEnterpriseCAProperties() { var ecdsa = ECDsa.Create(); var req = new CertificateRequest("cn=foobar", ecdsa, HashAlgorithmName.SHA256); var cert = req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); @@ -836,7 +885,8 @@ public void LDAPPropertyProcessor_ReadEnterpriseCAProperties() { {"flags", 1} }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadEnterpriseCAProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadEnterpriseCAProperties(mock); var keys = test.Keys; //These are not common properties @@ -860,7 +910,7 @@ public void LDAPPropertyProcessor_ReadEnterpriseCAProperties() { [Theory] [MemberData(nameof(EmptyCertBytes))] - public void LDAPPropertyProcessor_ReadEnterpriseCAProperties_NoCACertificate(byte[] CACertBytes) { + public async Task LDAPPropertyProcessor_ReadEnterpriseCAProperties_NoCACertificate(byte[] CACertBytes) { var mock = new MockDirectoryObject( "CN\u003dDUMPSTER-DC01-CA,CN\u003dAIA,CN\u003dPUBLIC KEY SERVICES,CN\u003dSERVICES,CN\u003dCONFIGURATION,DC\u003dDUMPSTER,DC\u003dFIRE", new Dictionary @@ -874,7 +924,8 @@ public void LDAPPropertyProcessor_ReadEnterpriseCAProperties_NoCACertificate(byt {"flags", 1} }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadEnterpriseCAProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadEnterpriseCAProperties(mock); var keys = test.Keys; //These are cert derived properties @@ -899,7 +950,7 @@ public void LDAPPropertyProcessor_ReadEnterpriseCAProperties_NoCACertificate(byt }; [Fact] - public void LDAPPropertyProcessor_ReadNTAuthStoreProperties() + public async Task LDAPPropertyProcessor_ReadNTAuthStoreProperties() { var mock = new MockDirectoryObject("CN\u003dNTAUTHCERTIFICATES,CN\u003dPUBLIC KEY SERVICES,CN\u003dSERVICES,CN\u003dCONFIGURATION,DC\u003dDUMPSTER,DC\u003dFIRE", new Dictionary @@ -911,7 +962,8 @@ public void LDAPPropertyProcessor_ReadNTAuthStoreProperties() {"whencreated", 1683986131}, }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadNTAuthStoreProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadNTAuthStoreProperties(mock); var keys = test.Keys; //These are not common properties @@ -923,7 +975,7 @@ public void LDAPPropertyProcessor_ReadNTAuthStoreProperties() } [Fact] - public void LDAPPropertyProcessor_ReadCertTemplateProperties() + public async Task LDAPPropertyProcessor_ReadCertTemplateProperties() { var mock = new MockDirectoryObject("CN\u003dWORKSTATION,CN\u003dCERTIFICATE TEMPLATES,CN\u003dPUBLIC KEY SERVICES,CN\u003dSERVICES,CN\u003dCONFIGURATION,DC\u003dEXTERNAL,DC\u003dLOCAL", new Dictionary @@ -961,7 +1013,8 @@ public void LDAPPropertyProcessor_ReadCertTemplateProperties() {LDAPProperties.PKIPrivateKeyFlag, 256}, }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadCertTemplateProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadCertTemplateProperties(mock); var keys = test.Keys; //These are not common properties