From 1e5a76ac7622a6388cc7a5e488f196533a694481 Mon Sep 17 00:00:00 2001 From: rvazarkar Date: Fri, 3 May 2024 13:55:52 -0400 Subject: [PATCH 1/5] wip: ldap connection consistency rewrite --- src/CommonLib/DomainControllerCacheKey.cs | 33 ++ src/CommonLib/DomainWrapper.cs | 15 + .../Exceptions/LdapAuthenticationException.cs | 12 + .../Exceptions/LdapConnectionException.cs | 13 + .../Exceptions/NoLdapDataException.cs | 9 + src/CommonLib/Extensions.cs | 50 +++ src/CommonLib/LDAPConfig.cs | 12 +- src/CommonLib/LDAPConnectionCacheKey.cs | 33 ++ src/CommonLib/LDAPProperties.cs | 4 + src/CommonLib/LDAPUtils.cs | 310 ++++++++++++++---- src/CommonLib/Processors/ACLProcessor.cs | 1 + 11 files changed, 431 insertions(+), 61 deletions(-) create mode 100644 src/CommonLib/DomainControllerCacheKey.cs create mode 100644 src/CommonLib/DomainWrapper.cs create mode 100644 src/CommonLib/Exceptions/LdapAuthenticationException.cs create mode 100644 src/CommonLib/Exceptions/LdapConnectionException.cs create mode 100644 src/CommonLib/Exceptions/NoLdapDataException.cs create mode 100644 src/CommonLib/LDAPConnectionCacheKey.cs diff --git a/src/CommonLib/DomainControllerCacheKey.cs b/src/CommonLib/DomainControllerCacheKey.cs new file mode 100644 index 00000000..4a9a9b84 --- /dev/null +++ b/src/CommonLib/DomainControllerCacheKey.cs @@ -0,0 +1,33 @@ +namespace SharpHoundCommonLib +{ + public class DomainControllerCacheKey + { + public string DomainName; + public int Port; + public bool GlobalCatalog; + + protected bool Equals(DomainControllerCacheKey other) + { + return DomainName == other.DomainName && Port == other.Port && GlobalCatalog == other.GlobalCatalog; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((DomainControllerCacheKey)obj); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = (DomainName != null ? DomainName.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ Port; + hashCode = (hashCode * 397) ^ GlobalCatalog.GetHashCode(); + return hashCode; + } + } + } +} \ No newline at end of file diff --git a/src/CommonLib/DomainWrapper.cs b/src/CommonLib/DomainWrapper.cs new file mode 100644 index 00000000..4a69cf5b --- /dev/null +++ b/src/CommonLib/DomainWrapper.cs @@ -0,0 +1,15 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.DirectoryServices.Protocols; + +namespace SharpHoundCommonLib +{ + public class DomainWrapper + { + public string DomainSID { get; set; } + public string DomainFQDN { get; set; } + public string DomainSearchBase { get; set; } + public string DomainConfigurationPath { get; set; } + public string DomainNetbiosName { get; set; } + } +} \ No newline at end of file diff --git a/src/CommonLib/Exceptions/LdapAuthenticationException.cs b/src/CommonLib/Exceptions/LdapAuthenticationException.cs new file mode 100644 index 00000000..e55413fb --- /dev/null +++ b/src/CommonLib/Exceptions/LdapAuthenticationException.cs @@ -0,0 +1,12 @@ +using System; +using System.DirectoryServices.Protocols; + +namespace SharpHoundCommonLib.Exceptions +{ + public class LdapAuthenticationException : Exception + { + public LdapAuthenticationException(LdapException exception) : base("Error authenticating to LDAP", exception) + { + } + } +} \ No newline at end of file diff --git a/src/CommonLib/Exceptions/LdapConnectionException.cs b/src/CommonLib/Exceptions/LdapConnectionException.cs new file mode 100644 index 00000000..d29970f8 --- /dev/null +++ b/src/CommonLib/Exceptions/LdapConnectionException.cs @@ -0,0 +1,13 @@ +using System; +using System.DirectoryServices.Protocols; + +namespace SharpHoundCommonLib.Exceptions +{ + public class LdapConnectionException : Exception + { + public LdapConnectionException(LdapException innerException) : base("Failed during ldap connection tests", innerException) + { + + } + } +} \ No newline at end of file diff --git a/src/CommonLib/Exceptions/NoLdapDataException.cs b/src/CommonLib/Exceptions/NoLdapDataException.cs new file mode 100644 index 00000000..1260899c --- /dev/null +++ b/src/CommonLib/Exceptions/NoLdapDataException.cs @@ -0,0 +1,9 @@ +using System; + +namespace SharpHoundCommonLib.Exceptions +{ + public class NoLdapDataException : Exception + { + + } +} \ No newline at end of file diff --git a/src/CommonLib/Extensions.cs b/src/CommonLib/Extensions.cs index 07ad8171..e85ed3f7 100644 --- a/src/CommonLib/Extensions.cs +++ b/src/CommonLib/Extensions.cs @@ -9,6 +9,8 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using SharpHoundCommonLib.Enums; +using SharpHoundCommonLib.LDAPQueries; +using SearchScope = System.DirectoryServices.Protocols.SearchScope; namespace SharpHoundCommonLib { @@ -23,6 +25,54 @@ static Extensions() Log = Logging.LogProvider.CreateLogger("Extensions"); } + public static (bool, LdapException, DomainWrapper) TestConnection(this LdapConnection connection) + { + try + { + //Attempt an initial bind. If this fails, likely auth is invalid, or its not a valid target + connection.Bind(); + } + catch (LdapException e) + { + return (false, e, null); + } + + try + { + //Do an initial search request to get the rootDSE + var searchRequest = new SearchRequest("", new LDAPFilter().AddAllObjects().GetFilter(), + SearchScope.Base, null); + searchRequest.Controls.Add(new SearchOptionsControl(SearchOption.DomainScope)); + + var response = (SearchResponse)connection.SendRequest(searchRequest); + if (response == null) + { + return (false, null, null); + } + + if (response.Entries.Count == 0) + { + return (false, null, null); + } + + var entry = response.Entries[0]; + var baseDN = entry.GetProperty(LDAPProperties.PrimaryNamingContext); + var configurationDN = entry.GetProperty(LDAPProperties.ConfigurationNamingContext); + var domainname = Helpers.DistinguishedNameToDomain(baseDN).ToUpper(); + + return (true, null, new DomainWrapper + { + DomainConfigurationPath = configurationDN, + DomainSearchBase = baseDN, + DomainFQDN = domainname + }); + } + catch (LdapException e) + { + return (false, e, null); + } + } + internal static async Task> ToListAsync(this IAsyncEnumerable items) { var results = new List(); diff --git a/src/CommonLib/LDAPConfig.cs b/src/CommonLib/LDAPConfig.cs index 17650024..71f1fd3c 100644 --- a/src/CommonLib/LDAPConfig.cs +++ b/src/CommonLib/LDAPConfig.cs @@ -8,14 +8,20 @@ public class LDAPConfig public string Password { get; set; } = null; public string Server { get; set; } = null; public int Port { get; set; } = 0; - public bool SSL { get; set; } = false; + public bool ForceSSL { get; set; } = false; public bool DisableSigning { get; set; } = false; public bool DisableCertVerification { get; set; } = false; public AuthType AuthType { get; set; } = AuthType.Kerberos; - public int GetPort() + //Returns the port for connecting to LDAP. Will always respect a user's overridden config over anything else + public int GetPort(bool ssl) { - return Port == 0 ? SSL ? 636 : 389 : Port; + if (Port != 0) + { + return Port; + } + + return ssl ? 636 : 389; } } } \ No newline at end of file diff --git a/src/CommonLib/LDAPConnectionCacheKey.cs b/src/CommonLib/LDAPConnectionCacheKey.cs new file mode 100644 index 00000000..38eef58a --- /dev/null +++ b/src/CommonLib/LDAPConnectionCacheKey.cs @@ -0,0 +1,33 @@ +namespace SharpHoundCommonLib +{ + public class LDAPConnectionCacheKey + { + public int Port; + public bool GlobalCatalog; + public string Domain; + + protected bool Equals(LDAPConnectionCacheKey other) + { + return Port == other.Port && GlobalCatalog == other.GlobalCatalog && Domain == other.Domain; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((LDAPConnectionCacheKey)obj); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = Port; + hashCode = (hashCode * 397) ^ GlobalCatalog.GetHashCode(); + hashCode = (hashCode * 397) ^ (Domain != null ? Domain.GetHashCode() : 0); + return hashCode; + } + } + } +} \ No newline at end of file diff --git a/src/CommonLib/LDAPProperties.cs b/src/CommonLib/LDAPProperties.cs index e2ea3ce1..3009eff2 100644 --- a/src/CommonLib/LDAPProperties.cs +++ b/src/CommonLib/LDAPProperties.cs @@ -69,5 +69,9 @@ public static class LDAPProperties public const string CertificateTemplates = "certificatetemplates"; public const string CrossCertificatePair = "crosscertificatepair"; public const string Flags = "flags"; + public const string PrimaryNamingContext = "primarynamingcontext"; + public const string ConfigurationNamingContext = "configurationnamingcontext"; + public const string NetbiosName = "netbiosName"; + public const string DnsRoot = "dnsroot"; } } diff --git a/src/CommonLib/LDAPUtils.cs b/src/CommonLib/LDAPUtils.cs index 9847ae17..e011a11d 100644 --- a/src/CommonLib/LDAPUtils.cs +++ b/src/CommonLib/LDAPUtils.cs @@ -47,17 +47,18 @@ private static readonly ConcurrentDictionary SeenWellKnownPrincipals = new(); private static readonly ConcurrentDictionary DomainControllers = new(); + private static readonly ConcurrentDictionary CachedDomainInfo = new(); private readonly ConcurrentDictionary _domainCache = new(); - private readonly ConcurrentDictionary _domainControllerCache = new(); + private readonly ConcurrentDictionary _domainControllerCache = new(); + public ConcurrentDictionary Connections { get; } = new(); private static readonly TimeSpan MinBackoffDelay = TimeSpan.FromSeconds(2); private static readonly TimeSpan MaxBackoffDelay = TimeSpan.FromSeconds(20); private const int BackoffDelayMultiplier = 2; private const int MaxRetries = 3; - - private readonly ConcurrentDictionary _globalCatalogConnections = new(); + private readonly ConcurrentDictionary _hostResolutionMap = new(); - private readonly ConcurrentDictionary _ldapConnections = new(); + private readonly ConcurrentDictionary _ldapConnections = new(); private readonly ConcurrentDictionary _ldapRangeSizeCache = new(); private readonly ILogger _log; private readonly NativeMethods _nativeMethods; @@ -100,12 +101,7 @@ public void SetLDAPConfig(LDAPConfig config) { _ldapConfig = config ?? throw new Exception("LDAP Configuration can not be null"); _domainControllerCache.Clear(); - foreach (var kv in _globalCatalogConnections) - { - kv.Value.Dispose(); - } - - _globalCatalogConnections.Clear(); + //Close out any existing LDAP connections to request a new incoming config foreach (var kv in _ldapConnections) { kv.Value.Dispose(); @@ -1473,6 +1469,43 @@ private async Task CreateGlobalCatalogConnection(string domainNa return connection; } + private LdapConnection CreateConnectionHelper(string directoryIdentifier, bool ssl, AuthType authType) + { + var port = _ldapConfig.GetPort(ssl); + var target = directoryIdentifier; + if (_ldapConfig.Server != null) + { + target = _ldapConfig.Server; + } + var identifier = new LdapDirectoryIdentifier(target, port, false, false); + var connection = new LdapConnection(identifier) { Timeout = new TimeSpan(0, 0, 5, 0) }; + SetupLdapConnection(connection, true, authType); + return connection; + } + + private void CheckAndThrowException(LdapException ldapException) + { + //A null error code with success false indicates that we successfully created a connection but got no data back, this is generally because our AuthType isn't compatible. + //AuthType Kerberos will only work across trusts in very specific scenarios + //Throw this exception for clients to handle + if (ldapException == null) + { + throw new NoLdapDataException(); + } + + //These error codes indicate bad auth of some kind or insufficient permissions + if (ldapException.ErrorCode is (int)ResultCode.AuthMethodNotSupported or (int)ResultCode.InappropriateAuthentication or (int)ResultCode.InsufficientAccessRights) + { + throw new LdapAuthenticationException(ldapException); + } + + //Any other error we dont have specific ways to handle + if (ldapException.ErrorCode != (int)ResultCode.Unavailable) + { + throw new LdapConnectionException(ldapException); + } + } + /// /// Creates an LDAP connection with appropriate options based off the ldap configuration. Caches connections /// @@ -1483,93 +1516,254 @@ private async Task CreateGlobalCatalogConnection(string domainNa private async Task CreateLDAPConnection(string domainName = null, bool skipCache = false, AuthType authType = AuthType.Kerberos) { - string targetServer; - if (_ldapConfig.Server != null) - targetServer = _ldapConfig.Server; - else + var domain = domainName?.ToUpper() ?? GetDomain(domainName)?.Name.ToUpper(); + + //If our domain is null here, then we'll automatically hit our current computer's domain, which isn't necessarily what we want. + //What we really want is the user's domain context + if (domain == null) { - var domain = GetDomain(domainName); - if (domain == null) + _log.LogDebug("Unable to create ldap connection for domain {DomainName}: Could not get a reliable domain name from GetDomain", + domainName); + throw new LDAPQueryException( + $"Error creating LDAP connection: GetDomain call failed for {domainName}"); + } + + if (!skipCache) + { + //Try both ssl and non-ssl connections from the cache + var key = new LDAPConnectionCacheKey { - _log.LogDebug("Unable to create ldap connection for domain {DomainName}: GetDomain failed", - domainName); - throw new LDAPQueryException( - $"Error creating LDAP connection: GetDomain call failed for {domainName}"); + GlobalCatalog = false, + Port = _ldapConfig.GetPort(true), + Domain = domain + }; + + if (_ldapConnections.TryGetValue(key, out var cachedConnection)) + { + return cachedConnection; } - if (!_domainControllerCache.TryGetValue(domain.Name, out targetServer)) - targetServer = await GetUsableDomainController(domain); + key.Port = _ldapConfig.GetPort(false); + if (_ldapConnections.TryGetValue(key, out cachedConnection)) + { + return cachedConnection; + } } + + //Lets build a new connection + //Always try SSL first + var connection = CreateConnectionHelper(domain, true, authType); + var (isSuccess, ldapException, baseDomainInfo) = connection.TestConnection(); - if (targetServer == null) - throw new LDAPQueryException($"No usable domain controller found for {domainName}"); + if (isSuccess) + { + if (!CachedDomainInfo.ContainsKey(domain.ToUpper())) + { + baseDomainInfo.DomainSID = GetDomainSid(connection, baseDomainInfo); + baseDomainInfo.DomainNetbiosName = GetDomainNetbiosName(connection, baseDomainInfo); + CachedDomainInfo.TryAdd(baseDomainInfo.DomainFQDN, baseDomainInfo); + CachedDomainInfo.TryAdd(baseDomainInfo.DomainNetbiosName, baseDomainInfo); + CachedDomainInfo.TryAdd(baseDomainInfo.DomainSID, baseDomainInfo); + } - if (!skipCache) - if (_ldapConnections.TryGetValue(targetServer, out var conn)) - return conn; + _ldapConnections.AddOrUpdate(new LDAPConnectionCacheKey + { + GlobalCatalog = false, + Port = _ldapConfig.GetPort(true), + Domain = domain + }, connection, (_, ldapConnection) => + { + ldapConnection.Dispose(); + return connection; + }); + return connection; + } - var port = _ldapConfig.GetPort(); - var ident = new LdapDirectoryIdentifier(targetServer, port, false, false); - var connection = new LdapConnection(ident) { Timeout = new TimeSpan(0, 0, 5, 0) }; - if (_ldapConfig.Username != null) + CheckAndThrowException(ldapException); + + connection = CreateConnectionHelper(domain, false, authType); + + (isSuccess, ldapException, baseDomainInfo) = connection.TestConnection(); + + if (isSuccess) { - var cred = new NetworkCredential(_ldapConfig.Username, _ldapConfig.Password); - connection.Credential = cred; + if (!CachedDomainInfo.ContainsKey(domain.ToUpper())) + { + baseDomainInfo.DomainSID = GetDomainSid(connection, baseDomainInfo); + baseDomainInfo.DomainNetbiosName = GetDomainNetbiosName(connection, baseDomainInfo); + CachedDomainInfo.TryAdd(baseDomainInfo.DomainFQDN, baseDomainInfo); + CachedDomainInfo.TryAdd(baseDomainInfo.DomainNetbiosName, baseDomainInfo); + CachedDomainInfo.TryAdd(baseDomainInfo.DomainSID, baseDomainInfo); + } + + _ldapConnections.AddOrUpdate(new LDAPConnectionCacheKey + { + GlobalCatalog = false, + Port = _ldapConfig.GetPort(true), + Domain = domain + }, connection, (_, ldapConnection) => + { + ldapConnection.Dispose(); + return connection; + }); + return connection; } + + CheckAndThrowException(ldapException); + + return connection; + } + private string GetDomainNetbiosName(LdapConnection connection, DomainWrapper wrapper) + { + try + { + var searchRequest = new SearchRequest($"CN=Partitions,{wrapper.DomainConfigurationPath}", + "(&(nETBIOSName=*)(dnsRoot=*)", + SearchScope.Subtree, new[] { "netbiosname", "dnsroot" }); + + var response = (SearchResponse)connection.SendRequest(searchRequest); + if (response == null || response.Entries.Count == 0) + { + return ""; + } + + foreach (SearchResultEntry entry in response.Entries) + { + var root = entry.GetProperty(LDAPProperties.DNSHostName); + var netbios = entry.GetProperty(LDAPProperties.NetbiosName); + + if (root.ToUpper().Equals(wrapper.DomainFQDN)) + { + return netbios.ToUpper(); + } + } + + return ""; + } + catch (LdapException) + { + _log.LogWarning("Failed grabbing netbios name from ldap for {domain}", wrapper.DomainFQDN); + return ""; + } + } + + private string GetDomainSid(LdapConnection connection, DomainWrapper wrapper) + { + try + { + //This ldap filter searches for domain controllers + var searchRequest = new SearchRequest(wrapper.DomainSearchBase, + "(userAccountControl:1.2.840.113556.1.4.803:=8192)", + SearchScope.Subtree, new[] { "objectsid"}); + + var response = (SearchResponse)connection.SendRequest(searchRequest); + if (response == null || response.Entries.Count == 0) + { + return ""; + } + + var entry = response.Entries[0]; + var sid = entry.GetSid(); + return sid.Substring(0, sid.LastIndexOf('-')).ToUpper(); + } + catch (LdapException) + { + _log.LogWarning("Failed grabbing domainsid from ldap for {domain}", wrapper.DomainFQDN); + return ""; + } + } + + private DomainWrapper BuildDomainInfo(LdapConnection connection) + { + try + { + //Do an initial search request to get the rootDSE + var searchRequest = new SearchRequest("", new LDAPFilter().AddAllObjects().GetFilter(), + SearchScope.Base, null); + searchRequest.Controls.Add(new SearchOptionsControl(SearchOption.DomainScope)); + + var response = (SearchResponse)connection.SendRequest(searchRequest); + if (response == null) + { + return (false, 0); + } + + return response.Entries.Count > 0 ? (true, 0) : (false, 0); + } + catch (LdapException e) + { + return (false, e.ErrorCode); + } + } + + private void SetupLdapConnection(LdapConnection connection, bool ssl, AuthType authType) + { //These options are important! connection.SessionOptions.ProtocolVersion = 3; + //Referral chasing does not work with paged searches connection.SessionOptions.ReferralChasing = ReferralChasingOptions.None; - + if (ssl) + { + connection.SessionOptions.SecureSocketLayer = true; + } + if (_ldapConfig.DisableSigning) { connection.SessionOptions.Sealing = false; connection.SessionOptions.Signing = false; } - - if (_ldapConfig.SSL) - connection.SessionOptions.SecureSocketLayer = true; - + if (_ldapConfig.DisableCertVerification) - connection.SessionOptions.VerifyServerCertificate = (ldapConnection, certificate) => true; - - connection.AuthType = authType; - - _ldapConnections.AddOrUpdate(targetServer, connection, (s, ldapConnection) => + connection.SessionOptions.VerifyServerCertificate = (_, _) => true; + + if (_ldapConfig.Username != null) { - ldapConnection.Dispose(); - return connection; - }); - - return connection; + var cred = new NetworkCredential(_ldapConfig.Username, _ldapConfig.Password); + connection.Credential = cred; + } + + connection.AuthType = authType; } - private async Task GetUsableDomainController(Domain domain, bool gc = false) + private async Task GetUsableDomainController(Domain domain, int port, bool gc = false) { - if (!gc && _domainControllerCache.TryGetValue(domain.Name.ToUpper(), out var dc)) + var name = domain.Name?.ToUpper(); + //If we don't have a domain name, just throw this out, we're not getting anything from this + if (name == null) + { + return null; + } + var key = new DomainControllerCacheKey + { + DomainName = name, + GlobalCatalog = gc, + Port = port + }; + + if (_domainControllerCache.TryGetValue(key, out var dc)) return dc; - var port = gc ? 3268 : _ldapConfig.GetPort(); var pdc = domain.PdcRoleOwner.Name; if (await _portScanner.CheckPort(pdc, port)) { - _domainControllerCache.TryAdd(domain.Name.ToUpper(), pdc); - _log.LogInformation("Found usable Domain Controller for {Domain} : {PDC}", domain.Name, pdc); + _domainControllerCache.TryAdd(key, pdc); + _log.LogInformation("Found usable primary domain Controller for {Domain} : {DC}", name, pdc); return pdc; } //If the PDC isn't reachable loop through the rest foreach (DomainController domainController in domain.DomainControllers) { - var name = domainController.Name; - if (!await _portScanner.CheckPort(name, port)) continue; - _log.LogInformation("Found usable Domain Controller for {Domain} : {PDC}", domain.Name, name); - _domainControllerCache.TryAdd(domain.Name.ToUpper(), name); + var dcname = domainController.Name; + if (!await _portScanner.CheckPort(dcname, port)) continue; + _log.LogInformation("Found usable Domain Controller for {Domain} : {DC}", name, dcname); + _domainControllerCache.TryAdd(key, dcname); return name; } //If we get here, somehow we didn't get any usable DCs. Save it off as null - _domainControllerCache.TryAdd(domain.Name.ToUpper(), null); + _domainControllerCache.TryAdd(key, null); _log.LogWarning("Unable to find usable domain controller for {Domain}", domain.Name); return null; } diff --git a/src/CommonLib/Processors/ACLProcessor.cs b/src/CommonLib/Processors/ACLProcessor.cs index dd6fb5ae..815b30bc 100644 --- a/src/CommonLib/Processors/ACLProcessor.cs +++ b/src/CommonLib/Processors/ACLProcessor.cs @@ -73,6 +73,7 @@ private void BuildGUIDCache(string domain) } _log.LogTrace("Requesting schema from {Schema}", schema); + foreach (var entry in _utils.QueryLDAP("(schemaIDGUID=*)", SearchScope.Subtree, new[] {LDAPProperties.SchemaIDGUID, LDAPProperties.Name}, adsPath: schema)) { From cb1cd1f3779d9556fc01ad1c647b98d364143804 Mon Sep 17 00:00:00 2001 From: rvazarkar Date: Thu, 16 May 2024 16:16:40 -0400 Subject: [PATCH 2/5] wip: ldap connection crap --- src/CommonLib/Enums/LdapErrorCodes.cs | 3 +- src/CommonLib/Extensions.cs | 51 +++++++-- src/CommonLib/LDAPConnectionCacheKey.cs | 19 ++-- src/CommonLib/LDAPProperties.cs | 1 + src/CommonLib/LDAPUtils.cs | 137 +++++++++++++++--------- src/CommonLib/LdapConnectionWrapper.cs | 12 +++ 6 files changed, 152 insertions(+), 71 deletions(-) create mode 100644 src/CommonLib/LdapConnectionWrapper.cs diff --git a/src/CommonLib/Enums/LdapErrorCodes.cs b/src/CommonLib/Enums/LdapErrorCodes.cs index cfbba682..ff81967c 100644 --- a/src/CommonLib/Enums/LdapErrorCodes.cs +++ b/src/CommonLib/Enums/LdapErrorCodes.cs @@ -5,6 +5,7 @@ public enum LdapErrorCodes : int Success = 0, Busy = 51, ServerDown = 81, - LocalError = 82 + LocalError = 82, + KerberosAuthType = 83 } } \ No newline at end of file diff --git a/src/CommonLib/Extensions.cs b/src/CommonLib/Extensions.cs index e85ed3f7..1a2161fb 100644 --- a/src/CommonLib/Extensions.cs +++ b/src/CommonLib/Extensions.cs @@ -25,7 +25,23 @@ static Extensions() Log = Logging.LogProvider.CreateLogger("Extensions"); } - public static (bool, LdapException, DomainWrapper) TestConnection(this LdapConnection connection) + public class LdapConnectionTestResult + { + public bool Success { get; set; } + public LdapException Exception { get; set; } + public DomainWrapper DomainInfo { get; set; } + public string ServerName { get; set; } + + public LdapConnectionTestResult(bool success, LdapException e, DomainWrapper info, string server) + { + Success = success; + Exception = e; + DomainInfo = info; + ServerName = server; + } + } + + public static LdapConnectionTestResult TestConnection(this LdapConnection connection) { try { @@ -34,12 +50,13 @@ public static (bool, LdapException, DomainWrapper) TestConnection(this LdapConne } catch (LdapException e) { - return (false, e, null); + return new LdapConnectionTestResult(false, e, null, null); } try { //Do an initial search request to get the rootDSE + //This ldap filter is equivalent to (objectclass=*) var searchRequest = new SearchRequest("", new LDAPFilter().AddAllObjects().GetFilter(), SearchScope.Base, null); searchRequest.Controls.Add(new SearchOptionsControl(SearchOption.DomainScope)); @@ -47,29 +64,41 @@ public static (bool, LdapException, DomainWrapper) TestConnection(this LdapConne var response = (SearchResponse)connection.SendRequest(searchRequest); if (response == null) { - return (false, null, null); + return new LdapConnectionTestResult(false, null, null, null); } if (response.Entries.Count == 0) { - return (false, null, null); + connection.Dispose(); + return new LdapConnectionTestResult(false, new LdapException((int)LdapErrorCodes.KerberosAuthType), null, null); } var entry = response.Entries[0]; - var baseDN = entry.GetProperty(LDAPProperties.PrimaryNamingContext); - var configurationDN = entry.GetProperty(LDAPProperties.ConfigurationNamingContext); - var domainname = Helpers.DistinguishedNameToDomain(baseDN).ToUpper(); - - return (true, null, new DomainWrapper + var baseDN = entry.GetProperty(LDAPProperties.PrimaryNamingContext).ToUpper().Trim(); + var configurationDN = entry.GetProperty(LDAPProperties.ConfigurationNamingContext).ToUpper().Trim(); + var domainname = Helpers.DistinguishedNameToDomain(baseDN).ToUpper().Trim(); + var servername = entry.GetProperty(LDAPProperties.ServerName); + var compName = servername.Substring(0, servername.IndexOf(',')).Substring(3).Trim(); + var fullServerName = $"{compName}.{domainname}".ToUpper().Trim(); + + return new LdapConnectionTestResult(true, null, new DomainWrapper { DomainConfigurationPath = configurationDN, DomainSearchBase = baseDN, DomainFQDN = domainname - }); + }, fullServerName); } catch (LdapException e) { - return (false, e, null); + try + { + connection.Dispose(); + } + catch + { + //pass + } + return new LdapConnectionTestResult(false, e, null, null); } } diff --git a/src/CommonLib/LDAPConnectionCacheKey.cs b/src/CommonLib/LDAPConnectionCacheKey.cs index 38eef58a..f1f76464 100644 --- a/src/CommonLib/LDAPConnectionCacheKey.cs +++ b/src/CommonLib/LDAPConnectionCacheKey.cs @@ -2,13 +2,19 @@ namespace SharpHoundCommonLib { public class LDAPConnectionCacheKey { - public int Port; - public bool GlobalCatalog; - public string Domain; + public bool GlobalCatalog { get; } + public string Domain { get; } + public string Server { get; set; } + + public LDAPConnectionCacheKey(string domain, bool globalCatalog) + { + GlobalCatalog = globalCatalog; + Domain = domain; + } protected bool Equals(LDAPConnectionCacheKey other) { - return Port == other.Port && GlobalCatalog == other.GlobalCatalog && Domain == other.Domain; + return GlobalCatalog == other.GlobalCatalog && Domain == other.Domain; } public override bool Equals(object obj) @@ -23,10 +29,7 @@ public override int GetHashCode() { unchecked { - var hashCode = Port; - hashCode = (hashCode * 397) ^ GlobalCatalog.GetHashCode(); - hashCode = (hashCode * 397) ^ (Domain != null ? Domain.GetHashCode() : 0); - return hashCode; + return (GlobalCatalog.GetHashCode() * 397) ^ (Domain != null ? Domain.GetHashCode() : 0); } } } diff --git a/src/CommonLib/LDAPProperties.cs b/src/CommonLib/LDAPProperties.cs index 3009eff2..7c2bc118 100644 --- a/src/CommonLib/LDAPProperties.cs +++ b/src/CommonLib/LDAPProperties.cs @@ -73,5 +73,6 @@ public static class LDAPProperties public const string ConfigurationNamingContext = "configurationnamingcontext"; public const string NetbiosName = "netbiosName"; public const string DnsRoot = "dnsroot"; + public const string ServerName = "servername"; } } diff --git a/src/CommonLib/LDAPUtils.cs b/src/CommonLib/LDAPUtils.cs index e011a11d..00fd0c21 100644 --- a/src/CommonLib/LDAPUtils.cs +++ b/src/CommonLib/LDAPUtils.cs @@ -1477,6 +1477,7 @@ private LdapConnection CreateConnectionHelper(string directoryIdentifier, bool s { target = _ldapConfig.Server; } + var identifier = new LdapDirectoryIdentifier(target, port, false, false); var connection = new LdapConnection(identifier) { Timeout = new TimeSpan(0, 0, 5, 0) }; SetupLdapConnection(connection, true, authType); @@ -1488,7 +1489,7 @@ private void CheckAndThrowException(LdapException ldapException) //A null error code with success false indicates that we successfully created a connection but got no data back, this is generally because our AuthType isn't compatible. //AuthType Kerberos will only work across trusts in very specific scenarios //Throw this exception for clients to handle - if (ldapException == null) + if (ldapException.ErrorCode == (int)LdapErrorCodes.KerberosAuthType) { throw new NoLdapDataException(); } @@ -1500,12 +1501,27 @@ private void CheckAndThrowException(LdapException ldapException) } //Any other error we dont have specific ways to handle - if (ldapException.ErrorCode != (int)ResultCode.Unavailable) + if (ldapException.ErrorCode != (int)ResultCode.Unavailable && ldapException.ErrorCode != (int)ResultCode.Busy) { throw new LdapConnectionException(ldapException); } } + private string ResolveDomainToFullName(string domain) + { + if (string.IsNullOrEmpty(domain)) + { + return GetDomain()?.Name.ToUpper().Trim(); + } + + if (CachedDomainInfo.TryGetValue(domain.ToUpper(), out var info)) + { + return info.DomainFQDN; + } + + return GetDomain(domain)?.Name.ToUpper().Trim(); + } + /// /// Creates an LDAP connection with appropriate options based off the ldap configuration. Caches connections /// @@ -1513,52 +1529,75 @@ private void CheckAndThrowException(LdapException ldapException) /// Skip the connection cache /// Auth type to use. Defaults to Kerberos. Use Negotiate for netonly scenarios /// A connected LDAP connection or null - private async Task CreateLDAPConnection(string domainName = null, bool skipCache = false, + private async Task CreateLDAPConnectionWrapper(string domainName = null, bool skipCache = false, AuthType authType = AuthType.Kerberos) { - var domain = domainName?.ToUpper() ?? GetDomain(domainName)?.Name.ToUpper(); - - //If our domain is null here, then we'll automatically hit our current computer's domain, which isn't necessarily what we want. - //What we really want is the user's domain context - if (domain == null) - { - _log.LogDebug("Unable to create ldap connection for domain {DomainName}: Could not get a reliable domain name from GetDomain", - domainName); - throw new LDAPQueryException( - $"Error creating LDAP connection: GetDomain call failed for {domainName}"); - } + var domain = domainName?.ToUpper().Trim() ?? ResolveDomainToFullName(domainName); if (!skipCache) { - //Try both ssl and non-ssl connections from the cache - var key = new LDAPConnectionCacheKey - { - GlobalCatalog = false, - Port = _ldapConfig.GetPort(true), - Domain = domain - }; - - if (_ldapConnections.TryGetValue(key, out var cachedConnection)) + if (GetCachedConnection(domain, false, out var conn)) { - return cachedConnection; + return conn; } + } + + var connection = CreateLDAPConnection(domain, authType); + //If our connection isn't null, it means we have a good connection + if (connection != null) + { + return connection; + } - key.Port = _ldapConfig.GetPort(false); - if (_ldapConnections.TryGetValue(key, out cachedConnection)) + if (domainName != null) + { + var newDomain = ResolveDomainToFullName(domainName); + if (!string.IsNullOrEmpty(newDomain) && !newDomain.Equals(domain, StringComparison.OrdinalIgnoreCase)) { - return cachedConnection; + if (!skipCache) + { + if (GetCachedConnection(domain, false, out var conn)) + { + return conn; + } + } + + connection = CreateLDAPConnection(domain, authType); + //If our connection isn't null, it means we have a good connection + if (connection != null) + { + return connection; + } } } - + //Next step, look for domain controllers + } + + private bool GetCachedConnection(string domain, bool globalCatalog, out LdapConnection connection) + { + var domainName = domain; + if (CachedDomainInfo.TryGetValue(domain.ToUpper(), out var resolved)) + { + domainName = resolved.DomainFQDN; + } + var key = new LDAPConnectionCacheKey(domainName, globalCatalog); + return _ldapConnections.TryGetValue(key, out connection); + } + + + private LdapConnection CreateLDAPConnection(string target, AuthType authType) + { //Lets build a new connection //Always try SSL first - var connection = CreateConnectionHelper(domain, true, authType); - var (isSuccess, ldapException, baseDomainInfo) = connection.TestConnection(); + var connection = CreateConnectionHelper(target, true, authType); + var connectionResult = connection.TestConnection(); - if (isSuccess) + if (connectionResult.Success) { - if (!CachedDomainInfo.ContainsKey(domain.ToUpper())) + var domain = connectionResult.DomainInfo.DomainFQDN; + if (!CachedDomainInfo.ContainsKey(domain)) { + var baseDomainInfo = connectionResult.DomainInfo; baseDomainInfo.DomainSID = GetDomainSid(connection, baseDomainInfo); baseDomainInfo.DomainNetbiosName = GetDomainNetbiosName(connection, baseDomainInfo); CachedDomainInfo.TryAdd(baseDomainInfo.DomainFQDN, baseDomainInfo); @@ -1566,12 +1605,8 @@ private async Task CreateLDAPConnection(string domainName = null CachedDomainInfo.TryAdd(baseDomainInfo.DomainSID, baseDomainInfo); } - _ldapConnections.AddOrUpdate(new LDAPConnectionCacheKey - { - GlobalCatalog = false, - Port = _ldapConfig.GetPort(true), - Domain = domain - }, connection, (_, ldapConnection) => + var cacheKey = new LDAPConnectionCacheKey(domain, _ldapConfig.GetPort(true), false); + _ldapConnections.AddOrUpdate(cacheKey, connection, (_, ldapConnection) => { ldapConnection.Dispose(); return connection; @@ -1579,16 +1614,20 @@ private async Task CreateLDAPConnection(string domainName = null return connection; } - CheckAndThrowException(ldapException); + CheckAndThrowException(connectionResult.Exception); - connection = CreateConnectionHelper(domain, false, authType); + //If we get to this point, it means we have an unsuccessful connection, but our error code doesn't indicate an outright failure + //Try a new connection without SSL + connection = CreateConnectionHelper(target, false, authType); - (isSuccess, ldapException, baseDomainInfo) = connection.TestConnection(); + connectionResult = connection.TestConnection(); - if (isSuccess) + if (connectionResult.Success) { + var domain = connectionResult.DomainInfo.DomainFQDN; if (!CachedDomainInfo.ContainsKey(domain.ToUpper())) { + var baseDomainInfo = connectionResult.DomainInfo; baseDomainInfo.DomainSID = GetDomainSid(connection, baseDomainInfo); baseDomainInfo.DomainNetbiosName = GetDomainNetbiosName(connection, baseDomainInfo); CachedDomainInfo.TryAdd(baseDomainInfo.DomainFQDN, baseDomainInfo); @@ -1596,12 +1635,9 @@ private async Task CreateLDAPConnection(string domainName = null CachedDomainInfo.TryAdd(baseDomainInfo.DomainSID, baseDomainInfo); } - _ldapConnections.AddOrUpdate(new LDAPConnectionCacheKey - { - GlobalCatalog = false, - Port = _ldapConfig.GetPort(true), - Domain = domain - }, connection, (_, ldapConnection) => + var cacheKey = new LDAPConnectionCacheKey(domain, _ldapConfig.GetPort(true), false); + + _ldapConnections.AddOrUpdate(cacheKey, connection, (_, ldapConnection) => { ldapConnection.Dispose(); return connection; @@ -1609,9 +1645,8 @@ private async Task CreateLDAPConnection(string domainName = null return connection; } - CheckAndThrowException(ldapException); - - return connection; + CheckAndThrowException(connectionResult.Exception); + return null; } private string GetDomainNetbiosName(LdapConnection connection, DomainWrapper wrapper) diff --git a/src/CommonLib/LdapConnectionWrapper.cs b/src/CommonLib/LdapConnectionWrapper.cs new file mode 100644 index 00000000..23d3bb69 --- /dev/null +++ b/src/CommonLib/LdapConnectionWrapper.cs @@ -0,0 +1,12 @@ +using System.DirectoryServices.Protocols; + +namespace SharpHoundCommonLib +{ + public class LdapConnectionWrapper + { + public string Server; + public int Port; + public LdapConnection Connection; + public string Domain; + } +} \ No newline at end of file From 76d7289f4d16be243c9a12dd6b11e8fbbc8651d9 Mon Sep 17 00:00:00 2001 From: anemeth Date: Fri, 17 May 2024 09:33:56 -0700 Subject: [PATCH 3/5] Create unit tests for TestConnection --- test/unit/ConnectionTest.cs | 46 +++++++++++++++++++++++ test/unit/Facades/MockLdapConnection.cs | 50 +++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 test/unit/ConnectionTest.cs create mode 100644 test/unit/Facades/MockLdapConnection.cs diff --git a/test/unit/ConnectionTest.cs b/test/unit/ConnectionTest.cs new file mode 100644 index 00000000..85c2fc23 --- /dev/null +++ b/test/unit/ConnectionTest.cs @@ -0,0 +1,46 @@ +using System; +using System.DirectoryServices.Protocols; +using CommonLibTest.Facades; +using SharpHoundCommonLib; +using SharpHoundCommonLib.Enums; +using Xunit; + +namespace ConnectionTest +{ + public class ConnectionTests + { + [Fact] + public void TestConnectionNullResponse() + { + var connection = MockLdapConnection.Get(ResponseBehavior.NullResponse); + var testResponse = connection.TestConnection(); + + Assert.False(testResponse.Success); + Assert.Null(testResponse.Exception); + } + + // This happens when a Kerberos misconfiguration occurs + [Fact] + public void TestConnectionEmptyResponse() + { + var connection = MockLdapConnection.Get(ResponseBehavior.EmptyResponse); + var testResponse = connection.TestConnection(); + + Assert.False(testResponse.Success); + Assert.IsType(testResponse.Exception); + Assert.Equal((int)LdapErrorCodes.KerberosAuthType, testResponse.Exception.ErrorCode); + Assert.Throws(() => connection.Bind()); + } + + [Fact] + public void TestConnectionThrowsLdapException() + { + var connection = MockLdapConnection.Get(ResponseBehavior.ThrowsLdapException); + var testResponse = connection.TestConnection(); + + Assert.False(testResponse.Success); + Assert.IsType(testResponse.Exception); + Assert.Throws(() => connection.Bind()); + } + } +} \ No newline at end of file diff --git a/test/unit/Facades/MockLdapConnection.cs b/test/unit/Facades/MockLdapConnection.cs new file mode 100644 index 00000000..081e3c30 --- /dev/null +++ b/test/unit/Facades/MockLdapConnection.cs @@ -0,0 +1,50 @@ +using System; +using System.DirectoryServices.Protocols; +using Moq; + +namespace CommonLibTest.Facades +{ + public static class MockLdapConnection + { + public static LdapConnection Get(ResponseBehavior responseBehavior) + => responseBehavior switch + { + ResponseBehavior.NullResponse => ReturnsNullResponse(), + ResponseBehavior.EmptyResponse => ReturnsEmptyResponse(), + ResponseBehavior.ThrowsLdapException => ThrowsLdapException(), + _ => throw new ArgumentOutOfRangeException(nameof(responseBehavior)) + }; + + private static LdapConnection ReturnsNullResponse() + { + var mock = new Mock(); + mock.Setup(x => x.SendRequest(It.IsAny())) + .Returns(null); + return mock.Object; + } + + private static LdapConnection ReturnsEmptyResponse() + { + var mock = new Mock(); + var emptyResponseMock = Mock.Of(m => m.Entries == Mock.Of(m => m.Count == 0)); + mock.Setup(x => x.SendRequest(It.IsAny())) + .Returns(emptyResponseMock); + return mock.Object; + } + + private static LdapConnection ThrowsLdapException() + { + var mock = new Mock(); + mock.Setup(x => x.SendRequest(It.IsAny())) + .Throws(); + return mock.Object; + } + } + + public enum ResponseBehavior + { + NullResponse, + EmptyResponse, + ThrowsLdapException, + } +} \ No newline at end of file From 8096b3cf83503aa99a11b41fcbfe9c3a7c3b780c Mon Sep 17 00:00:00 2001 From: anemeth Date: Fri, 17 May 2024 11:28:49 -0700 Subject: [PATCH 4/5] Add happy path TestConnection test --- test/unit/ConnectionTest.cs | 13 +++++++++++ test/unit/Facades/MockLdapConnection.cs | 29 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/test/unit/ConnectionTest.cs b/test/unit/ConnectionTest.cs index 85c2fc23..ce04b5da 100644 --- a/test/unit/ConnectionTest.cs +++ b/test/unit/ConnectionTest.cs @@ -9,6 +9,18 @@ namespace ConnectionTest { public class ConnectionTests { + [Fact] + public void TestConnectionHappyPath() + { + var connection = MockLdapConnection.Get(ResponseBehavior.HappyPath); + var testResponse = connection.TestConnection(); + + Assert.True(testResponse.Success); + Assert.Null(testResponse.Exception); + + // TODO : check testResponse domain data properties + } + [Fact] public void TestConnectionNullResponse() { @@ -17,6 +29,7 @@ public void TestConnectionNullResponse() Assert.False(testResponse.Success); Assert.Null(testResponse.Exception); + Assert.Throws(() => connection.Bind()); } // This happens when a Kerberos misconfiguration occurs diff --git a/test/unit/Facades/MockLdapConnection.cs b/test/unit/Facades/MockLdapConnection.cs index 081e3c30..543b78f2 100644 --- a/test/unit/Facades/MockLdapConnection.cs +++ b/test/unit/Facades/MockLdapConnection.cs @@ -9,12 +9,40 @@ public static class MockLdapConnection public static LdapConnection Get(ResponseBehavior responseBehavior) => responseBehavior switch { + ResponseBehavior.HappyPath => HappyPathResponse(), ResponseBehavior.NullResponse => ReturnsNullResponse(), ResponseBehavior.EmptyResponse => ReturnsEmptyResponse(), ResponseBehavior.ThrowsLdapException => ThrowsLdapException(), _ => throw new ArgumentOutOfRangeException(nameof(responseBehavior)) }; + private static LdapConnection HappyPathResponse() + { + // Create a mock SearchResultEntry + var entryMock = new Mock("DN=MockEntry,DC=example,DC=com"); + // Add attributes to the entry if needed + entryMock.SetupGet(e => e.Attributes["cn"]).Returns(new DirectoryAttribute("MockEntry")); + + // TODO : add properties to entryMock as used by TestConnection + + // Create a mock SearchResultEntryCollection + var entryCollectionMock = new Mock(); + entryCollectionMock.Setup(e => e.GetEnumerator()).Returns(new[] { entryMock.Object }.GetEnumerator()); + entryCollectionMock.SetupGet(e => e.Count).Returns(1); + + // Create a mock SearchResponse + var searchResponseMock = new Mock(); + searchResponseMock.SetupGet(r => r.Entries).Returns(entryCollectionMock.Object); + + // Modify searchResponseMock to return entryMock when accessing Entries[] + searchResponseMock.SetupGet(r => r.Entries[It.IsAny()]).Returns(entryMock.Object); + + var connectionMock = new Mock(); + connectionMock.Setup(x => x.SendRequest(It.IsAny())) + .Returns(searchResponseMock.Object); + return connectionMock.Object; + } + private static LdapConnection ReturnsNullResponse() { var mock = new Mock(); @@ -43,6 +71,7 @@ private static LdapConnection ThrowsLdapException() public enum ResponseBehavior { + HappyPath, NullResponse, EmptyResponse, ThrowsLdapException, From 48067602c849af79525d09ce2e0e2cd90d65658d Mon Sep 17 00:00:00 2001 From: anemeth Date: Fri, 17 May 2024 14:26:27 -0700 Subject: [PATCH 5/5] 'Surprise' exception tests on TestConnection --- test/unit/ConnectionTest.cs | 19 ++++++++++++++++++- test/unit/Facades/MockLdapConnection.cs | 10 ++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/test/unit/ConnectionTest.cs b/test/unit/ConnectionTest.cs index ce04b5da..03c97068 100644 --- a/test/unit/ConnectionTest.cs +++ b/test/unit/ConnectionTest.cs @@ -18,7 +18,10 @@ public void TestConnectionHappyPath() Assert.True(testResponse.Success); Assert.Null(testResponse.Exception); - // TODO : check testResponse domain data properties + // TODO : check testResponse domain data properties? + // Not sure I should care about these implementation details tbh + // might be breaking that logic out, make easier to Mock + // tbd } [Fact] @@ -55,5 +58,19 @@ public void TestConnectionThrowsLdapException() Assert.IsType(testResponse.Exception); Assert.Throws(() => connection.Bind()); } + + [Fact] + public void TestConnectionThrowsOtherException() + { + var connection = MockLdapConnection.Get(ResponseBehavior.ThrowsLdapException); + var testResponse = connection.TestConnection(); + + // TODO : evaluate this behavior + // currently in TestConnection we raise any non-ldap exception up + // should we? + Assert.False(testResponse.Success); + Assert.NotNull(testResponse.Exception); + Assert.Throws(() => connection.Bind()); + } } } \ No newline at end of file diff --git a/test/unit/Facades/MockLdapConnection.cs b/test/unit/Facades/MockLdapConnection.cs index 543b78f2..96e64b4e 100644 --- a/test/unit/Facades/MockLdapConnection.cs +++ b/test/unit/Facades/MockLdapConnection.cs @@ -13,6 +13,7 @@ public static LdapConnection Get(ResponseBehavior responseBehavior) ResponseBehavior.NullResponse => ReturnsNullResponse(), ResponseBehavior.EmptyResponse => ReturnsEmptyResponse(), ResponseBehavior.ThrowsLdapException => ThrowsLdapException(), + ResponseBehavior.ThrowsOtherException => ThrowsOtherException(), _ => throw new ArgumentOutOfRangeException(nameof(responseBehavior)) }; @@ -67,6 +68,14 @@ private static LdapConnection ThrowsLdapException() .Throws(); return mock.Object; } + + private static LdapConnection ThrowsOtherException() + { + var mock = new Mock(); + mock.Setup(x => x.SendRequest(It.IsAny())) + .Throws(); + return mock.Object; + } } public enum ResponseBehavior @@ -75,5 +84,6 @@ public enum ResponseBehavior NullResponse, EmptyResponse, ThrowsLdapException, + ThrowsOtherException, } } \ No newline at end of file