Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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";
}
}
}
6 changes: 5 additions & 1 deletion src/CommonLib/ILdapUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ IAsyncEnumerable<Result<string>> RangedRetrieval(string distinguishedName,
/// <param name="config">The new ldap config</param>
void SetLdapConfig(LdapConfig config);
/// <summary>
/// Gets whether custom deny ACE collection is disabled for this utils instance
/// </summary>
bool SkipDenyAces { get; }
/// <summary>
/// Tests if a LDAP connection can be made successfully to a domain
/// </summary>
/// <param name="domain">The domain to test</param>
Expand All @@ -175,4 +179,4 @@ IAsyncEnumerable<Result<string>> RangedRetrieval(string distinguishedName,
/// </summary>
void ResetUtils();
}
}
}
4 changes: 3 additions & 1 deletion src/CommonLib/LdapConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)) {
Expand All @@ -53,4 +55,4 @@ public override string ToString() {
return sb.ToString();
}
}
}
}
7 changes: 5 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 &&
(sidValue == WellKnownPrincipal.EveryoneSid || sidValue == "S-1-5-11")) {
return await GetWellKnownPrincipal(sid.Value, computerDomain);
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -1418,4 +1421,4 @@ private static string ComputeDisplayName(IDirectoryObject directoryObject, strin
return displayName.ToUpper();
}
}
}
}
178 changes: 178 additions & 0 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 @@ -881,6 +890,175 @@ or Label.NTAuthStore
}
}

public Task<string[]> GetCustomDenyAces(ResolvedSearchResult result, IDirectoryObject searchResult) {
if (!searchResult.TryGetByteProperty(LDAPProperties.SecurityDescriptor, out var descriptor)) {
return Task.FromResult(Array.Empty<string>());
}

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

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

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

if (descriptor.DiscretionaryAcl == null || descriptor.DiscretionaryAcl.Count == 0) {
return Array.Empty<string>();
}

var results = new List<string>();

// 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<string>() : results.ToArray();
}

public async Task AddCustomDenyAcesProperty(Dictionary<string, object> 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<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.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;
}
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);
}

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


/// <summary>
/// Helper function to use commonlib types and pass to ProcessGMSAReaders
Expand Down
Loading
Loading