Skip to content
Draft
Empty file added .codex
Empty file.
1 change: 1 addition & 0 deletions docfx/Docfx.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<IsPackable>false</IsPackable>
<BuildDocFx Condition="'$(BuildDocFx)' == '' and '$(OS)' != 'Windows_NT'">false</BuildDocFx>
<PreviewOutputFolder>$([MSBuild]::NormalizeDirectory($(MSBuildThisFileDirectory), `..`, `docs`))</PreviewOutputFolder>
<PreviewPort Condition=" '$(PreviewPort)' == '' ">8002</PreviewPort>
<LogFile>$(MSBuildThisFileDirectory)docfx.log</LogFile>
Expand Down
3 changes: 2 additions & 1 deletion src/CommonLib/Enums/DirectoryPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
}
2 changes: 1 addition & 1 deletion src/CommonLib/ILdapUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,4 @@ IAsyncEnumerable<Result<string>> RangedRetrieval(string distinguishedName,
/// </summary>
void ResetUtils();
}
}
}
2 changes: 1 addition & 1 deletion src/CommonLib/LdapConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,4 @@ public override string ToString() {
return sb.ToString();
}
}
}
}
5 changes: 3 additions & 2 deletions src/CommonLib/LdapUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this change for?

(sidValue == WellKnownPrincipal.EveryoneSid || sidValue == "S-1-5-11")) {
return await GetWellKnownPrincipal(sid.Value, computerDomain);
}

Expand Down Expand Up @@ -1418,4 +1419,4 @@ private static string ComputeDisplayName(IDirectoryObject directoryObject, strin
return displayName.ToUpper();
}
}
}
}
256 changes: 254 additions & 2 deletions src/CommonLib/Processors/ACLProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Microsoft.Extensions.Logging;
using SharpHoundCommonLib.DirectoryObjects;
using SharpHoundCommonLib.Enums;
using SharpHoundCommonLib.LDAPQueries;
using SharpHoundCommonLib.OutputTypes;
using System.Linq;

Expand All @@ -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<string, string[]> _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<string> 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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -431,6 +478,24 @@ public IAsyncEnumerable<ACE> ProcessACL(ResolvedSearchResult result, IDirectoryO
return ProcessACL(descriptor, result.Domain, result.ObjectType, searchResult.HasLAPS(), checkForOwnerRights, result.DisplayName);
}

/// <summary>
/// Processes the regular ACL edges and custom deny ACE counts in one ACL traversal.
/// </summary>
/// <remarks>
/// Callers that do not want custom deny ACE counts should continue to call <see cref="ProcessACL(ResolvedSearchResult, IDirectoryObject, bool)"/>.
/// </remarks>
public Task<ACLProcessingResult> ProcessACLWithCustomDenyAces(ResolvedSearchResult result,
IDirectoryObject searchResult, bool checkForOwnerRights = true) {
if (!searchResult.TryGetByteProperty(LDAPProperties.SecurityDescriptor, out var descriptor)) {
return Task.FromResult(new ACLProcessingResult(Array.Empty<ACE>(), new CustomDenyAceCounts()));
}

searchResult.TryGetDistinguishedName(out var distinguishedName);
return ProcessACLWithCustomDenyAces(descriptor, result.Domain, result.ObjectType, searchResult.HasLAPS(),
checkForOwnerRights, distinguishedName, searchResult.IsMSA() || searchResult.IsGMSA(),
result.DisplayName);
}

/// <summary>
/// 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
Expand All @@ -447,8 +512,25 @@ public IAsyncEnumerable<ACE> ProcessACL(byte[] ntSecurityDescriptor, string obje
return ProcessACL(ntSecurityDescriptor, objectDomain, objectType, hasLaps, true, objectName);
}

public async IAsyncEnumerable<ACE> ProcessACL(byte[] ntSecurityDescriptor, string objectDomain,
public IAsyncEnumerable<ACE> ProcessACL(byte[] ntSecurityDescriptor, string objectDomain,
Label objectType, bool hasLaps, bool checkForOwnerRights, string objectName) {
return ProcessACLInternal(ntSecurityDescriptor, objectDomain, objectType, hasLaps, checkForOwnerRights,
objectName);
}

public async Task<ACLProcessingResult> 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<ACE> 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) {
Expand Down Expand Up @@ -496,7 +578,19 @@ public async IAsyncEnumerable<ACE> 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;
}

Expand Down Expand Up @@ -881,6 +975,164 @@ or Label.NTAuthStore
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would introduce a new function here that processes deny aces at the same time as the regular ACL data, that way we can do both in one call and then we can prioritize that call as the "canonical" version.

public Task<CustomDenyAceCounts> GetCustomDenyAceCounts(ResolvedSearchResult result,
IDirectoryObject searchResult) {
if (!searchResult.TryGetByteProperty(LDAPProperties.SecurityDescriptor, out var descriptor)) {
return Task.FromResult(new CustomDenyAceCounts());
}

searchResult.TryGetDistinguishedName(out var distinguishedName);
return GetCustomDenyAceCounts(
descriptor,
result.Domain,
result.ObjectType,
distinguishedName,
searchResult.IsMSA() || searchResult.IsGMSA(),
result.DisplayName);
}

public async Task<CustomDenyAceCounts> GetCustomDenyAceCounts(byte[] ntSecurityDescriptor,
string objectDomain, Label objectType, string distinguishedName = null, bool isMSA = false,
string objectName = "") {
if (ntSecurityDescriptor == null) {
return new CustomDenyAceCounts();
}

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 new CustomDenyAceCounts();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (descriptor.DiscretionaryAcl == null || descriptor.DiscretionaryAcl.Count == 0) {
return new CustomDenyAceCounts();
}

var explicitCount = 0;
var inheritedCount = 0;

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;
}

if ((ace.AceFlags & AceFlags.Inherited) == AceFlags.Inherited) {
inheritedCount++;
} else {
explicitCount++;
}
}

return new CustomDenyAceCounts(explicitCount, inheritedCount);
}

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 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<bool> 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 == (ActiveDirectoryRights.Delete | 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;
}
Comment thread
JonasBK marked this conversation as resolved.
}

return false;
}

private async Task<bool> 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<string>();
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);
}

/// <summary>
/// Helper function to use commonlib types and pass to ProcessGMSAReaders
Expand Down
4 changes: 3 additions & 1 deletion src/CommonLib/WellKnownPrincipal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ namespace SharpHoundCommonLib
{
public static class WellKnownPrincipal
{
public const string EveryoneSid = "S-1-1-0";

/// <summary>
/// Gets the principal associated with a well known SID
/// </summary>
Expand All @@ -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),
Expand Down
Loading
Loading