| | 1 | | using System; |
| | 2 | | using System.Collections.Concurrent; |
| | 3 | | using System.Collections.Generic; |
| | 4 | | using System.Diagnostics; |
| | 5 | | using System.DirectoryServices; |
| | 6 | | using System.DirectoryServices.ActiveDirectory; |
| | 7 | | using System.DirectoryServices.Protocols; |
| | 8 | | using System.Linq; |
| | 9 | | using System.Net; |
| | 10 | | using System.Net.Sockets; |
| | 11 | | using System.Security.Principal; |
| | 12 | | using System.Text; |
| | 13 | | using System.Threading; |
| | 14 | | using System.Threading.Tasks; |
| | 15 | | using Microsoft.Extensions.Logging; |
| | 16 | | using SharpHoundCommonLib.Enums; |
| | 17 | | using SharpHoundCommonLib.Exceptions; |
| | 18 | | using SharpHoundCommonLib.LDAPQueries; |
| | 19 | | using SharpHoundCommonLib.OutputTypes; |
| | 20 | | using SharpHoundCommonLib.Processors; |
| | 21 | | using SharpHoundRPC.NetAPINative; |
| | 22 | | using Domain = System.DirectoryServices.ActiveDirectory.Domain; |
| | 23 | | using SearchScope = System.DirectoryServices.Protocols.SearchScope; |
| | 24 | | using SecurityMasks = System.DirectoryServices.Protocols.SecurityMasks; |
| | 25 | |
|
| | 26 | | namespace SharpHoundCommonLib |
| | 27 | | { |
| | 28 | | public class LDAPUtils : ILDAPUtils |
| | 29 | | { |
| | 30 | | private const string NullCacheKey = "UNIQUENULL"; |
| | 31 | |
|
| | 32 | | // The following byte stream contains the necessary message to request a NetBios name from a machine |
| | 33 | | // http://web.archive.org/web/20100409111218/http://msdn.microsoft.com/en-us/library/system.net.sockets.socket.a |
| 1 | 34 | | private static readonly byte[] NameRequest = |
| 1 | 35 | | { |
| 1 | 36 | | 0x80, 0x94, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, |
| 1 | 37 | | 0x00, 0x00, 0x00, 0x00, 0x20, 0x43, 0x4b, 0x41, |
| 1 | 38 | | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, |
| 1 | 39 | | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, |
| 1 | 40 | | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, |
| 1 | 41 | | 0x41, 0x41, 0x41, 0x41, 0x41, 0x00, 0x00, 0x21, |
| 1 | 42 | | 0x00, 0x01 |
| 1 | 43 | | }; |
| | 44 | |
|
| | 45 | |
|
| 1 | 46 | | private static readonly ConcurrentDictionary<string, ResolvedWellKnownPrincipal> |
| 1 | 47 | | SeenWellKnownPrincipals = new(); |
| | 48 | |
|
| 1 | 49 | | private static readonly ConcurrentDictionary<string, byte> DomainControllers = new(); |
| | 50 | |
|
| 64 | 51 | | private readonly ConcurrentDictionary<string, Domain> _domainCache = new(); |
| 64 | 52 | | private readonly ConcurrentDictionary<string, string> _domainControllerCache = new(); |
| 1 | 53 | | private static readonly TimeSpan MinBackoffDelay = TimeSpan.FromSeconds(2); |
| 1 | 54 | | private static readonly TimeSpan MaxBackoffDelay = TimeSpan.FromSeconds(20); |
| | 55 | | private const int BackoffDelayMultiplier = 2; |
| | 56 | | private const int MaxRetries = 3; |
| | 57 | |
|
| 64 | 58 | | private readonly ConcurrentDictionary<string, LdapConnection> _globalCatalogConnections = new(); |
| 64 | 59 | | private readonly ConcurrentDictionary<string, string> _hostResolutionMap = new(); |
| 64 | 60 | | private readonly ConcurrentDictionary<string, LdapConnection> _ldapConnections = new(); |
| 64 | 61 | | private readonly ConcurrentDictionary<string, int> _ldapRangeSizeCache = new(); |
| | 62 | | private readonly ILogger _log; |
| | 63 | | private readonly NativeMethods _nativeMethods; |
| 64 | 64 | | private readonly ConcurrentDictionary<string, string> _netbiosCache = new(); |
| | 65 | | private readonly PortScanner _portScanner; |
| 64 | 66 | | private LDAPConfig _ldapConfig = new(); |
| 64 | 67 | | private readonly ManualResetEvent _connectionResetEvent = new(false); |
| 64 | 68 | | private readonly object _lockObj = new(); |
| | 69 | |
|
| | 70 | |
|
| | 71 | | /// <summary> |
| | 72 | | /// Creates a new instance of LDAP Utils with defaults |
| | 73 | | /// </summary> |
| 64 | 74 | | public LDAPUtils() |
| 64 | 75 | | { |
| 64 | 76 | | _nativeMethods = new NativeMethods(); |
| 64 | 77 | | _portScanner = new PortScanner(); |
| 64 | 78 | | _log = Logging.LogProvider.CreateLogger("LDAPUtils"); |
| 64 | 79 | | } |
| | 80 | |
|
| | 81 | | /// <summary> |
| | 82 | | /// Creates a new instance of LDAP utils and allows overriding implementations |
| | 83 | | /// </summary> |
| | 84 | | /// <param name="nativeMethods"></param> |
| | 85 | | /// <param name="scanner"></param> |
| | 86 | | /// <param name="log"></param> |
| 0 | 87 | | public LDAPUtils(NativeMethods nativeMethods = null, PortScanner scanner = null, ILogger log = null) |
| 0 | 88 | | { |
| 0 | 89 | | _nativeMethods = nativeMethods ?? new NativeMethods(); |
| 0 | 90 | | _portScanner = scanner ?? new PortScanner(); |
| 0 | 91 | | _log = log ?? Logging.LogProvider.CreateLogger("LDAPUtils"); |
| 0 | 92 | | } |
| | 93 | |
|
| | 94 | | /// <summary> |
| | 95 | | /// Sets the configuration for LDAP queries |
| | 96 | | /// </summary> |
| | 97 | | /// <param name="config"></param> |
| | 98 | | /// <exception cref="Exception"></exception> |
| | 99 | | public void SetLDAPConfig(LDAPConfig config) |
| 0 | 100 | | { |
| 0 | 101 | | _ldapConfig = config ?? throw new Exception("LDAP Configuration can not be null"); |
| 0 | 102 | | _domainControllerCache.Clear(); |
| 0 | 103 | | foreach (var kv in _globalCatalogConnections) |
| 0 | 104 | | { |
| 0 | 105 | | kv.Value.Dispose(); |
| 0 | 106 | | } |
| | 107 | |
|
| 0 | 108 | | _globalCatalogConnections.Clear(); |
| 0 | 109 | | foreach (var kv in _ldapConnections) |
| 0 | 110 | | { |
| 0 | 111 | | kv.Value.Dispose(); |
| 0 | 112 | | } |
| | 113 | |
|
| 0 | 114 | | _ldapConnections.Clear(); |
| 0 | 115 | | } |
| | 116 | |
|
| | 117 | | /// <summary> |
| | 118 | | /// Turns a sid into a well known principal ID. |
| | 119 | | /// </summary> |
| | 120 | | /// <param name="sid"></param> |
| | 121 | | /// <param name="domain"></param> |
| | 122 | | /// <param name="commonPrincipal"></param> |
| | 123 | | /// <returns>True if a well known principal was identified, false if not</returns> |
| | 124 | | public bool GetWellKnownPrincipal(string sid, string domain, out TypedPrincipal commonPrincipal) |
| 4 | 125 | | { |
| 5 | 126 | | if (!WellKnownPrincipal.GetWellKnownPrincipal(sid, out commonPrincipal)) return false; |
| 3 | 127 | | var tempDomain = domain ?? GetDomain()?.Name ?? "UNKNOWN"; |
| 3 | 128 | | commonPrincipal.ObjectIdentifier = ConvertWellKnownPrincipal(sid, tempDomain); |
| 3 | 129 | | SeenWellKnownPrincipals.TryAdd(commonPrincipal.ObjectIdentifier, new ResolvedWellKnownPrincipal |
| 3 | 130 | | { |
| 3 | 131 | | DomainName = domain, |
| 3 | 132 | | WkpId = sid |
| 3 | 133 | | }); |
| 3 | 134 | | return true; |
| 4 | 135 | | } |
| | 136 | |
|
| | 137 | | /// <summary> |
| | 138 | | /// Adds a SID to an internal list of domain controllers |
| | 139 | | /// </summary> |
| | 140 | | /// <param name="domainControllerSID"></param> |
| | 141 | | public void AddDomainController(string domainControllerSID) |
| 0 | 142 | | { |
| 0 | 143 | | DomainControllers.TryAdd(domainControllerSID, new byte()); |
| 0 | 144 | | } |
| | 145 | |
|
| | 146 | | /// <summary> |
| | 147 | | /// Gets output objects for currently observed well known principals |
| | 148 | | /// </summary> |
| | 149 | | /// <returns></returns> |
| | 150 | | /// <exception cref="ArgumentOutOfRangeException"></exception> |
| | 151 | | public IEnumerable<OutputBase> GetWellKnownPrincipalOutput(string domain) |
| 0 | 152 | | { |
| 0 | 153 | | foreach (var wkp in SeenWellKnownPrincipals) |
| 0 | 154 | | { |
| 0 | 155 | | WellKnownPrincipal.GetWellKnownPrincipal(wkp.Value.WkpId, out var principal); |
| 0 | 156 | | OutputBase output = principal.ObjectType switch |
| 0 | 157 | | { |
| 0 | 158 | | Label.User => new User(), |
| 0 | 159 | | Label.Computer => new Computer(), |
| 0 | 160 | | Label.Group => new Group(), |
| 0 | 161 | | Label.GPO => new GPO(), |
| 0 | 162 | | Label.Domain => new OutputTypes.Domain(), |
| 0 | 163 | | Label.OU => new OU(), |
| 0 | 164 | | Label.Container => new Container(), |
| 0 | 165 | | Label.Configuration => new Container(), |
| 0 | 166 | | _ => throw new ArgumentOutOfRangeException() |
| 0 | 167 | | }; |
| | 168 | |
|
| 0 | 169 | | output.Properties.Add("name", $"{principal.ObjectIdentifier}@{wkp.Value.DomainName}".ToUpper()); |
| 0 | 170 | | var domainSid = GetSidFromDomainName(wkp.Value.DomainName); |
| 0 | 171 | | output.Properties.Add("domainsid", domainSid); |
| 0 | 172 | | output.Properties.Add("domain", wkp.Value.DomainName.ToUpper()); |
| 0 | 173 | | output.ObjectIdentifier = wkp.Key; |
| 0 | 174 | | yield return output; |
| 0 | 175 | | } |
| | 176 | |
|
| 0 | 177 | | var entdc = GetBaseEnterpriseDC(domain); |
| 0 | 178 | | entdc.Members = DomainControllers.Select(x => new TypedPrincipal(x.Key, Label.Computer)).ToArray(); |
| 0 | 179 | | yield return entdc; |
| 0 | 180 | | } |
| | 181 | |
|
| | 182 | | /// <summary> |
| | 183 | | /// Converts a |
| | 184 | | /// </summary> |
| | 185 | | /// <param name="sid"></param> |
| | 186 | | /// <param name="domain"></param> |
| | 187 | | /// <returns></returns> |
| | 188 | | public string ConvertWellKnownPrincipal(string sid, string domain) |
| 3 | 189 | | { |
| 3 | 190 | | if (!WellKnownPrincipal.GetWellKnownPrincipal(sid, out _)) return sid; |
| | 191 | |
|
| 5 | 192 | | if (sid != "S-1-5-9") return $"{domain}-{sid}".ToUpper(); |
| | 193 | |
|
| 1 | 194 | | var forest = GetForest(domain)?.Name; |
| 1 | 195 | | if (forest == null) _log.LogWarning("Error getting forest, ENTDC sid is likely incorrect"); |
| 1 | 196 | | return $"{forest ?? "UNKNOWN"}-{sid}".ToUpper(); |
| 3 | 197 | | } |
| | 198 | |
|
| | 199 | | /// <summary> |
| | 200 | | /// Queries the global catalog to get potential SID matches for a username in the forest |
| | 201 | | /// </summary> |
| | 202 | | /// <param name="name"></param> |
| | 203 | | /// <returns></returns> |
| | 204 | | public string[] GetUserGlobalCatalogMatches(string name) |
| 1 | 205 | | { |
| 1 | 206 | | var tempName = name.ToLower(); |
| 1 | 207 | | if (Cache.GetGCCache(tempName, out var sids)) |
| 0 | 208 | | return sids; |
| | 209 | |
|
| 1 | 210 | | var query = new LDAPFilter().AddUsers($"samaccountname={tempName}").GetFilter(); |
| 1 | 211 | | var results = QueryLDAP(query, SearchScope.Subtree, new[] { "objectsid" }, globalCatalog: true) |
| 1 | 212 | | .Select(x => x.GetSid()).Where(x => x != null).ToArray(); |
| 1 | 213 | | Cache.AddGCCache(tempName, results); |
| 1 | 214 | | return results; |
| 1 | 215 | | } |
| | 216 | |
|
| | 217 | | /// <summary> |
| | 218 | | /// Uses an LDAP lookup to attempt to find the Label for a given SID |
| | 219 | | /// Will also convert to a well known principal ID if needed |
| | 220 | | /// </summary> |
| | 221 | | /// <param name="id"></param> |
| | 222 | | /// <param name="fallbackDomain"></param> |
| | 223 | | /// <returns></returns> |
| | 224 | | public TypedPrincipal ResolveIDAndType(string id, string fallbackDomain) |
| 2 | 225 | | { |
| | 226 | | //This is a duplicated SID object which is weird and makes things unhappy. Throw it out |
| 2 | 227 | | if (id.Contains("0ACNF")) |
| 1 | 228 | | return null; |
| | 229 | |
|
| 1 | 230 | | if (GetWellKnownPrincipal(id, fallbackDomain, out var principal)) |
| 1 | 231 | | return principal; |
| | 232 | |
|
| 0 | 233 | | var type = id.StartsWith("S-") ? LookupSidType(id, fallbackDomain) : LookupGuidType(id, fallbackDomain); |
| 0 | 234 | | return new TypedPrincipal(id, type); |
| 2 | 235 | | } |
| | 236 | |
|
| | 237 | | public TypedPrincipal ResolveCertTemplateByProperty(string propValue, string propertyName, string containerDN, |
| | 238 | | string domainName) |
| 0 | 239 | | { |
| 0 | 240 | | var filter = new LDAPFilter().AddCertificateTemplates().AddFilter(propertyName + "=" + propValue, true); |
| 0 | 241 | | var res = QueryLDAP(filter.GetFilter(), SearchScope.OneLevel, |
| 0 | 242 | | CommonProperties.TypeResolutionProps, adsPath: containerDN, domainName: domainName); |
| | 243 | |
|
| 0 | 244 | | if (res == null) |
| 0 | 245 | | { |
| 0 | 246 | | _log.LogWarning( |
| 0 | 247 | | "Could not find certificate template with '{propertyName}:{propValue}' under {containerDN}; null res |
| 0 | 248 | | propertyName, propValue, containerDN); |
| 0 | 249 | | return null; |
| | 250 | | } |
| | 251 | |
|
| 0 | 252 | | List<ISearchResultEntry> resList = new List<ISearchResultEntry>(res); |
| 0 | 253 | | if (resList.Count == 0) |
| 0 | 254 | | { |
| 0 | 255 | | _log.LogWarning( |
| 0 | 256 | | "Could not find certificate template with '{propertyName}:{propValue}' under {containerDN}; empty li |
| 0 | 257 | | propertyName, propValue, containerDN); |
| 0 | 258 | | return null; |
| | 259 | | } |
| | 260 | |
|
| 0 | 261 | | if (resList.Count > 1) |
| 0 | 262 | | { |
| 0 | 263 | | _log.LogWarning( |
| 0 | 264 | | "Found more than one certificate template with '{propertyName}:{propValue}' under {containerDN}", |
| 0 | 265 | | propertyName, propValue, containerDN); |
| 0 | 266 | | return null; |
| | 267 | | } |
| | 268 | |
|
| 0 | 269 | | ISearchResultEntry searchResultEntry = resList.FirstOrDefault(); |
| 0 | 270 | | return new TypedPrincipal(searchResultEntry.GetGuid(), Label.CertTemplate); |
| 0 | 271 | | } |
| | 272 | |
|
| | 273 | | /// <summary> |
| | 274 | | /// Attempts to lookup the Label for a sid |
| | 275 | | /// </summary> |
| | 276 | | /// <param name="sid"></param> |
| | 277 | | /// <param name="domain"></param> |
| | 278 | | /// <returns></returns> |
| | 279 | | public Label LookupSidType(string sid, string domain) |
| 0 | 280 | | { |
| 0 | 281 | | if (Cache.GetIDType(sid, out var type)) |
| 0 | 282 | | return type; |
| | 283 | |
|
| 0 | 284 | | var rDomain = GetDomainNameFromSid(sid) ?? domain; |
| | 285 | |
|
| 0 | 286 | | var result = |
| 0 | 287 | | QueryLDAP(CommonFilters.SpecificSID(sid), SearchScope.Subtree, CommonProperties.TypeResolutionProps, |
| 0 | 288 | | rDomain) |
| 0 | 289 | | .DefaultIfEmpty(null).FirstOrDefault(); |
| | 290 | |
|
| 0 | 291 | | type = result?.GetLabel() ?? Label.Base; |
| 0 | 292 | | Cache.AddType(sid, type); |
| 0 | 293 | | return type; |
| 0 | 294 | | } |
| | 295 | |
|
| | 296 | | /// <summary> |
| | 297 | | /// Attempts to lookup the Label for a GUID |
| | 298 | | /// </summary> |
| | 299 | | /// <param name="guid"></param> |
| | 300 | | /// <param name="domain"></param> |
| | 301 | | /// <returns></returns> |
| | 302 | | public Label LookupGuidType(string guid, string domain) |
| 0 | 303 | | { |
| 0 | 304 | | if (Cache.GetIDType(guid, out var type)) |
| 0 | 305 | | return type; |
| | 306 | |
|
| 0 | 307 | | var hex = Helpers.ConvertGuidToHexGuid(guid); |
| 0 | 308 | | if (hex == null) |
| 0 | 309 | | return Label.Base; |
| | 310 | |
|
| 0 | 311 | | var result = |
| 0 | 312 | | QueryLDAP($"(objectguid={hex})", SearchScope.Subtree, CommonProperties.TypeResolutionProps, domain) |
| 0 | 313 | | .DefaultIfEmpty(null).FirstOrDefault(); |
| | 314 | |
|
| 0 | 315 | | type = result?.GetLabel() ?? Label.Base; |
| 0 | 316 | | Cache.AddType(guid, type); |
| 0 | 317 | | return type; |
| 0 | 318 | | } |
| | 319 | |
|
| | 320 | | /// <summary> |
| | 321 | | /// Attempts to find the domain associated with a SID |
| | 322 | | /// </summary> |
| | 323 | | /// <param name="sid"></param> |
| | 324 | | /// <returns></returns> |
| | 325 | | public string GetDomainNameFromSid(string sid) |
| 0 | 326 | | { |
| | 327 | | try |
| 0 | 328 | | { |
| 0 | 329 | | var parsedSid = new SecurityIdentifier(sid); |
| 0 | 330 | | var domainSid = parsedSid.AccountDomainSid?.Value.ToUpper(); |
| 0 | 331 | | if (domainSid == null) |
| 0 | 332 | | return null; |
| | 333 | |
|
| 0 | 334 | | _log.LogDebug("Resolving sid {DomainSid}", domainSid); |
| | 335 | |
|
| 0 | 336 | | if (Cache.GetDomainSidMapping(domainSid, out var domain)) |
| 0 | 337 | | return domain; |
| | 338 | |
|
| 0 | 339 | | _log.LogDebug("No cache hit for {DomainSid}", domainSid); |
| 0 | 340 | | domain = GetDomainNameFromSidLdap(domainSid); |
| 0 | 341 | | _log.LogDebug("Resolved to {Domain}", domain); |
| | 342 | |
|
| | 343 | | //Cache both to and from so we can use this later |
| 0 | 344 | | if (domain != null) |
| 0 | 345 | | { |
| 0 | 346 | | Cache.AddSidToDomain(domainSid, domain); |
| 0 | 347 | | Cache.AddSidToDomain(domain, domainSid); |
| 0 | 348 | | } |
| | 349 | |
|
| 0 | 350 | | return domain; |
| | 351 | | } |
| 0 | 352 | | catch |
| 0 | 353 | | { |
| 0 | 354 | | return null; |
| | 355 | | } |
| 0 | 356 | | } |
| | 357 | |
|
| | 358 | | /// <summary> |
| | 359 | | /// Attempts to get the SID associated with a domain name |
| | 360 | | /// </summary> |
| | 361 | | /// <param name="domainName"></param> |
| | 362 | | /// <returns></returns> |
| | 363 | | public string GetSidFromDomainName(string domainName) |
| 0 | 364 | | { |
| 0 | 365 | | var tempDomainName = NormalizeDomainName(domainName); |
| 0 | 366 | | if (tempDomainName == null) |
| 0 | 367 | | return null; |
| 0 | 368 | | if (Cache.GetDomainSidMapping(tempDomainName, out var sid)) return sid; |
| | 369 | |
|
| 0 | 370 | | var domainObj = GetDomain(tempDomainName); |
| | 371 | |
|
| 0 | 372 | | if (domainObj != null) |
| 0 | 373 | | sid = domainObj.GetDirectoryEntry().GetSid(); |
| | 374 | | else |
| 0 | 375 | | sid = null; |
| | 376 | |
|
| 0 | 377 | | if (sid != null) |
| 0 | 378 | | { |
| 0 | 379 | | Cache.AddSidToDomain(sid, tempDomainName); |
| 0 | 380 | | Cache.AddSidToDomain(tempDomainName, sid); |
| 0 | 381 | | } |
| | 382 | |
|
| 0 | 383 | | return sid; |
| 0 | 384 | | } |
| | 385 | |
|
| | 386 | | // Saving this code for an eventual async implementation |
| | 387 | | // public async IAsyncEnumerable<string> DoRangedRetrievalAsync(string distinguishedName, string attributeName) |
| | 388 | | // { |
| | 389 | | // var domainName = Helpers.DistinguishedNameToDomain(distinguishedName); |
| | 390 | | // LdapConnection conn; |
| | 391 | | // try |
| | 392 | | // { |
| | 393 | | // conn = await CreateLDAPConnection(domainName, authType: _ldapConfig.AuthType); |
| | 394 | | // } |
| | 395 | | // catch |
| | 396 | | // { |
| | 397 | | // yield break; |
| | 398 | | // } |
| | 399 | | // |
| | 400 | | // if (conn == null) |
| | 401 | | // yield break; |
| | 402 | | // |
| | 403 | | // var index = 0; |
| | 404 | | // var step = 0; |
| | 405 | | // var currentRange = $"{attributeName};range={index}-*"; |
| | 406 | | // var complete = false; |
| | 407 | | // |
| | 408 | | // var searchRequest = CreateSearchRequest($"{attributeName}=*", SearchScope.Base, new[] {currentRange}, |
| | 409 | | // domainName, distinguishedName); |
| | 410 | | // |
| | 411 | | // var backoffDelay = MinBackoffDelay; |
| | 412 | | // var retryCount = 0; |
| | 413 | | // |
| | 414 | | // while (true) |
| | 415 | | // { |
| | 416 | | // DirectoryResponse searchResult; |
| | 417 | | // try |
| | 418 | | // { |
| | 419 | | // searchResult = await Task.Factory.FromAsync(conn.BeginSendRequest, conn.EndSendRequest, |
| | 420 | | // searchRequest, |
| | 421 | | // PartialResultProcessing.NoPartialResultSupport, null); |
| | 422 | | // } |
| | 423 | | // catch (LdapException le) when (le.ErrorCode == 51 && retryCount < MaxRetries) |
| | 424 | | // { |
| | 425 | | // //Allow three retries with a backoff on each one if we get a "Server is Busy" error |
| | 426 | | // retryCount++; |
| | 427 | | // await Task.Delay(backoffDelay); |
| | 428 | | // backoffDelay = TimeSpan.FromSeconds(Math.Min( |
| | 429 | | // backoffDelay.TotalSeconds * BackoffDelayMultiplier.TotalSeconds, MaxBackoffDelay.TotalSeconds |
| | 430 | | // continue; |
| | 431 | | // } |
| | 432 | | // catch (Exception e) |
| | 433 | | // { |
| | 434 | | // _log.LogWarning(e,"Caught exception during ranged retrieval for {DN}", distinguishedName); |
| | 435 | | // yield break; |
| | 436 | | // } |
| | 437 | | // |
| | 438 | | // if (searchResult is SearchResponse response && response.Entries.Count == 1) |
| | 439 | | // { |
| | 440 | | // var entry = response.Entries[0]; |
| | 441 | | // var attributeNames = entry?.Attributes?.AttributeNames; |
| | 442 | | // if (attributeNames != null) |
| | 443 | | // { |
| | 444 | | // foreach (string attr in attributeNames) |
| | 445 | | // { |
| | 446 | | // //Set our current range to the name of the attribute, which will tell us how far we are i |
| | 447 | | // currentRange = attr; |
| | 448 | | // //Check if the string has the * character in it. If it does, we've reached the end of thi |
| | 449 | | // complete = currentRange.IndexOf("*", 0, StringComparison.Ordinal) > 0; |
| | 450 | | // //Set our step to the number of attributes that came back. |
| | 451 | | // step = entry.Attributes[currentRange].Count; |
| | 452 | | // } |
| | 453 | | // } |
| | 454 | | // |
| | 455 | | // |
| | 456 | | // foreach (string val in entry.Attributes[currentRange].GetValues(typeof(string))) |
| | 457 | | // { |
| | 458 | | // yield return val; |
| | 459 | | // index++; |
| | 460 | | // } |
| | 461 | | // |
| | 462 | | // if (complete) yield break; |
| | 463 | | // |
| | 464 | | // currentRange = $"{attributeName};range={index}-{index + step}"; |
| | 465 | | // searchRequest.Attributes.Clear(); |
| | 466 | | // searchRequest.Attributes.Add(currentRange); |
| | 467 | | // } |
| | 468 | | // else |
| | 469 | | // { |
| | 470 | | // yield break; |
| | 471 | | // } |
| | 472 | | // } |
| | 473 | | // } |
| | 474 | |
|
| | 475 | | /// <summary> |
| | 476 | | /// Performs Attribute Ranged Retrieval |
| | 477 | | /// https://docs.microsoft.com/en-us/windows/win32/adsi/attribute-range-retrieval |
| | 478 | | /// The function self-determines the range and internally handles the maximum step allowed by the server |
| | 479 | | /// </summary> |
| | 480 | | /// <param name="distinguishedName"></param> |
| | 481 | | /// <param name="attributeName"></param> |
| | 482 | | /// <returns></returns> |
| | 483 | | public IEnumerable<string> DoRangedRetrieval(string distinguishedName, string attributeName) |
| 0 | 484 | | { |
| 0 | 485 | | var domainName = Helpers.DistinguishedNameToDomain(distinguishedName); |
| 0 | 486 | | var task = Task.Run(() => CreateLDAPConnection(domainName, authType: _ldapConfig.AuthType)); |
| | 487 | |
|
| | 488 | | LdapConnection conn; |
| | 489 | |
|
| | 490 | | try |
| 0 | 491 | | { |
| 0 | 492 | | conn = task.ConfigureAwait(false).GetAwaiter().GetResult(); |
| 0 | 493 | | } |
| 0 | 494 | | catch |
| 0 | 495 | | { |
| 0 | 496 | | yield break; |
| | 497 | | } |
| | 498 | |
|
| 0 | 499 | | if (conn == null) |
| 0 | 500 | | yield break; |
| | 501 | |
|
| 0 | 502 | | var index = 0; |
| 0 | 503 | | var step = 0; |
| 0 | 504 | | var baseString = $"{attributeName}"; |
| | 505 | | //Example search string: member;range=0-1000 |
| 0 | 506 | | var currentRange = $"{baseString};range={index}-*"; |
| 0 | 507 | | var complete = false; |
| | 508 | |
|
| 0 | 509 | | var searchRequest = CreateSearchRequest($"{attributeName}=*", SearchScope.Base, new[] { currentRange }, |
| 0 | 510 | | domainName, distinguishedName); |
| | 511 | |
|
| 0 | 512 | | if (searchRequest == null) |
| 0 | 513 | | yield break; |
| | 514 | |
|
| 0 | 515 | | var backoffDelay = MinBackoffDelay; |
| 0 | 516 | | var retryCount = 0; |
| | 517 | |
|
| 0 | 518 | | while (true) |
| 0 | 519 | | { |
| | 520 | | SearchResponse response; |
| | 521 | | try |
| 0 | 522 | | { |
| 0 | 523 | | response = (SearchResponse)conn.SendRequest(searchRequest); |
| 0 | 524 | | } |
| 0 | 525 | | catch (LdapException le) when (le.ErrorCode == (int)LdapErrorCodes.Busy && retryCount < MaxRetries) |
| 0 | 526 | | { |
| | 527 | | //Allow three retries with a backoff on each one if we get a "Server is Busy" error |
| 0 | 528 | | retryCount++; |
| 0 | 529 | | Thread.Sleep(backoffDelay); |
| 0 | 530 | | backoffDelay = GetNextBackoff(retryCount); |
| 0 | 531 | | continue; |
| | 532 | | } |
| 0 | 533 | | catch (Exception e) |
| 0 | 534 | | { |
| 0 | 535 | | _log.LogError(e, "Error doing ranged retrieval for {Attribute} on {Dn}", attributeName, |
| 0 | 536 | | distinguishedName); |
| 0 | 537 | | yield break; |
| | 538 | | } |
| | 539 | |
|
| | 540 | | //If we ever get more than one response from here, something is horribly wrong |
| 0 | 541 | | if (response?.Entries.Count == 1) |
| 0 | 542 | | { |
| 0 | 543 | | var entry = response.Entries[0]; |
| | 544 | | //Process the attribute we get back to determine a few things |
| 0 | 545 | | foreach (string attr in entry.Attributes.AttributeNames) |
| 0 | 546 | | { |
| | 547 | | //Set our current range to the name of the attribute, which will tell us how far we are in "pagi |
| 0 | 548 | | currentRange = attr; |
| | 549 | | //Check if the string has the * character in it. If it does, we've reached the end of this searc |
| 0 | 550 | | complete = currentRange.IndexOf("*", 0, StringComparison.Ordinal) > 0; |
| | 551 | | //Set our step to the number of attributes that came back. |
| 0 | 552 | | step = entry.Attributes[currentRange].Count; |
| 0 | 553 | | } |
| | 554 | |
|
| 0 | 555 | | foreach (string val in entry.Attributes[currentRange].GetValues(typeof(string))) |
| 0 | 556 | | { |
| 0 | 557 | | yield return val; |
| 0 | 558 | | index++; |
| 0 | 559 | | } |
| | 560 | |
|
| 0 | 561 | | if (complete) yield break; |
| | 562 | |
|
| 0 | 563 | | currentRange = $"{baseString};range={index}-{index + step}"; |
| 0 | 564 | | searchRequest.Attributes.Clear(); |
| 0 | 565 | | searchRequest.Attributes.Add(currentRange); |
| 0 | 566 | | } |
| | 567 | | else |
| 0 | 568 | | { |
| | 569 | | //Something went wrong here. |
| 0 | 570 | | yield break; |
| | 571 | | } |
| 0 | 572 | | } |
| | 573 | | } |
| | 574 | |
|
| | 575 | | /// <summary> |
| | 576 | | /// Takes a host in most applicable forms from AD and attempts to resolve it into a SID. |
| | 577 | | /// </summary> |
| | 578 | | /// <param name="hostname"></param> |
| | 579 | | /// <param name="domain"></param> |
| | 580 | | /// <returns></returns> |
| | 581 | | public async Task<string> ResolveHostToSid(string hostname, string domain) |
| 0 | 582 | | { |
| 0 | 583 | | var strippedHost = Helpers.StripServicePrincipalName(hostname).ToUpper().TrimEnd('$'); |
| 0 | 584 | | if (string.IsNullOrEmpty(strippedHost)) |
| 0 | 585 | | { |
| 0 | 586 | | return null; |
| | 587 | | } |
| | 588 | |
|
| 0 | 589 | | if (_hostResolutionMap.TryGetValue(strippedHost, out var sid)) return sid; |
| | 590 | |
|
| 0 | 591 | | var normalDomain = NormalizeDomainName(domain); |
| | 592 | |
|
| | 593 | | string tempName; |
| 0 | 594 | | string tempDomain = null; |
| | 595 | |
|
| | 596 | | //Step 1: Handle non-IP address values |
| 0 | 597 | | if (!IPAddress.TryParse(strippedHost, out _)) |
| 0 | 598 | | { |
| | 599 | | // Format: ABC.TESTLAB.LOCAL |
| 0 | 600 | | if (strippedHost.Contains(".")) |
| 0 | 601 | | { |
| 0 | 602 | | var split = strippedHost.Split('.'); |
| 0 | 603 | | tempName = split[0]; |
| 0 | 604 | | tempDomain = string.Join(".", split.Skip(1).ToArray()); |
| 0 | 605 | | } |
| | 606 | | // Format: WINDOWS |
| | 607 | | else |
| 0 | 608 | | { |
| 0 | 609 | | tempName = strippedHost; |
| 0 | 610 | | tempDomain = normalDomain; |
| 0 | 611 | | } |
| | 612 | |
|
| | 613 | | // Add $ to the end of the name to match how computers are stored in AD |
| 0 | 614 | | tempName = $"{tempName}$".ToUpper(); |
| 0 | 615 | | var principal = ResolveAccountName(tempName, tempDomain); |
| 0 | 616 | | sid = principal?.ObjectIdentifier; |
| 0 | 617 | | if (sid != null) |
| 0 | 618 | | { |
| 0 | 619 | | _hostResolutionMap.TryAdd(strippedHost, sid); |
| 0 | 620 | | return sid; |
| | 621 | | } |
| 0 | 622 | | } |
| | 623 | |
|
| | 624 | | //Step 2: Try NetWkstaGetInfo |
| | 625 | | //Next we'll try calling NetWkstaGetInfo in hopes of getting the NETBIOS name directly from the computer |
| | 626 | | //We'll use the hostname that we started with instead of the one from our previous step |
| 0 | 627 | | var workstationInfo = await GetWorkstationInfo(strippedHost); |
| 0 | 628 | | if (workstationInfo.HasValue) |
| 0 | 629 | | { |
| 0 | 630 | | tempName = workstationInfo.Value.ComputerName; |
| 0 | 631 | | tempDomain = workstationInfo.Value.LanGroup; |
| | 632 | |
|
| 0 | 633 | | if (string.IsNullOrEmpty(tempDomain)) |
| 0 | 634 | | tempDomain = normalDomain; |
| | 635 | |
|
| 0 | 636 | | if (!string.IsNullOrEmpty(tempName)) |
| 0 | 637 | | { |
| | 638 | | //Append the $ to indicate this is a computer |
| 0 | 639 | | tempName = $"{tempName}$".ToUpper(); |
| 0 | 640 | | var principal = ResolveAccountName(tempName, tempDomain); |
| 0 | 641 | | sid = principal?.ObjectIdentifier; |
| 0 | 642 | | if (sid != null) |
| 0 | 643 | | { |
| 0 | 644 | | _hostResolutionMap.TryAdd(strippedHost, sid); |
| 0 | 645 | | return sid; |
| | 646 | | } |
| 0 | 647 | | } |
| 0 | 648 | | } |
| | 649 | |
|
| | 650 | | //Step 3: Socket magic |
| | 651 | | // Attempt to request the NETBIOS name of the computer directly |
| 0 | 652 | | if (RequestNETBIOSNameFromComputer(strippedHost, normalDomain, out tempName)) |
| 0 | 653 | | { |
| 0 | 654 | | tempDomain ??= normalDomain; |
| 0 | 655 | | tempName = $"{tempName}$".ToUpper(); |
| | 656 | |
|
| 0 | 657 | | var principal = ResolveAccountName(tempName, tempDomain); |
| 0 | 658 | | sid = principal?.ObjectIdentifier; |
| 0 | 659 | | if (sid != null) |
| 0 | 660 | | { |
| 0 | 661 | | _hostResolutionMap.TryAdd(strippedHost, sid); |
| 0 | 662 | | return sid; |
| | 663 | | } |
| 0 | 664 | | } |
| | 665 | |
|
| | 666 | | //Try DNS resolution next |
| | 667 | | string resolvedHostname; |
| | 668 | | try |
| 0 | 669 | | { |
| 0 | 670 | | resolvedHostname = (await Dns.GetHostEntryAsync(strippedHost)).HostName; |
| 0 | 671 | | } |
| 0 | 672 | | catch |
| 0 | 673 | | { |
| 0 | 674 | | resolvedHostname = null; |
| 0 | 675 | | } |
| | 676 | |
|
| 0 | 677 | | if (resolvedHostname != null) |
| 0 | 678 | | { |
| 0 | 679 | | var splitName = resolvedHostname.Split('.'); |
| 0 | 680 | | tempName = $"{splitName[0]}$".ToUpper(); |
| 0 | 681 | | tempDomain = string.Join(".", splitName.Skip(1)); |
| | 682 | |
|
| 0 | 683 | | var principal = ResolveAccountName(tempName, tempDomain); |
| 0 | 684 | | sid = principal?.ObjectIdentifier; |
| 0 | 685 | | if (sid != null) |
| 0 | 686 | | { |
| 0 | 687 | | _hostResolutionMap.TryAdd(strippedHost, sid); |
| 0 | 688 | | return sid; |
| | 689 | | } |
| 0 | 690 | | } |
| | 691 | |
|
| | 692 | | //If we get here, everything has failed, and life is very sad. |
| 0 | 693 | | tempName = strippedHost; |
| 0 | 694 | | tempDomain = normalDomain; |
| | 695 | |
|
| 0 | 696 | | if (tempName.Contains(".")) |
| 0 | 697 | | { |
| 0 | 698 | | _hostResolutionMap.TryAdd(strippedHost, tempName); |
| 0 | 699 | | return tempName; |
| | 700 | | } |
| | 701 | |
|
| 0 | 702 | | tempName = $"{tempName}.{tempDomain}"; |
| 0 | 703 | | _hostResolutionMap.TryAdd(strippedHost, tempName); |
| 0 | 704 | | return tempName; |
| 0 | 705 | | } |
| | 706 | |
|
| | 707 | | /// <summary> |
| | 708 | | /// Attempts to convert a bare account name (usually from session enumeration) to its corresponding ID and o |
| | 709 | | /// </summary> |
| | 710 | | /// <param name="name"></param> |
| | 711 | | /// <param name="domain"></param> |
| | 712 | | /// <returns></returns> |
| | 713 | | public TypedPrincipal ResolveAccountName(string name, string domain) |
| 0 | 714 | | { |
| 0 | 715 | | if (string.IsNullOrWhiteSpace(name)) |
| 0 | 716 | | return null; |
| | 717 | |
|
| 0 | 718 | | if (Cache.GetPrefixedValue(name, domain, out var id) && Cache.GetIDType(id, out var type)) |
| 0 | 719 | | return new TypedPrincipal |
| 0 | 720 | | { |
| 0 | 721 | | ObjectIdentifier = id, |
| 0 | 722 | | ObjectType = type |
| 0 | 723 | | }; |
| | 724 | |
|
| 0 | 725 | | var d = NormalizeDomainName(domain); |
| 0 | 726 | | var result = QueryLDAP($"(samaccountname={name})", SearchScope.Subtree, |
| 0 | 727 | | CommonProperties.TypeResolutionProps, |
| 0 | 728 | | d).DefaultIfEmpty(null).FirstOrDefault(); |
| | 729 | |
|
| 0 | 730 | | if (result == null) |
| 0 | 731 | | { |
| 0 | 732 | | _log.LogDebug("ResolveAccountName - unable to get result for {Name}", name); |
| 0 | 733 | | return null; |
| | 734 | | } |
| | 735 | |
|
| 0 | 736 | | type = result.GetLabel(); |
| 0 | 737 | | id = result.GetObjectIdentifier(); |
| | 738 | |
|
| 0 | 739 | | if (id == null) |
| 0 | 740 | | { |
| 0 | 741 | | _log.LogDebug("ResolveAccountName - could not retrieve ID on {DN} for {Name}", result.DistinguishedName, |
| 0 | 742 | | name); |
| 0 | 743 | | return null; |
| | 744 | | } |
| | 745 | |
|
| 0 | 746 | | Cache.AddPrefixedValue(name, domain, id); |
| 0 | 747 | | Cache.AddType(id, type); |
| | 748 | |
|
| 0 | 749 | | id = ConvertWellKnownPrincipal(id, domain); |
| | 750 | |
|
| 0 | 751 | | return new TypedPrincipal |
| 0 | 752 | | { |
| 0 | 753 | | ObjectIdentifier = id, |
| 0 | 754 | | ObjectType = type |
| 0 | 755 | | }; |
| 0 | 756 | | } |
| | 757 | |
|
| | 758 | | /// <summary> |
| | 759 | | /// Attempts to convert a distinguishedname to its corresponding ID and object type. |
| | 760 | | /// </summary> |
| | 761 | | /// <param name="dn">DistinguishedName</param> |
| | 762 | | /// <returns>A <c>TypedPrincipal</c> object with the SID and Label</returns> |
| | 763 | | public TypedPrincipal ResolveDistinguishedName(string dn) |
| 0 | 764 | | { |
| 0 | 765 | | if (Cache.GetConvertedValue(dn, out var id) && Cache.GetIDType(id, out var type)) |
| 0 | 766 | | return new TypedPrincipal |
| 0 | 767 | | { |
| 0 | 768 | | ObjectIdentifier = id, |
| 0 | 769 | | ObjectType = type |
| 0 | 770 | | }; |
| | 771 | |
|
| 0 | 772 | | var domain = Helpers.DistinguishedNameToDomain(dn); |
| 0 | 773 | | var result = QueryLDAP("(objectclass=*)", SearchScope.Base, CommonProperties.TypeResolutionProps, domain, |
| 0 | 774 | | adsPath: dn) |
| 0 | 775 | | .DefaultIfEmpty(null).FirstOrDefault(); |
| | 776 | |
|
| 0 | 777 | | if (result == null) |
| 0 | 778 | | { |
| 0 | 779 | | _log.LogDebug("ResolveDistinguishedName - No result for {DN}", dn); |
| 0 | 780 | | return null; |
| | 781 | | } |
| | 782 | |
|
| 0 | 783 | | id = result.GetObjectIdentifier(); |
| 0 | 784 | | if (id == null) |
| 0 | 785 | | { |
| 0 | 786 | | _log.LogDebug("ResolveDistinguishedName - could not retrieve object identifier from {DN}", dn); |
| 0 | 787 | | return null; |
| | 788 | | } |
| | 789 | |
|
| 0 | 790 | | if (GetWellKnownPrincipal(id, domain, out var principal)) return principal; |
| | 791 | |
|
| 0 | 792 | | type = result.GetLabel(); |
| | 793 | |
|
| 0 | 794 | | Cache.AddConvertedValue(dn, id); |
| 0 | 795 | | Cache.AddType(id, type); |
| | 796 | |
|
| 0 | 797 | | id = ConvertWellKnownPrincipal(id, domain); |
| | 798 | |
|
| 0 | 799 | | return new TypedPrincipal |
| 0 | 800 | | { |
| 0 | 801 | | ObjectIdentifier = id, |
| 0 | 802 | | ObjectType = type |
| 0 | 803 | | }; |
| 0 | 804 | | } |
| | 805 | |
|
| | 806 | | /// <summary> |
| | 807 | | /// Queries LDAP using LDAPQueryOptions |
| | 808 | | /// </summary> |
| | 809 | | /// <param name="options"></param> |
| | 810 | | /// <returns></returns> |
| | 811 | | public IEnumerable<ISearchResultEntry> QueryLDAP(LDAPQueryOptions options) |
| 2 | 812 | | { |
| 2 | 813 | | return QueryLDAP( |
| 2 | 814 | | options.Filter, |
| 2 | 815 | | options.Scope, |
| 2 | 816 | | options.Properties, |
| 2 | 817 | | options.CancellationToken, |
| 2 | 818 | | options.DomainName, |
| 2 | 819 | | options.IncludeAcl, |
| 2 | 820 | | options.ShowDeleted, |
| 2 | 821 | | options.AdsPath, |
| 2 | 822 | | options.GlobalCatalog, |
| 2 | 823 | | options.SkipCache, |
| 2 | 824 | | options.ThrowException |
| 2 | 825 | | ); |
| 2 | 826 | | } |
| | 827 | |
|
| | 828 | | /// <summary> |
| | 829 | | /// Performs an LDAP query using the parameters specified by the user. |
| | 830 | | /// </summary> |
| | 831 | | /// <param name="ldapFilter">LDAP filter</param> |
| | 832 | | /// <param name="scope">SearchScope to query</param> |
| | 833 | | /// <param name="props">LDAP properties to fetch for each object</param> |
| | 834 | | /// <param name="cancellationToken">Cancellation Token</param> |
| | 835 | | /// <param name="includeAcl">Include the DACL and Owner values in the NTSecurityDescriptor</param> |
| | 836 | | /// <param name="showDeleted">Include deleted objects</param> |
| | 837 | | /// <param name="domainName">Domain to query</param> |
| | 838 | | /// <param name="adsPath">ADS path to limit the query too</param> |
| | 839 | | /// <param name="globalCatalog">Use the global catalog instead of the regular LDAP server</param> |
| | 840 | | /// <param name="skipCache"> |
| | 841 | | /// Skip the connection cache and force a new connection. You must dispose of this connection |
| | 842 | | /// yourself. |
| | 843 | | /// </param> |
| | 844 | | /// <param name="throwException">Throw exceptions rather than logging the errors directly</param> |
| | 845 | | /// <returns>All LDAP search results matching the specified parameters</returns> |
| | 846 | | /// <exception cref="LDAPQueryException"> |
| | 847 | | /// Thrown when an error occurs during LDAP query (only when throwException = true) |
| | 848 | | /// </exception> |
| | 849 | | public IEnumerable<ISearchResultEntry> QueryLDAP(string ldapFilter, SearchScope scope, |
| | 850 | | string[] props, CancellationToken cancellationToken, string domainName = null, bool includeAcl = false, |
| | 851 | | bool showDeleted = false, string adsPath = null, bool globalCatalog = false, bool skipCache = false, |
| | 852 | | bool throwException = false) |
| 5 | 853 | | { |
| 5 | 854 | | var queryParams = SetupLDAPQueryFilter( |
| 5 | 855 | | ldapFilter, scope, props, includeAcl, domainName, includeAcl, adsPath, globalCatalog, skipCache); |
| | 856 | |
|
| 5 | 857 | | if (queryParams.Exception != null) |
| 5 | 858 | | { |
| 5 | 859 | | _log.LogWarning("Failed to setup LDAP Query Filter: {Message}", queryParams.Exception.Message); |
| 5 | 860 | | if (throwException) |
| 2 | 861 | | throw new LDAPQueryException("Failed to setup LDAP Query Filter", queryParams.Exception); |
| 3 | 862 | | yield break; |
| | 863 | | } |
| | 864 | |
|
| 0 | 865 | | var conn = queryParams.Connection; |
| 0 | 866 | | var request = queryParams.SearchRequest; |
| 0 | 867 | | var pageControl = queryParams.PageControl; |
| | 868 | |
|
| 0 | 869 | | PageResultResponseControl pageResponse = null; |
| 0 | 870 | | var backoffDelay = MinBackoffDelay; |
| 0 | 871 | | var retryCount = 0; |
| 0 | 872 | | while (true) |
| 0 | 873 | | { |
| 0 | 874 | | if (cancellationToken.IsCancellationRequested) |
| 0 | 875 | | yield break; |
| | 876 | |
|
| | 877 | | SearchResponse response; |
| | 878 | | try |
| 0 | 879 | | { |
| 0 | 880 | | _log.LogTrace("Sending LDAP request for {Filter}", ldapFilter); |
| 0 | 881 | | response = (SearchResponse)conn.SendRequest(request); |
| 0 | 882 | | if (response != null) |
| 0 | 883 | | pageResponse = (PageResultResponseControl)response.Controls |
| 0 | 884 | | .Where(x => x is PageResultResponseControl).DefaultIfEmpty(null).FirstOrDefault(); |
| 0 | 885 | | } |
| 0 | 886 | | catch (LdapException le) when (le.ErrorCode == (int)LdapErrorCodes.ServerDown && |
| 0 | 887 | | retryCount < MaxRetries) |
| 0 | 888 | | { |
| | 889 | | /*A ServerDown exception indicates that our connection is no longer valid for one of many reasons. |
| | 890 | | However, this function is generally called by multiple threads, so we need to be careful in recreati |
| | 891 | | the connection. Using a semaphore, we can ensure that only one thread is actually recreating the con |
| | 892 | | while the other threads that hit the ServerDown exception simply wait. The initial caller will hold |
| | 893 | | and do a backoff delay before trying to make a new connection which will replace the existing connec |
| | 894 | | _ldapConnections cache. Other threads will retrieve the new connection from the cache instead of mak |
| | 895 | | This minimizes overhead of new connections while still fixing our core problem.*/ |
| | 896 | |
|
| | 897 | | //Always increment retry count |
| 0 | 898 | | retryCount++; |
| | 899 | |
|
| | 900 | | //Attempt to acquire a lock |
| 0 | 901 | | if (Monitor.TryEnter(_lockObj)) |
| 0 | 902 | | { |
| | 903 | | //If we've acquired the lock, we want to immediately signal our reset event so everyone else wai |
| 0 | 904 | | _connectionResetEvent.Reset(); |
| | 905 | | try |
| 0 | 906 | | { |
| | 907 | | //Sleep for our backoff |
| 0 | 908 | | Thread.Sleep(backoffDelay); |
| | 909 | | //Explicitly skip the cache so we don't get the same connection back |
| 0 | 910 | | conn = CreateNewConnection(domainName, globalCatalog, true); |
| 0 | 911 | | if (conn == null) |
| 0 | 912 | | { |
| 0 | 913 | | _log.LogError( |
| 0 | 914 | | "Unable to create replacement ldap connection for ServerDown exception. Breaking loo |
| 0 | 915 | | yield break; |
| | 916 | | } |
| | 917 | |
|
| 0 | 918 | | _log.LogInformation("Created new LDAP connection after receiving ServerDown from server"); |
| 0 | 919 | | } |
| | 920 | | finally |
| 0 | 921 | | { |
| | 922 | | //Reset our event + release the lock |
| 0 | 923 | | _connectionResetEvent.Set(); |
| 0 | 924 | | Monitor.Exit(_lockObj); |
| 0 | 925 | | } |
| 0 | 926 | | } |
| | 927 | | else |
| 0 | 928 | | { |
| | 929 | | //If someone else is holding the reset event, we want to just wait and then pull the newly creat |
| | 930 | | //This event will be released after the first entrant thread is done making a new connection |
| | 931 | | //The thread.sleep is to prevent a potential, very unlikely race |
| 0 | 932 | | Thread.Sleep(50); |
| 0 | 933 | | _connectionResetEvent.WaitOne(); |
| 0 | 934 | | conn = CreateNewConnection(domainName, globalCatalog); |
| 0 | 935 | | } |
| | 936 | |
|
| 0 | 937 | | backoffDelay = GetNextBackoff(retryCount); |
| 0 | 938 | | continue; |
| | 939 | | } |
| 0 | 940 | | catch (LdapException le) when (le.ErrorCode == (int)LdapErrorCodes.Busy && retryCount < MaxRetries) |
| 0 | 941 | | { |
| 0 | 942 | | retryCount++; |
| 0 | 943 | | backoffDelay = GetNextBackoff(retryCount); |
| 0 | 944 | | continue; |
| | 945 | | } |
| 0 | 946 | | catch (LdapException le) |
| 0 | 947 | | { |
| 0 | 948 | | if (le.ErrorCode != (int)LdapErrorCodes.LocalError) |
| 0 | 949 | | { |
| 0 | 950 | | if (throwException) |
| 0 | 951 | | { |
| 0 | 952 | | throw new LDAPQueryException( |
| 0 | 953 | | $"LDAP Exception in Loop: {le.ErrorCode}. {le.ServerErrorMessage}. {le.Message}. Filter: |
| 0 | 954 | | le); |
| | 955 | | } |
| | 956 | |
|
| 0 | 957 | | _log.LogWarning(le, |
| 0 | 958 | | "LDAP Exception in Loop: {ErrorCode}. {ServerErrorMessage}. {Message}. Filter: {Filter}. Dom |
| 0 | 959 | | le.ErrorCode, le.ServerErrorMessage, le.Message, ldapFilter, domainName); |
| 0 | 960 | | } |
| | 961 | |
|
| 0 | 962 | | yield break; |
| | 963 | | } |
| 0 | 964 | | catch (Exception e) |
| 0 | 965 | | { |
| 0 | 966 | | _log.LogWarning(e, "Exception in LDAP loop for {Filter} and {Domain}", ldapFilter, domainName); |
| 0 | 967 | | if (throwException) |
| 0 | 968 | | throw new LDAPQueryException($"Exception in LDAP loop for {ldapFilter} and {domainName}", e); |
| | 969 | |
|
| 0 | 970 | | yield break; |
| | 971 | | } |
| | 972 | |
|
| 0 | 973 | | if (cancellationToken.IsCancellationRequested) |
| 0 | 974 | | yield break; |
| | 975 | |
|
| 0 | 976 | | if (response == null || pageResponse == null) |
| 0 | 977 | | continue; |
| | 978 | |
|
| 0 | 979 | | foreach (SearchResultEntry entry in response.Entries) |
| 0 | 980 | | { |
| 0 | 981 | | if (cancellationToken.IsCancellationRequested) |
| 0 | 982 | | yield break; |
| | 983 | |
|
| 0 | 984 | | yield return new SearchResultEntryWrapper(entry, this); |
| 0 | 985 | | } |
| | 986 | |
|
| 0 | 987 | | if (pageResponse.Cookie.Length == 0 || response.Entries.Count == 0 || |
| 0 | 988 | | cancellationToken.IsCancellationRequested) |
| 0 | 989 | | yield break; |
| | 990 | |
|
| 0 | 991 | | pageControl.Cookie = pageResponse.Cookie; |
| 0 | 992 | | } |
| | 993 | | } |
| | 994 | |
|
| | 995 | | private LdapConnection CreateNewConnection(string domainName = null, bool globalCatalog = false, |
| | 996 | | bool skipCache = false) |
| 0 | 997 | | { |
| 0 | 998 | | var task = globalCatalog |
| 0 | 999 | | ? Task.Run(() => CreateGlobalCatalogConnection(domainName, _ldapConfig.AuthType)) |
| 0 | 1000 | | : Task.Run(() => CreateLDAPConnection(domainName, skipCache, _ldapConfig.AuthType)); |
| | 1001 | |
|
| | 1002 | | try |
| 0 | 1003 | | { |
| 0 | 1004 | | return task.ConfigureAwait(false).GetAwaiter().GetResult(); |
| | 1005 | | } |
| 0 | 1006 | | catch |
| 0 | 1007 | | { |
| 0 | 1008 | | return null; |
| | 1009 | | } |
| 0 | 1010 | | } |
| | 1011 | |
|
| | 1012 | | /// <summary> |
| | 1013 | | /// Performs an LDAP query using the parameters specified by the user. |
| | 1014 | | /// </summary> |
| | 1015 | | /// <param name="ldapFilter">LDAP filter</param> |
| | 1016 | | /// <param name="scope">SearchScope to query</param> |
| | 1017 | | /// <param name="props">LDAP properties to fetch for each object</param> |
| | 1018 | | /// <param name="includeAcl">Include the DACL and Owner values in the NTSecurityDescriptor</param> |
| | 1019 | | /// <param name="showDeleted">Include deleted objects</param> |
| | 1020 | | /// <param name="domainName">Domain to query</param> |
| | 1021 | | /// <param name="adsPath">ADS path to limit the query too</param> |
| | 1022 | | /// <param name="globalCatalog">Use the global catalog instead of the regular LDAP server</param> |
| | 1023 | | /// <param name="skipCache"> |
| | 1024 | | /// Skip the connection cache and force a new connection. You must dispose of this connection |
| | 1025 | | /// yourself. |
| | 1026 | | /// </param> |
| | 1027 | | /// <param name="throwException">Throw exceptions rather than logging the errors directly</param> |
| | 1028 | | /// <returns>All LDAP search results matching the specified parameters</returns> |
| | 1029 | | /// <exception cref="LDAPQueryException"> |
| | 1030 | | /// Thrown when an error occurs during LDAP query (only when throwException = true) |
| | 1031 | | /// </exception> |
| | 1032 | | public virtual IEnumerable<ISearchResultEntry> QueryLDAP(string ldapFilter, SearchScope scope, |
| | 1033 | | string[] props, string domainName = null, bool includeAcl = false, bool showDeleted = false, |
| | 1034 | | string adsPath = null, bool globalCatalog = false, bool skipCache = false, bool throwException = false) |
| 1 | 1035 | | { |
| 1 | 1036 | | return QueryLDAP(ldapFilter, scope, props, new CancellationToken(), domainName, includeAcl, showDeleted, |
| 1 | 1037 | | adsPath, globalCatalog, skipCache, throwException); |
| 1 | 1038 | | } |
| | 1039 | |
|
| | 1040 | | private static TimeSpan GetNextBackoff(int retryCount) |
| 0 | 1041 | | { |
| 0 | 1042 | | return TimeSpan.FromSeconds(Math.Min( |
| 0 | 1043 | | MinBackoffDelay.TotalSeconds * Math.Pow(BackoffDelayMultiplier, retryCount), |
| 0 | 1044 | | MaxBackoffDelay.TotalSeconds)); |
| 0 | 1045 | | } |
| | 1046 | |
|
| | 1047 | | /// <summary> |
| | 1048 | | /// Gets the forest associated with a domain. |
| | 1049 | | /// If no domain is provided, defaults to current domain |
| | 1050 | | /// </summary> |
| | 1051 | | /// <param name="domainName"></param> |
| | 1052 | | /// <returns></returns> |
| | 1053 | | public virtual Forest GetForest(string domainName = null) |
| 35 | 1054 | | { |
| | 1055 | | try |
| 35 | 1056 | | { |
| 35 | 1057 | | if (domainName == null && _ldapConfig.Username == null) |
| 35 | 1058 | | return Forest.GetCurrentForest(); |
| | 1059 | |
|
| 0 | 1060 | | var domain = GetDomain(domainName); |
| 0 | 1061 | | return domain?.Forest; |
| | 1062 | | } |
| 35 | 1063 | | catch |
| 35 | 1064 | | { |
| 35 | 1065 | | return null; |
| | 1066 | | } |
| 35 | 1067 | | } |
| | 1068 | |
|
| | 1069 | | /// <summary> |
| | 1070 | | /// Creates a new ActiveDirectorySecurityDescriptor |
| | 1071 | | /// Function created for testing purposes |
| | 1072 | | /// </summary> |
| | 1073 | | /// <returns></returns> |
| | 1074 | | public ActiveDirectorySecurityDescriptor MakeSecurityDescriptor() |
| 0 | 1075 | | { |
| 0 | 1076 | | return new ActiveDirectorySecurityDescriptor(new ActiveDirectorySecurity()); |
| 0 | 1077 | | } |
| | 1078 | |
|
| | 1079 | | public string BuildLdapPath(string dnPath, string domainName) |
| 2 | 1080 | | { |
| 2 | 1081 | | var domain = GetDomain(domainName)?.Name; |
| 2 | 1082 | | if (domain == null) |
| 1 | 1083 | | return null; |
| | 1084 | |
|
| 1 | 1085 | | var adPath = $"{dnPath},DC={domain.Replace(".", ",DC=")}"; |
| 1 | 1086 | | return adPath; |
| 2 | 1087 | | } |
| | 1088 | |
|
| | 1089 | | /// <summary> |
| | 1090 | | /// Tests the current LDAP config to ensure its valid by pulling a domain object |
| | 1091 | | /// </summary> |
| | 1092 | | /// <returns>True if connection was successful, else false</returns> |
| | 1093 | | public bool TestLDAPConfig(string domain) |
| 0 | 1094 | | { |
| 0 | 1095 | | var filter = new LDAPFilter(); |
| 0 | 1096 | | filter.AddDomains(); |
| | 1097 | |
|
| 0 | 1098 | | var resDomain = GetDomain(domain)?.Name ?? domain; |
| 0 | 1099 | | _log.LogTrace("Testing LDAP connection for domain {Domain}", resDomain); |
| | 1100 | |
|
| 0 | 1101 | | var result = QueryLDAP(filter.GetFilter(), SearchScope.Subtree, CommonProperties.ObjectID, resDomain, |
| 0 | 1102 | | throwException: true) |
| 0 | 1103 | | .DefaultIfEmpty(null).FirstOrDefault(); |
| 0 | 1104 | | _log.LogTrace("Result object from LDAP connection test is {DN}", result?.DistinguishedName ?? "null"); |
| 0 | 1105 | | return result != null; |
| 0 | 1106 | | } |
| | 1107 | |
|
| | 1108 | | /// <summary> |
| | 1109 | | /// Gets the domain object associated with the specified domain name. |
| | 1110 | | /// Defaults to current domain if none specified |
| | 1111 | | /// </summary> |
| | 1112 | | /// <param name="domainName"></param> |
| | 1113 | | /// <returns></returns> |
| | 1114 | | public virtual Domain GetDomain(string domainName = null) |
| 5 | 1115 | | { |
| 5 | 1116 | | var cacheKey = domainName ?? NullCacheKey; |
| 7 | 1117 | | if (_domainCache.TryGetValue(cacheKey, out var domain)) return domain; |
| | 1118 | |
|
| | 1119 | | try |
| 3 | 1120 | | { |
| | 1121 | | DirectoryContext context; |
| 3 | 1122 | | if (_ldapConfig.Username != null) |
| 0 | 1123 | | context = domainName != null |
| 0 | 1124 | | ? new DirectoryContext(DirectoryContextType.Domain, domainName, _ldapConfig.Username, |
| 0 | 1125 | | _ldapConfig.Password) |
| 0 | 1126 | | : new DirectoryContext(DirectoryContextType.Domain, _ldapConfig.Username, |
| 0 | 1127 | | _ldapConfig.Password); |
| | 1128 | | else |
| 3 | 1129 | | context = domainName != null |
| 3 | 1130 | | ? new DirectoryContext(DirectoryContextType.Domain, domainName) |
| 3 | 1131 | | : new DirectoryContext(DirectoryContextType.Domain); |
| | 1132 | |
|
| 3 | 1133 | | domain = Domain.GetDomain(context); |
| 0 | 1134 | | } |
| 3 | 1135 | | catch (Exception e) |
| 3 | 1136 | | { |
| 3 | 1137 | | _log.LogDebug(e, "GetDomain call failed at {StackTrace}", new StackFrame()); |
| 3 | 1138 | | domain = null; |
| 3 | 1139 | | } |
| | 1140 | |
|
| 3 | 1141 | | _domainCache.TryAdd(cacheKey, domain); |
| 3 | 1142 | | return domain; |
| 5 | 1143 | | } |
| | 1144 | |
|
| | 1145 | | /// <summary> |
| | 1146 | | /// Setup LDAP query for filter |
| | 1147 | | /// </summary> |
| | 1148 | | /// <param name="ldapFilter">LDAP filter</param> |
| | 1149 | | /// <param name="scope">SearchScope to query</param> |
| | 1150 | | /// <param name="props">LDAP properties to fetch for each object</param> |
| | 1151 | | /// <param name="includeAcl">Include the DACL and Owner values in the NTSecurityDescriptor</param> |
| | 1152 | | /// <param name="domainName">Domain to query</param> |
| | 1153 | | /// <param name="showDeleted">Include deleted objects</param> |
| | 1154 | | /// <param name="adsPath">ADS path to limit the query too</param> |
| | 1155 | | /// <param name="globalCatalog">Use the global catalog instead of the regular LDAP server</param> |
| | 1156 | | /// <param name="skipCache"> |
| | 1157 | | /// Skip the connection cache and force a new connection. You must dispose of this connection |
| | 1158 | | /// yourself. |
| | 1159 | | /// </param> |
| | 1160 | | /// <returns>Tuple of LdapConnection, SearchRequest, PageResultRequestControl and LDAPQueryException</returns> |
| | 1161 | | // ReSharper disable once MemberCanBePrivate.Global |
| | 1162 | | internal LDAPQueryParams SetupLDAPQueryFilter( |
| | 1163 | | string ldapFilter, |
| | 1164 | | SearchScope scope, string[] props, bool includeAcl = false, string domainName = null, |
| | 1165 | | bool showDeleted = false, |
| | 1166 | | string adsPath = null, bool globalCatalog = false, bool skipCache = false) |
| 5 | 1167 | | { |
| 5 | 1168 | | _log.LogTrace("Creating ldap connection for {Target} with filter {Filter}", |
| 5 | 1169 | | globalCatalog ? "Global Catalog" : "DC", ldapFilter); |
| 5 | 1170 | | var task = globalCatalog |
| 1 | 1171 | | ? Task.Run(() => CreateGlobalCatalogConnection(domainName, _ldapConfig.AuthType)) |
| 9 | 1172 | | : Task.Run(() => CreateLDAPConnection(domainName, skipCache, _ldapConfig.AuthType)); |
| | 1173 | |
|
| 5 | 1174 | | var queryParams = new LDAPQueryParams(); |
| | 1175 | |
|
| | 1176 | | LdapConnection conn; |
| | 1177 | | try |
| 5 | 1178 | | { |
| 5 | 1179 | | conn = task.ConfigureAwait(false).GetAwaiter().GetResult(); |
| 0 | 1180 | | } |
| 0 | 1181 | | catch (LdapException ldapException) |
| 0 | 1182 | | { |
| 0 | 1183 | | var errorString = |
| 0 | 1184 | | $"LDAP Exception {ldapException.ErrorCode} when creating connection for {ldapFilter} and domain {dom |
| 0 | 1185 | | queryParams.Exception = new LDAPQueryException(errorString, ldapException); |
| 0 | 1186 | | return queryParams; |
| | 1187 | | } |
| 5 | 1188 | | catch (LDAPQueryException ldapQueryException) |
| 5 | 1189 | | { |
| 5 | 1190 | | queryParams.Exception = ldapQueryException; |
| 5 | 1191 | | return queryParams; |
| | 1192 | | } |
| 0 | 1193 | | catch (Exception e) |
| 0 | 1194 | | { |
| 0 | 1195 | | var errorString = |
| 0 | 1196 | | $"Exception getting LDAP connection for {ldapFilter} and domain {domainName ?? "Default Domain"}"; |
| 0 | 1197 | | queryParams.Exception = new LDAPQueryException(errorString, e); |
| 0 | 1198 | | return queryParams; |
| | 1199 | | } |
| | 1200 | |
|
| | 1201 | | //If we get a null connection, something went wrong, but we don't have an error to go with it for whatever r |
| 0 | 1202 | | if (conn == null) |
| 0 | 1203 | | { |
| 0 | 1204 | | var errorString = |
| 0 | 1205 | | $"LDAP connection is null for filter {ldapFilter} and domain {domainName ?? "Default Domain"}"; |
| 0 | 1206 | | queryParams.Exception = new LDAPQueryException(errorString); |
| 0 | 1207 | | return queryParams; |
| | 1208 | | } |
| | 1209 | |
|
| | 1210 | | SearchRequest request; |
| | 1211 | |
|
| | 1212 | | try |
| 0 | 1213 | | { |
| 0 | 1214 | | request = CreateSearchRequest(ldapFilter, scope, props, domainName, adsPath, showDeleted); |
| 0 | 1215 | | } |
| 0 | 1216 | | catch (LDAPQueryException ldapQueryException) |
| 0 | 1217 | | { |
| 0 | 1218 | | queryParams.Exception = ldapQueryException; |
| 0 | 1219 | | return queryParams; |
| | 1220 | | } |
| | 1221 | |
|
| 0 | 1222 | | if (request == null) |
| 0 | 1223 | | { |
| 0 | 1224 | | var errorString = |
| 0 | 1225 | | $"Search request is null for filter {ldapFilter} and domain {domainName ?? "Default Domain"}"; |
| 0 | 1226 | | queryParams.Exception = new LDAPQueryException(errorString); |
| 0 | 1227 | | return queryParams; |
| | 1228 | | } |
| | 1229 | |
|
| 0 | 1230 | | var pageControl = new PageResultRequestControl(500); |
| 0 | 1231 | | request.Controls.Add(pageControl); |
| | 1232 | |
|
| 0 | 1233 | | if (includeAcl) |
| 0 | 1234 | | request.Controls.Add(new SecurityDescriptorFlagControl |
| 0 | 1235 | | { |
| 0 | 1236 | | SecurityMasks = SecurityMasks.Dacl | SecurityMasks.Owner |
| 0 | 1237 | | }); |
| | 1238 | |
|
| 0 | 1239 | | queryParams.Connection = conn; |
| 0 | 1240 | | queryParams.SearchRequest = request; |
| 0 | 1241 | | queryParams.PageControl = pageControl; |
| | 1242 | |
|
| 0 | 1243 | | return queryParams; |
| 5 | 1244 | | } |
| | 1245 | |
|
| | 1246 | | private Group GetBaseEnterpriseDC(string domain) |
| 0 | 1247 | | { |
| 0 | 1248 | | var forest = GetForest(domain)?.Name; |
| 0 | 1249 | | if (forest == null) _log.LogWarning("Error getting forest, ENTDC sid is likely incorrect"); |
| 0 | 1250 | | var g = new Group { ObjectIdentifier = $"{forest}-S-1-5-9".ToUpper() }; |
| 0 | 1251 | | g.Properties.Add("name", $"ENTERPRISE DOMAIN CONTROLLERS@{forest ?? "UNKNOWN"}".ToUpper()); |
| 0 | 1252 | | g.Properties.Add("domainsid", GetSidFromDomainName(forest)); |
| 0 | 1253 | | g.Properties.Add("domain", forest); |
| 0 | 1254 | | return g; |
| 0 | 1255 | | } |
| | 1256 | |
|
| | 1257 | | /// <summary> |
| | 1258 | | /// Updates the config for querying LDAP |
| | 1259 | | /// </summary> |
| | 1260 | | /// <param name="config"></param> |
| | 1261 | | public void UpdateLDAPConfig(LDAPConfig config) |
| 0 | 1262 | | { |
| 0 | 1263 | | _ldapConfig = config; |
| 0 | 1264 | | } |
| | 1265 | |
|
| | 1266 | | private string GetDomainNameFromSidLdap(string sid) |
| 0 | 1267 | | { |
| 0 | 1268 | | var hexSid = Helpers.ConvertSidToHexSid(sid); |
| | 1269 | |
|
| 0 | 1270 | | if (hexSid == null) |
| 0 | 1271 | | return null; |
| | 1272 | |
|
| | 1273 | | //Search using objectsid first |
| 0 | 1274 | | var result = |
| 0 | 1275 | | QueryLDAP($"(&(objectclass=domain)(objectsid={hexSid}))", SearchScope.Subtree, |
| 0 | 1276 | | new[] { "distinguishedname" }, globalCatalog: true).DefaultIfEmpty(null).FirstOrDefault(); |
| | 1277 | |
|
| 0 | 1278 | | if (result != null) |
| 0 | 1279 | | { |
| 0 | 1280 | | var domainName = Helpers.DistinguishedNameToDomain(result.DistinguishedName); |
| 0 | 1281 | | return domainName; |
| | 1282 | | } |
| | 1283 | |
|
| | 1284 | | //Try trusteddomain objects with the securityidentifier attribute |
| 0 | 1285 | | result = |
| 0 | 1286 | | QueryLDAP($"(&(objectclass=trusteddomain)(securityidentifier={sid}))", SearchScope.Subtree, |
| 0 | 1287 | | new[] { "cn" }, globalCatalog: true).DefaultIfEmpty(null).FirstOrDefault(); |
| | 1288 | |
|
| 0 | 1289 | | if (result != null) |
| 0 | 1290 | | { |
| 0 | 1291 | | var domainName = result.GetProperty(LDAPProperties.CanonicalName); |
| 0 | 1292 | | return domainName; |
| | 1293 | | } |
| | 1294 | |
|
| | 1295 | | //We didn't find anything so just return null |
| 0 | 1296 | | return null; |
| 0 | 1297 | | } |
| | 1298 | |
|
| | 1299 | | /// <summary> |
| | 1300 | | /// Uses a socket and a set of bytes to request the NETBIOS name from a remote computer |
| | 1301 | | /// </summary> |
| | 1302 | | /// <param name="server"></param> |
| | 1303 | | /// <param name="domain"></param> |
| | 1304 | | /// <param name="netbios"></param> |
| | 1305 | | /// <returns></returns> |
| | 1306 | | private static bool RequestNETBIOSNameFromComputer(string server, string domain, out string netbios) |
| 0 | 1307 | | { |
| 0 | 1308 | | var receiveBuffer = new byte[1024]; |
| 0 | 1309 | | var requestSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); |
| | 1310 | | try |
| 0 | 1311 | | { |
| | 1312 | | //Set receive timeout to 1 second |
| 0 | 1313 | | requestSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveTimeout, 1000); |
| | 1314 | | EndPoint remoteEndpoint; |
| | 1315 | |
|
| | 1316 | | //We need to create an endpoint to bind too. If its an IP, just use that. |
| 0 | 1317 | | if (IPAddress.TryParse(server, out var parsedAddress)) |
| 0 | 1318 | | remoteEndpoint = new IPEndPoint(parsedAddress, 137); |
| | 1319 | | else |
| | 1320 | | //If its not an IP, we're going to try and resolve it from DNS |
| | 1321 | | try |
| 0 | 1322 | | { |
| | 1323 | | IPAddress address; |
| 0 | 1324 | | if (server.Contains(".")) |
| 0 | 1325 | | address = Dns |
| 0 | 1326 | | .GetHostAddresses(server).First(x => x.AddressFamily == AddressFamily.InterNetwork); |
| | 1327 | | else |
| 0 | 1328 | | address = Dns.GetHostAddresses($"{server}.{domain}")[0]; |
| | 1329 | |
|
| 0 | 1330 | | if (address == null) |
| 0 | 1331 | | { |
| 0 | 1332 | | netbios = null; |
| 0 | 1333 | | return false; |
| | 1334 | | } |
| | 1335 | |
|
| 0 | 1336 | | remoteEndpoint = new IPEndPoint(address, 137); |
| 0 | 1337 | | } |
| 0 | 1338 | | catch |
| 0 | 1339 | | { |
| | 1340 | | //Failed to resolve an IP, so return null |
| 0 | 1341 | | netbios = null; |
| 0 | 1342 | | return false; |
| | 1343 | | } |
| | 1344 | |
|
| 0 | 1345 | | var originEndpoint = new IPEndPoint(IPAddress.Any, 0); |
| 0 | 1346 | | requestSocket.Bind(originEndpoint); |
| | 1347 | |
|
| | 1348 | | try |
| 0 | 1349 | | { |
| 0 | 1350 | | requestSocket.SendTo(NameRequest, remoteEndpoint); |
| 0 | 1351 | | var receivedByteCount = requestSocket.ReceiveFrom(receiveBuffer, ref remoteEndpoint); |
| 0 | 1352 | | if (receivedByteCount >= 90) |
| 0 | 1353 | | { |
| 0 | 1354 | | netbios = new ASCIIEncoding().GetString(receiveBuffer, 57, 16).Trim('\0', ' '); |
| 0 | 1355 | | return true; |
| | 1356 | | } |
| | 1357 | |
|
| 0 | 1358 | | netbios = null; |
| 0 | 1359 | | return false; |
| | 1360 | | } |
| 0 | 1361 | | catch (SocketException) |
| 0 | 1362 | | { |
| 0 | 1363 | | netbios = null; |
| 0 | 1364 | | return false; |
| | 1365 | | } |
| | 1366 | | } |
| | 1367 | | finally |
| 0 | 1368 | | { |
| | 1369 | | //Make sure we close the socket if its open |
| 0 | 1370 | | requestSocket.Close(); |
| 0 | 1371 | | } |
| 0 | 1372 | | } |
| | 1373 | |
|
| | 1374 | | /// <summary> |
| | 1375 | | /// Calls the NetWkstaGetInfo API on a hostname |
| | 1376 | | /// </summary> |
| | 1377 | | /// <param name="hostname"></param> |
| | 1378 | | /// <returns></returns> |
| | 1379 | | private async Task<NetAPIStructs.WorkstationInfo100?> GetWorkstationInfo(string hostname) |
| 0 | 1380 | | { |
| 0 | 1381 | | if (!await _portScanner.CheckPort(hostname)) |
| 0 | 1382 | | return null; |
| | 1383 | |
|
| 0 | 1384 | | var result = NetAPIMethods.NetWkstaGetInfo(hostname); |
| 0 | 1385 | | if (result.IsSuccess) return result.Value; |
| | 1386 | |
|
| 0 | 1387 | | return null; |
| 0 | 1388 | | } |
| | 1389 | |
|
| | 1390 | | /// <summary> |
| | 1391 | | /// Creates a SearchRequest object for use in querying LDAP. |
| | 1392 | | /// </summary> |
| | 1393 | | /// <param name="filter">LDAP filter</param> |
| | 1394 | | /// <param name="scope">SearchScope to query</param> |
| | 1395 | | /// <param name="attributes">LDAP properties to fetch for each object</param> |
| | 1396 | | /// <param name="domainName">Domain to query</param> |
| | 1397 | | /// <param name="adsPath">ADS path to limit the query too</param> |
| | 1398 | | /// <param name="showDeleted">Include deleted objects in results</param> |
| | 1399 | | /// <returns>A built SearchRequest</returns> |
| | 1400 | | private SearchRequest CreateSearchRequest(string filter, SearchScope scope, string[] attributes, |
| | 1401 | | string domainName = null, string adsPath = null, bool showDeleted = false) |
| 0 | 1402 | | { |
| 0 | 1403 | | var domain = GetDomain(domainName)?.Name ?? domainName; |
| | 1404 | |
|
| 0 | 1405 | | if (domain == null) |
| 0 | 1406 | | throw new LDAPQueryException( |
| 0 | 1407 | | $"Unable to create search request: GetDomain call failed for {domainName}"); |
| | 1408 | |
|
| 0 | 1409 | | var adPath = adsPath?.Replace("LDAP://", "") ?? $"DC={domain.Replace(".", ",DC=")}"; |
| | 1410 | |
|
| 0 | 1411 | | var request = new SearchRequest(adPath, filter, scope, attributes); |
| 0 | 1412 | | request.Controls.Add(new SearchOptionsControl(SearchOption.DomainScope)); |
| 0 | 1413 | | if (showDeleted) |
| 0 | 1414 | | request.Controls.Add(new ShowDeletedControl()); |
| | 1415 | |
|
| 0 | 1416 | | return request; |
| 0 | 1417 | | } |
| | 1418 | |
|
| | 1419 | | /// <summary> |
| | 1420 | | /// Creates a LDAP connection to a global catalog server |
| | 1421 | | /// </summary> |
| | 1422 | | /// <param name="domainName">Domain to connect too</param> |
| | 1423 | | /// <param name="authType">Auth type to use. Defaults to Kerberos. Use Negotiate for netonly scenarios</param> |
| | 1424 | | /// <returns>A connected LdapConnection or null</returns> |
| | 1425 | | private async Task<LdapConnection> CreateGlobalCatalogConnection(string domainName = null, |
| | 1426 | | AuthType authType = AuthType.Kerberos) |
| 1 | 1427 | | { |
| | 1428 | | string targetServer; |
| 1 | 1429 | | if (_ldapConfig.Server != null) |
| 0 | 1430 | | { |
| 0 | 1431 | | targetServer = _ldapConfig.Server; |
| 0 | 1432 | | } |
| | 1433 | | else |
| 1 | 1434 | | { |
| 1 | 1435 | | var domain = GetDomain(domainName); |
| 1 | 1436 | | if (domain == null) |
| 1 | 1437 | | { |
| 1 | 1438 | | _log.LogDebug( |
| 1 | 1439 | | "Unable to create global catalog connection for domain {DomainName}: GetDomain failed", |
| 1 | 1440 | | domainName); |
| 1 | 1441 | | throw new LDAPQueryException($"GetDomain call failed for {domainName}"); |
| | 1442 | | } |
| | 1443 | |
|
| 0 | 1444 | | if (!_domainControllerCache.TryGetValue(domain.Name, out targetServer)) |
| 0 | 1445 | | targetServer = await GetUsableDomainController(domain); |
| 0 | 1446 | | } |
| | 1447 | |
|
| 0 | 1448 | | if (targetServer == null) |
| 0 | 1449 | | throw new LDAPQueryException($"No usable global catalog found for {domainName}"); |
| | 1450 | |
|
| 0 | 1451 | | if (_globalCatalogConnections.TryGetValue(targetServer, out var connection)) |
| 0 | 1452 | | return connection; |
| | 1453 | |
|
| 0 | 1454 | | connection = new LdapConnection(new LdapDirectoryIdentifier(targetServer, 3268)); |
| | 1455 | |
|
| 0 | 1456 | | connection.SessionOptions.ProtocolVersion = 3; |
| | 1457 | |
|
| 0 | 1458 | | if (_ldapConfig.Username != null) |
| 0 | 1459 | | { |
| 0 | 1460 | | var cred = new NetworkCredential(_ldapConfig.Username, _ldapConfig.Password); |
| 0 | 1461 | | connection.Credential = cred; |
| 0 | 1462 | | } |
| | 1463 | |
|
| 0 | 1464 | | if (_ldapConfig.DisableSigning) |
| 0 | 1465 | | { |
| 0 | 1466 | | connection.SessionOptions.Sealing = false; |
| 0 | 1467 | | connection.SessionOptions.Signing = false; |
| 0 | 1468 | | } |
| | 1469 | |
|
| 0 | 1470 | | connection.AuthType = authType; |
| | 1471 | |
|
| 0 | 1472 | | _globalCatalogConnections.TryAdd(targetServer, connection); |
| 0 | 1473 | | return connection; |
| 0 | 1474 | | } |
| | 1475 | |
|
| | 1476 | | /// <summary> |
| | 1477 | | /// Creates an LDAP connection with appropriate options based off the ldap configuration. Caches connections |
| | 1478 | | /// </summary> |
| | 1479 | | /// <param name="domainName">The domain to connect too</param> |
| | 1480 | | /// <param name="skipCache">Skip the connection cache</param> |
| | 1481 | | /// <param name="authType">Auth type to use. Defaults to Kerberos. Use Negotiate for netonly scenarios</param> |
| | 1482 | | /// <returns>A connected LDAP connection or null</returns> |
| | 1483 | | private async Task<LdapConnection> CreateLDAPConnection(string domainName = null, bool skipCache = false, |
| | 1484 | | AuthType authType = AuthType.Kerberos) |
| 4 | 1485 | | { |
| | 1486 | | string targetServer; |
| 4 | 1487 | | if (_ldapConfig.Server != null) |
| 0 | 1488 | | targetServer = _ldapConfig.Server; |
| | 1489 | | else |
| 4 | 1490 | | { |
| 4 | 1491 | | var domain = GetDomain(domainName); |
| 4 | 1492 | | if (domain == null) |
| 4 | 1493 | | { |
| 4 | 1494 | | _log.LogDebug("Unable to create ldap connection for domain {DomainName}: GetDomain failed", |
| 4 | 1495 | | domainName); |
| 4 | 1496 | | throw new LDAPQueryException( |
| 4 | 1497 | | $"Error creating LDAP connection: GetDomain call failed for {domainName}"); |
| | 1498 | | } |
| | 1499 | |
|
| 0 | 1500 | | if (!_domainControllerCache.TryGetValue(domain.Name, out targetServer)) |
| 0 | 1501 | | targetServer = await GetUsableDomainController(domain); |
| 0 | 1502 | | } |
| | 1503 | |
|
| 0 | 1504 | | if (targetServer == null) |
| 0 | 1505 | | throw new LDAPQueryException($"No usable domain controller found for {domainName}"); |
| | 1506 | |
|
| 0 | 1507 | | if (!skipCache) |
| 0 | 1508 | | if (_ldapConnections.TryGetValue(targetServer, out var conn)) |
| 0 | 1509 | | return conn; |
| | 1510 | |
|
| 0 | 1511 | | var port = _ldapConfig.GetPort(); |
| 0 | 1512 | | var ident = new LdapDirectoryIdentifier(targetServer, port, false, false); |
| 0 | 1513 | | var connection = new LdapConnection(ident) { Timeout = new TimeSpan(0, 0, 5, 0) }; |
| 0 | 1514 | | if (_ldapConfig.Username != null) |
| 0 | 1515 | | { |
| 0 | 1516 | | var cred = new NetworkCredential(_ldapConfig.Username, _ldapConfig.Password); |
| 0 | 1517 | | connection.Credential = cred; |
| 0 | 1518 | | } |
| | 1519 | |
|
| | 1520 | | //These options are important! |
| 0 | 1521 | | connection.SessionOptions.ProtocolVersion = 3; |
| 0 | 1522 | | connection.SessionOptions.ReferralChasing = ReferralChasingOptions.None; |
| | 1523 | |
|
| 0 | 1524 | | if (_ldapConfig.DisableSigning) |
| 0 | 1525 | | { |
| 0 | 1526 | | connection.SessionOptions.Sealing = false; |
| 0 | 1527 | | connection.SessionOptions.Signing = false; |
| 0 | 1528 | | } |
| | 1529 | |
|
| 0 | 1530 | | if (_ldapConfig.SSL) |
| 0 | 1531 | | connection.SessionOptions.SecureSocketLayer = true; |
| | 1532 | |
|
| 0 | 1533 | | if (_ldapConfig.DisableCertVerification) |
| 0 | 1534 | | connection.SessionOptions.VerifyServerCertificate = (ldapConnection, certificate) => true; |
| | 1535 | |
|
| 0 | 1536 | | connection.AuthType = authType; |
| | 1537 | |
|
| 0 | 1538 | | _ldapConnections.AddOrUpdate(targetServer, connection, (s, ldapConnection) => |
| 0 | 1539 | | { |
| 0 | 1540 | | ldapConnection.Dispose(); |
| 0 | 1541 | | return connection; |
| 0 | 1542 | | }); |
| | 1543 | |
|
| 0 | 1544 | | return connection; |
| 0 | 1545 | | } |
| | 1546 | |
|
| | 1547 | | private async Task<string> GetUsableDomainController(Domain domain, bool gc = false) |
| 0 | 1548 | | { |
| 0 | 1549 | | if (!gc && _domainControllerCache.TryGetValue(domain.Name.ToUpper(), out var dc)) |
| 0 | 1550 | | return dc; |
| | 1551 | |
|
| 0 | 1552 | | var port = gc ? 3268 : _ldapConfig.GetPort(); |
| 0 | 1553 | | var pdc = domain.PdcRoleOwner.Name; |
| 0 | 1554 | | if (await _portScanner.CheckPort(pdc, port)) |
| 0 | 1555 | | { |
| 0 | 1556 | | _domainControllerCache.TryAdd(domain.Name.ToUpper(), pdc); |
| 0 | 1557 | | _log.LogInformation("Found usable Domain Controller for {Domain} : {PDC}", domain.Name, pdc); |
| 0 | 1558 | | return pdc; |
| | 1559 | | } |
| | 1560 | |
|
| | 1561 | | //If the PDC isn't reachable loop through the rest |
| 0 | 1562 | | foreach (DomainController domainController in domain.DomainControllers) |
| 0 | 1563 | | { |
| 0 | 1564 | | var name = domainController.Name; |
| 0 | 1565 | | if (!await _portScanner.CheckPort(name, port)) continue; |
| 0 | 1566 | | _log.LogInformation("Found usable Domain Controller for {Domain} : {PDC}", domain.Name, name); |
| 0 | 1567 | | _domainControllerCache.TryAdd(domain.Name.ToUpper(), name); |
| 0 | 1568 | | return name; |
| | 1569 | | } |
| | 1570 | |
|
| | 1571 | | //If we get here, somehow we didn't get any usable DCs. Save it off as null |
| 0 | 1572 | | _domainControllerCache.TryAdd(domain.Name.ToUpper(), null); |
| 0 | 1573 | | _log.LogWarning("Unable to find usable domain controller for {Domain}", domain.Name); |
| 0 | 1574 | | return null; |
| 0 | 1575 | | } |
| | 1576 | |
|
| | 1577 | | /// <summary> |
| | 1578 | | /// Normalizes a domain name to its full DNS name |
| | 1579 | | /// </summary> |
| | 1580 | | /// <param name="domain"></param> |
| | 1581 | | /// <returns></returns> |
| | 1582 | | internal string NormalizeDomainName(string domain) |
| 0 | 1583 | | { |
| 0 | 1584 | | if (domain == null) |
| 0 | 1585 | | return null; |
| | 1586 | |
|
| 0 | 1587 | | var resolved = domain; |
| | 1588 | |
|
| 0 | 1589 | | if (resolved.Contains(".")) |
| 0 | 1590 | | return domain.ToUpper(); |
| | 1591 | |
|
| 0 | 1592 | | resolved = ResolveDomainNetbiosToDns(domain) ?? domain; |
| | 1593 | |
|
| 0 | 1594 | | return resolved.ToUpper(); |
| 0 | 1595 | | } |
| | 1596 | |
|
| | 1597 | | /// <summary> |
| | 1598 | | /// Turns a domain Netbios name into its FQDN using the DsGetDcName function (TESTLAB -> TESTLAB.LOCAL) |
| | 1599 | | /// </summary> |
| | 1600 | | /// <param name="domainName"></param> |
| | 1601 | | /// <returns></returns> |
| | 1602 | | internal string ResolveDomainNetbiosToDns(string domainName) |
| 0 | 1603 | | { |
| 0 | 1604 | | var key = domainName.ToUpper(); |
| 0 | 1605 | | if (_netbiosCache.TryGetValue(key, out var flatName)) |
| 0 | 1606 | | return flatName; |
| | 1607 | |
|
| 0 | 1608 | | var domain = GetDomain(domainName); |
| 0 | 1609 | | if (domain != null) |
| 0 | 1610 | | { |
| 0 | 1611 | | _netbiosCache.TryAdd(key, domain.Name); |
| 0 | 1612 | | return domain.Name; |
| | 1613 | | } |
| | 1614 | |
|
| 0 | 1615 | | var computerName = _ldapConfig.Server; |
| | 1616 | |
|
| 0 | 1617 | | var dci = _nativeMethods.CallDsGetDcName(computerName, domainName); |
| 0 | 1618 | | if (dci.IsSuccess) |
| 0 | 1619 | | { |
| 0 | 1620 | | flatName = dci.Value.DomainName; |
| 0 | 1621 | | _netbiosCache.TryAdd(key, flatName); |
| 0 | 1622 | | return flatName; |
| | 1623 | | } |
| | 1624 | |
|
| 0 | 1625 | | return domainName.ToUpper(); |
| 0 | 1626 | | } |
| | 1627 | |
|
| | 1628 | | /// <summary> |
| | 1629 | | /// Gets the range retrieval limit for a domain |
| | 1630 | | /// </summary> |
| | 1631 | | /// <param name="domainName"></param> |
| | 1632 | | /// <param name="defaultRangeSize"></param> |
| | 1633 | | /// <returns></returns> |
| | 1634 | | public int GetDomainRangeSize(string domainName = null, int defaultRangeSize = 750) |
| 4 | 1635 | | { |
| 4 | 1636 | | var domainPath = DomainNameToDistinguishedName(domainName); |
| | 1637 | | //Default to a page size of 750 for safety |
| 4 | 1638 | | if (domainPath == null) |
| 2 | 1639 | | { |
| 2 | 1640 | | _log.LogDebug("Unable to resolve domain {Domain} to distinguishedname to get page size", |
| 2 | 1641 | | domainName ?? "current domain"); |
| 2 | 1642 | | return defaultRangeSize; |
| | 1643 | | } |
| | 1644 | |
|
| 2 | 1645 | | if (_ldapRangeSizeCache.TryGetValue(domainPath.ToUpper(), out var parsedPageSize)) |
| 0 | 1646 | | { |
| 0 | 1647 | | return parsedPageSize; |
| | 1648 | | } |
| | 1649 | |
|
| 2 | 1650 | | var configPath = CommonPaths.CreateDNPath(CommonPaths.QueryPolicyPath, domainPath); |
| 2 | 1651 | | var enumerable = QueryLDAP("(objectclass=*)", SearchScope.Base, null, adsPath: configPath); |
| 2 | 1652 | | var config = enumerable.DefaultIfEmpty(null).FirstOrDefault(); |
| 2 | 1653 | | var pageSize = config?.GetArrayProperty(LDAPProperties.LdapAdminLimits) |
| 3 | 1654 | | .FirstOrDefault(x => x.StartsWith("MaxPageSize", StringComparison.OrdinalIgnoreCase)); |
| 2 | 1655 | | if (pageSize == null) |
| 1 | 1656 | | { |
| 1 | 1657 | | _log.LogDebug("No LDAPAdminLimits object found for {Domain}", domainName); |
| 1 | 1658 | | _ldapRangeSizeCache.TryAdd(domainPath.ToUpper(), defaultRangeSize); |
| 1 | 1659 | | return defaultRangeSize; |
| | 1660 | | } |
| | 1661 | |
|
| 1 | 1662 | | if (int.TryParse(pageSize.Split('=').Last(), out parsedPageSize)) |
| 1 | 1663 | | { |
| 1 | 1664 | | _ldapRangeSizeCache.TryAdd(domainPath.ToUpper(), parsedPageSize); |
| 1 | 1665 | | _log.LogInformation("Found page size {PageSize} for {Domain}", parsedPageSize, |
| 1 | 1666 | | domainName ?? "current domain"); |
| 1 | 1667 | | return parsedPageSize; |
| | 1668 | | } |
| | 1669 | |
|
| 0 | 1670 | | _log.LogDebug("Failed to parse pagesize for {Domain}, returning default", domainName ?? "current domain"); |
| | 1671 | |
|
| 0 | 1672 | | _ldapRangeSizeCache.TryAdd(domainPath.ToUpper(), defaultRangeSize); |
| 0 | 1673 | | return defaultRangeSize; |
| 4 | 1674 | | } |
| | 1675 | |
|
| | 1676 | | private string DomainNameToDistinguishedName(string domain) |
| 4 | 1677 | | { |
| 4 | 1678 | | var resolvedDomain = GetDomain(domain)?.Name ?? domain; |
| 4 | 1679 | | return resolvedDomain == null ? null : $"DC={resolvedDomain.Replace(".", ",DC=")}"; |
| 4 | 1680 | | } |
| | 1681 | |
|
| | 1682 | | private class ResolvedWellKnownPrincipal |
| | 1683 | | { |
| 3 | 1684 | | public string DomainName { get; set; } |
| 3 | 1685 | | public string WkpId { get; set; } |
| | 1686 | | } |
| | 1687 | |
|
| | 1688 | | public string GetConfigurationPath(string domainName = null) |
| 0 | 1689 | | { |
| 0 | 1690 | | string path = domainName == null |
| 0 | 1691 | | ? "LDAP://RootDSE" |
| 0 | 1692 | | : $"LDAP://{NormalizeDomainName(domainName)}/RootDSE"; |
| | 1693 | |
|
| | 1694 | | DirectoryEntry rootDse; |
| 0 | 1695 | | if (_ldapConfig.Username != null) |
| 0 | 1696 | | rootDse = new DirectoryEntry(path, _ldapConfig.Username, _ldapConfig.Password); |
| | 1697 | | else |
| 0 | 1698 | | rootDse = new DirectoryEntry(path); |
| | 1699 | |
|
| 0 | 1700 | | return $"{rootDse.Properties["configurationNamingContext"]?[0]}"; |
| 0 | 1701 | | } |
| | 1702 | |
|
| | 1703 | | public string GetSchemaPath(string domainName) |
| 0 | 1704 | | { |
| 0 | 1705 | | string path = domainName == null |
| 0 | 1706 | | ? "LDAP://RootDSE" |
| 0 | 1707 | | : $"LDAP://{NormalizeDomainName(domainName)}/RootDSE"; |
| | 1708 | |
|
| | 1709 | | DirectoryEntry rootDse; |
| 0 | 1710 | | if (_ldapConfig.Username != null) |
| 0 | 1711 | | rootDse = new DirectoryEntry(path, _ldapConfig.Username, _ldapConfig.Password); |
| | 1712 | | else |
| 0 | 1713 | | rootDse = new DirectoryEntry(path); |
| | 1714 | |
|
| 0 | 1715 | | return $"{rootDse.Properties["schemaNamingContext"]?[0]}"; |
| 0 | 1716 | | } |
| | 1717 | |
|
| | 1718 | | public bool IsDomainController(string computerObjectId, string domainName) |
| 0 | 1719 | | { |
| 0 | 1720 | | var filter = new LDAPFilter().AddFilter(LDAPProperties.ObjectSID + "=" + computerObjectId, true) |
| 0 | 1721 | | .AddFilter(CommonFilters.DomainControllers, true); |
| 0 | 1722 | | var res = QueryLDAP(filter.GetFilter(), SearchScope.Subtree, |
| 0 | 1723 | | CommonProperties.ObjectID, domainName: domainName); |
| 0 | 1724 | | if (res.Count() > 0) |
| 0 | 1725 | | return true; |
| 0 | 1726 | | return false; |
| 0 | 1727 | | } |
| | 1728 | | } |
| | 1729 | | } |