diff --git a/.gitignore b/.gitignore index 6e28db82c..6a47cda64 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ coverage.* logs/ *.log +# Codex +.codex + ################ # Boilerplate Settings (modify as needed) # (https://github.com/github/gitignore) 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..9ddba7c55 100644 --- a/src/CommonLib/ILdapUtils.cs +++ b/src/CommonLib/ILdapUtils.cs @@ -175,4 +175,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..745a19b6e 100644 --- a/src/CommonLib/LdapConfig.cs +++ b/src/CommonLib/LdapConfig.cs @@ -53,4 +53,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 0745af9e8..f637130ea 100644 --- a/src/CommonLib/LdapUtils.cs +++ b/src/CommonLib/LdapUtils.cs @@ -881,7 +881,7 @@ 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 WellKnownPrincipal.EveryoneSid or "S-1-5-11") { return await GetWellKnownPrincipal(sid.Value, computerDomain); } @@ -1418,4 +1418,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 119a66791..53e346ab0 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 @@ -48,6 +57,44 @@ public ACLProcessor(ILdapUtils utils, ILogger log = null) _log = log ?? Logging.LogProvider.CreateLogger("ACLProc"); } + public readonly struct CustomDenyAceCounts { + public CustomDenyAceCounts(int explicitCount, int inheritedCount) { + ExplicitCount = explicitCount; + InheritedCount = inheritedCount; + } + + public int ExplicitCount { get; } + public int InheritedCount { get; } + public int Total => ExplicitCount + InheritedCount; + } + + public sealed class ACLProcessingResult { + public ACLProcessingResult(ACE[] aces, CustomDenyAceCounts customDenyAceCounts) { + Aces = aces; + CustomDenyAceCounts = customDenyAceCounts; + } + + public ACE[] Aces { get; } + public CustomDenyAceCounts CustomDenyAceCounts { get; } + } + + private sealed class CustomDenyAceAccumulator { + private int _explicitCount; + private int _inheritedCount; + + public void Add(bool inherited) { + if (inherited) { + _inheritedCount++; + } else { + _explicitCount++; + } + } + + public CustomDenyAceCounts ToCounts() { + return new CustomDenyAceCounts(_explicitCount, _inheritedCount); + } + } + /// Represents a lightweight Access Control Entry (ACE) used to compute hash values /// for AdminSDHolder purposes internal class ACEForHashing { @@ -431,6 +478,24 @@ public IAsyncEnumerable ProcessACL(ResolvedSearchResult result, IDirectoryO return ProcessACL(descriptor, result.Domain, result.ObjectType, searchResult.HasLAPS(), checkForOwnerRights, result.DisplayName); } + /// + /// Processes the regular ACL edges and custom deny ACE counts in one ACL traversal. + /// + /// + /// Callers that do not want custom deny ACE counts should continue to call . + /// + public Task ProcessACLWithCustomDenyAces(ResolvedSearchResult result, + IDirectoryObject searchResult, bool checkForOwnerRights = true) { + if (!searchResult.TryGetByteProperty(LDAPProperties.SecurityDescriptor, out var descriptor)) { + return Task.FromResult(new ACLProcessingResult(Array.Empty(), new CustomDenyAceCounts())); + } + + searchResult.TryGetDistinguishedName(out var distinguishedName); + return ProcessACLWithCustomDenyAces(descriptor, result.Domain, result.ObjectType, searchResult.HasLAPS(), + checkForOwnerRights, distinguishedName, searchResult.IsMSA() || searchResult.IsGMSA(), + result.DisplayName); + } + /// /// Read's a raw ntSecurityDescriptor and processes the ACEs in the ACL, filtering out ACEs that /// BloodHound is not interested in as well as principals we don't care about @@ -447,8 +512,25 @@ public IAsyncEnumerable ProcessACL(byte[] ntSecurityDescriptor, string obje return ProcessACL(ntSecurityDescriptor, objectDomain, objectType, hasLaps, true, objectName); } - public async IAsyncEnumerable ProcessACL(byte[] ntSecurityDescriptor, string objectDomain, + public IAsyncEnumerable ProcessACL(byte[] ntSecurityDescriptor, string objectDomain, Label objectType, bool hasLaps, bool checkForOwnerRights, string objectName) { + return ProcessACLInternal(ntSecurityDescriptor, objectDomain, objectType, hasLaps, checkForOwnerRights, + objectName); + } + + public async Task ProcessACLWithCustomDenyAces(byte[] ntSecurityDescriptor, + string objectDomain, Label objectType, bool hasLaps, bool checkForOwnerRights = true, + string distinguishedName = null, bool isMSA = false, string objectName = "") { + var accumulator = new CustomDenyAceAccumulator(); + var aces = await ProcessACLInternal(ntSecurityDescriptor, objectDomain, objectType, hasLaps, + checkForOwnerRights, objectName, accumulator, distinguishedName, isMSA).ToArrayAsync(); + return new ACLProcessingResult(aces, accumulator.ToCounts()); + } + + private async IAsyncEnumerable ProcessACLInternal(byte[] ntSecurityDescriptor, string objectDomain, + Label objectType, bool hasLaps, bool checkForOwnerRights, string objectName, + CustomDenyAceAccumulator customDenyAceAccumulator = null, string distinguishedName = null, + bool isMSA = false) { await BuildGuidCache(objectDomain); if (ntSecurityDescriptor == null) { @@ -496,7 +578,19 @@ public async IAsyncEnumerable ProcessACL(byte[] ntSecurityDescriptor, strin bool isPermissionForOwnerRightsSid = false; bool isInheritedPermissionForOwnerRightsSid = false; - if (ace == null || ace.AccessControlType() == AccessControlType.Deny || !ace.IsAceInheritedFrom(BaseGuids[objectType])) { + if (ace == null) { + continue; + } + + if (ace.AccessControlType() == AccessControlType.Deny) { + if (customDenyAceAccumulator != null) { + await CountCustomDenyAce(ace, customDenyAceAccumulator, objectDomain, objectType, + distinguishedName, isMSA); + } + continue; + } + + if (!ace.IsAceInheritedFrom(BaseGuids[objectType])) { continue; } @@ -902,6 +996,83 @@ or Label.NTAuthStore } } + private async Task CountCustomDenyAce(ActiveDirectoryRuleDescriptor ace, + CustomDenyAceAccumulator accumulator, string objectDomain, Label objectType, string distinguishedName, + bool isMSA) { + var principalSid = ace.IdentityReference(); + if (string.IsNullOrWhiteSpace(principalSid)) { + return; + } + + if (await ShouldExcludeCustomDenyAce(principalSid, ace.ActiveDirectoryRights(), ace.ObjectType(), + objectDomain, objectType, distinguishedName, isMSA)) { + return; + } + + accumulator.Add(ace.IsInherited()); + } + + 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.Domain && rights.Equals(ActiveDirectoryRights.DeleteChild)) { + return true; + } + + if ((objectType is Label.OU or Label.Container) && + rights.Equals(ActiveDirectoryRights.Delete | ActiveDirectoryRights.DeleteTree)) { + return true; + } + + if (isMSA && + rights.Equals(ActiveDirectoryRights.ExtendedRight) && + objectAceType.Equals(new Guid(ACEGuids.UserForceChangePassword))) { + 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); + } /// /// Helper function to use commonlib types and pass to ProcessGMSAReaders 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 a8e4d3b33..8a3a2b5b9 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; @@ -2288,5 +2291,146 @@ public async Task ACLProcessor_ProcessACL_GenericWrite_Computer_WritePublicInfor Assert.False(actual.IsInherited); Assert.Equal(actual.RightName, expectedRightName); } + + [Fact] + public async Task ACLProcessor_ProcessACLWithCustomDenyAces_ReturnsRegularAcesAndDenyCounts() { + var denyRule = CreateRuleDescriptor("S-1-5-21-3130019616-2776909439-2417379446-3100", + AccessControlType.Deny, ActiveDirectoryRights.Delete); + var allowRule = CreateRuleDescriptor("S-1-5-21-3130019616-2776909439-2417379446-3101", + AccessControlType.Allow, ActiveDirectoryRights.WriteDacl); + allowRule.Setup(x => x.IsAceInheritedFrom(It.IsAny())).Returns(true); + + var processor = CreateCombinedAclProcessor(new[] { denyRule.Object, allowRule.Object }); + var result = await processor.ProcessACLWithCustomDenyAces(new byte[] { 1 }, _testDomainName, Label.User, + false); + + Assert.Single(result.Aces); + Assert.Equal(EdgeNames.WriteDacl, result.Aces[0].RightName); + AssertCustomDenyAceCounts(result.CustomDenyAceCounts, 1, 0); + } + + [Fact] + public async Task ACLProcessor_ProcessACLWithCustomDenyAces_DoesNotCountExcludedDenyAces() { + var denyRule = CreateRuleDescriptor(WellKnownPrincipal.EveryoneSid, AccessControlType.Deny, + ActiveDirectoryRights.Delete | ActiveDirectoryRights.DeleteTree); + + var processor = CreateCombinedAclProcessor(new[] { denyRule.Object }); + var result = await processor.ProcessACLWithCustomDenyAces(new byte[] { 1 }, _testDomainName, Label.OU, + false); + + Assert.Empty(result.Aces); + AssertCustomDenyAceCounts(result.CustomDenyAceCounts, 0, 0); + } + + [Fact] + public async Task ACLProcessor_ProcessACLWithCustomDenyAces_CountsAccidentalDeletionProtectionWithAdditionalRights() { + var denyRule = CreateRuleDescriptor(WellKnownPrincipal.EveryoneSid, AccessControlType.Deny, + ActiveDirectoryRights.Delete | ActiveDirectoryRights.DeleteTree | ActiveDirectoryRights.WriteDacl); + + var processor = CreateCombinedAclProcessor(new[] { denyRule.Object }); + var result = await processor.ProcessACLWithCustomDenyAces(new byte[] { 1 }, _testDomainName, Label.OU, + false); + + Assert.Empty(result.Aces); + AssertCustomDenyAceCounts(result.CustomDenyAceCounts, 1, 0); + } + + [Fact] + public async Task ACLProcessor_ProcessACLWithCustomDenyAces_CountsMsaForceChangePasswordDenyWithAdditionalRights() { + var denyRule = CreateRuleDescriptor(WellKnownPrincipal.EveryoneSid, AccessControlType.Deny, + ActiveDirectoryRights.ExtendedRight | ActiveDirectoryRights.WriteDacl, + objectType: new Guid(ACEGuids.UserForceChangePassword)); + + var processor = CreateCombinedAclProcessor(new[] { denyRule.Object }); + var result = await processor.ProcessACLWithCustomDenyAces(new byte[] { 1 }, _testDomainName, Label.User, + false, isMSA: true); + + Assert.Empty(result.Aces); + AssertCustomDenyAceCounts(result.CustomDenyAceCounts, 1, 0); + } + + [Fact] + public async Task ACLProcessor_ProcessACLWithCustomDenyAces_CountsDomainDeleteChildDenyWithAdditionalRights() { + var denyRule = CreateRuleDescriptor(WellKnownPrincipal.EveryoneSid, AccessControlType.Deny, + ActiveDirectoryRights.DeleteChild | ActiveDirectoryRights.WriteDacl); + + var processor = CreateCombinedAclProcessor(new[] { denyRule.Object }); + var result = await processor.ProcessACLWithCustomDenyAces(new byte[] { 1 }, _testDomainName, Label.Domain, + false); + + Assert.Empty(result.Aces); + AssertCustomDenyAceCounts(result.CustomDenyAceCounts, 1, 0); + } + + 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 ACLProcessor CreateCombinedAclProcessor(IEnumerable rules) { + var mockLdapUtils = new Mock(); + var mockSecurityDescriptor = new Mock(MockBehavior.Loose, null); + mockSecurityDescriptor.Setup(x => x.GetOwner(It.IsAny())).Returns((string)null); + mockSecurityDescriptor.Setup(x => x.GetAccessRules(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(rules.ToList()); + mockLdapUtils.Setup(x => x.MakeSecurityDescriptor()).Returns(mockSecurityDescriptor.Object); + mockLdapUtils.Setup(x => x.PagedQuery(It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable.Empty>()); + mockLdapUtils.Setup(x => x.ResolveAccountName(It.IsAny(), It.IsAny())) + .ReturnsAsync((false, null)); + mockLdapUtils.Setup(x => x.ResolveIDAndType(It.IsAny(), It.IsAny())) + .ReturnsAsync((string sid, string _) => (true, new TypedPrincipal(sid, Label.User))); + return new ACLProcessor(mockLdapUtils.Object); + } + + private static Mock CreateRuleDescriptor(string sid, + AccessControlType accessControlType, ActiveDirectoryRights rights, bool inherited = false, + Guid objectType = default) { + var rule = new Mock(MockBehavior.Loose, null); + rule.Setup(x => x.IdentityReference()).Returns(sid); + rule.Setup(x => x.AccessControlType()).Returns(accessControlType); + rule.Setup(x => x.ActiveDirectoryRights()).Returns(rights); + rule.Setup(x => x.ObjectType()).Returns(objectType); + rule.Setup(x => x.IsInherited()).Returns(inherited); + return rule; + } + + private static void AssertCustomDenyAceCounts(ACLProcessor.CustomDenyAceCounts result, + int expectedExplicitCount, int expectedInheritedCount) { + Assert.Equal(expectedExplicitCount, result.ExplicitCount); + Assert.Equal(expectedInheritedCount, result.InheritedCount); + Assert.Equal(expectedExplicitCount + expectedInheritedCount, result.Total); + } + + 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, + AceFlags aceFlags = AceFlags.None) { + return new CommonAce(aceFlags, 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); + } } -} \ No newline at end of file +} diff --git a/test/unit/Facades/MockLdapUtils.cs b/test/unit/Facades/MockLdapUtils.cs index f60608d16..7cc463eaa 100644 --- a/test/unit/Facades/MockLdapUtils.cs +++ b/test/unit/Facades/MockLdapUtils.cs @@ -1127,4 +1127,4 @@ public void Dispose() { return (true, "0"); } } -} \ No newline at end of file +}