Active Directory Service Interface (ADSI) and the Read Only Domain Controller (RODC) - Avoiding performance issues

Hi there,

this is the first blog entry for the new info series 'Coding from the field'.

The intention of this blog is to help you avoiding the reinvention of the wheel as well as to communicate known tripping hazards.

Todays topic:

Active Directory Service Interface (ADSI) and the Read Only Domain Controller (RODC)

Avoiding performance issues

When communicating with Active Directory (AD) via script or assembly the used interface will commonly be ADSI - even if you don't spot this. There are some caveats when using ADSI in infrastructures with read Only Domain Controllers deployed. We'll discuss this in the Symptom section.

Below you will see some examples of typical programmatic communications with AD that are using (directly or under the hood) ADSI:

 

A)      VBS

  • IADs::GetObject Set oIADsUser = GetObject(LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com)
    sDefNC = GetObject("LDAP://rootDSE").Get("defaultNamingContext")

  • ActiveX Data Objects (ADO) Set adoRS = adoCMD.Execute("<LDAP://DC=contoso,DC=com>;(&(objectClass=user)(!objectClass=inetOrgPerson)(cn=TheCN);ADspath,memberOf;subtree")

  • IADsOpenObject::OpenDSObject Set oIADsUser = GetObject("LDAP:").OpenDSObject("LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com", Nothing, Nothing, 513)

B)      PoSh

  • IADs::GetObject $IADsUser = [ADSI]LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com  
  • System.DirectoryServices.DirectoryEntry $IADsUser = New-Object System.DirectoryServices.DirectoryEntry(LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com)
     
  • System.DirectoryServices.AccountManagement
    see .Net
  • Quest AD-CMDLets $IADsUser = Get-QADUser "CN=TheCN,OU=TheOU,DC=contoso,DC=com"  
  • Microsoft PowerShell Active Directory Modul CMDLets $IADsUser = Get-ADUser "UserName"

C)      Perl

  • IADs::GetObject my $oIADsUser = Win32::OLE->GetObject(LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com);

D)     C++

  • IADsOpenObject::OpenDSObject hr = ADsOpenObject( LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com, Null, Null, ADS_SECURE_AUTHENTICATION, IID_IADs, (void**)&IADsUser);

E)      .Net

  • System.DirectoryServices.DirectoryEntry DirectoryEntry deIADsUser = new DirectoryEntry(LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com);  

  • System.DirectoryServices.DirectorySearcher SearchResult srIADsUser = new DirectorySearcher(new DirectoryEntry("LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com"), "(&(objectClass=user)(!objectClass=inetOrgPerson)(cn=TheCN))", new String[] {"ADspath","memberOf"}).FindOne();  

  • System.DirectoryServices.AccountManagement.UserPrincipal PrincipalContext pContext = new PrincipalContext(ContextType.Domain, Environment.ExpandEnvironmentVariables("%userdnsdomain%"));

    UserPrincipal User = UserPrincipal.FindByIdentity(Context, IdentityType.SamAccountName, UserName);

  • System.DirectoryServices.AccountManagement.PrincipalSearcher PrincipalContext pContext = new PrincipalContext(ContextType.Domain, Environment.ExpandEnvironmentVariables("%userdnsdomain%"));

    PrincipalSearcher pSearcher = new PrincipalSearcher();

    pSearcher.QueryFilter = new UserPrincipal(Context) { Surname = "Smith",  GivenName ="John" };

    PrincipalSearchResult<Principal> Results = pSearcher.FindAll();

 

Indeed - the .Net namespace System.DirectoryServices is nothing else than a wrapper library for ADSI and the namepsace System.DirectoryServices.AccountManagement wraps System.DirectoryServices for the ease of use.

Symptom:

Let's have a look at a very simple AD infrastructure design:

  • single domain forest
  • central site with one Windows Server 2008 R2 DC
  • branch site with one Windows Server 2008 R2 RODC
  • Windows 7 client in branch site
  • Windows 7 client is member of Allowed RODC Password Replication Group

When the client starts up he establishes a secure channel to the RODC (client PWD is cached on the RODC) and caches the RODC in the DCLocator cache.

When executing an ADSI call on the client (like IADs::GetObject(Path) or DirectoryEntry(Path))  against an object of the domain, this is what is going to happen:

  • ADSI instructs LSASS to detect a writable (always) Domain Controller of the domain (DsGetDcName call with Flag DS_WRITABLE_FLAG) and passes the RWDC to ADSI.

  • ADSI performs a LDAP-Bind to this RWDC.

  • ADSI compares the modifyTimeStamp attribute of the Aggregate Schema entry on this RWDC with the locally cached value for this forest.
    If the value on the Aggregate Schema is higher than the cached one, the Aggregate Schema is downloaded into the Schema cache on the client and the cached timestamp gets updated.

  • LSASS calls again DsGetDcName with Flag DS_WRITABLE_FLAG and passes the returned RWDC to ADSI.

  • ADSI performs a LDAP-Bind to this RWDC.

  • ADSI does a base query against the object path and asks for the default attributes of this object.
    The attribute values that are returned from the AD database are stored in the ADSI Property Cache in the RAM of the client.

  • When we try to process one of the cached attributes (all attribute values are coming back as string values), ADSI must use the Schema cache to translate the attribute value into the correct syntax.
    -> ADSI verifies the Schema cache again by instructing LSASS to enumerate through the DCs of the domain containing the object. This is done by calls against

    • DsGetDcOpen
    • DsGetDcNext
    • DsGetDcClose

    In a network trace we see, that there are up to 255 DCLocator calls against DCs - no matter how many DC we do have in the domain.

  • After the enormous number of DCLocator calls ADSI decides to use the locally cached Schema Cache - only now ADSI translates the string value of the requested property into it's proper syntax.

Hence we waited approximately 60 seconds between the LDAP-Bind and the attribute retrieval.

Cause:

The RODC in the DCLocator cache is marked with a DCQuality flag, a combination of values based on the following enumeration:

  • DS_DS_FLAG: 1              (Has a Directory Service instance)
  • DS_GOOD_TIMESERV_FLAG: 1   (Uses an external time source)
  • DS_HAS_IP: 2               (Has an IP address)
  • DS_KDC_FLAG: 5             (Has a Key Distribution Center instance)
  • DS_TIMESERV_FLAG: 5        (Is a time server)
  • DS_WS_FLAG: 5              (Has an AD-WebService instance)
  • DS_CLOSEST_FLAG: 10        (Is in the same or the next closest site)

This results in our scenario in a value of 28 for our RODC (DS_DS_FLAG + DS_HAS_IP + DS_KDC_FLAG + DS_TIMESERV_FLAG + DS_WS_FLAG + DS_CLOSEST_FLAG).

Because ADSI always requests a RWDC and never reuses the RWDC used in the last bind, ADSI tries again to get a RWDC for the Schema cache verification - with the assumption that the returned DCQuality flag is higher than the one of the cached RODC.

In our scenario no RWDC can return a DCQuality flag including DS_CLOSEST_FLAG and therefore the DCQuality flag of the RWDC will never be higher than 19. And because there is no DS_WRITABLE_FLAG value for the DCQuality flag - every answer of the DCLocator calls is discarded and LSASS goes on searching for RWDC with a sufficiently high DCQuality flag.

As we can see - LSASS will never find a fitting DC -> LSASS stops enumerating the DCs when reaching the internal limit of 255 calls (fortunately there is a limit - otherwise we would need a Cray computer - it's rumoured the Cray to be the only machine that finishes an endless loop in 4 hours).

This behavior has to be expected on every bind / search call in our code!

AntiDot:

  • Get a RWDC into the DCLocator cache
  1. Call nltest /DSGETDC:DomainName /WRITABLE /FORCE /DS /RET_DNS on the client
     

  2. Use DsGetDcname wrapper in .Net namespace System.DirectoryService.ActiveDirectory to get a RWDC into the DCLocator cache.
    C# sample:

    string foundDC = DomainController.FindOne(new DirectoryContext(DirectoryContextType.Domain), ActiveDirectorySite.GetComputerSite().ToString(), LocatorOptions.ForceRediscovery | LocatorOptions.WriteableRequired).Name;

-> we circumvent the DCQuality flag issue because the now cached RWDC has no 'unbeatable' DCQuality flag.

  • LDAP-Bind with full qualified domain name

    If we use the DNS name of the domain containing our object in our 'bind' * string we avoid the above issue because we need not rely on the DCLocator cache - a new DC discovery is performed via API DsGetDcOpen.
    This API uses the DCs found in _tcp.sitename._sites.dc._msdcs.contoso.com respectively tcp.sites.dc._msdcs.contoso.com and no RODC does register himself in these DNS zones -> no DCQuality flag issue -> no endless loop -> no cray computer necessary.
    Samples:

    VBS Set oIADsUser = GetObject(LDAP://contoso.com/ CN=TheCN,OU=TheOU,DC=contoso,DC=com)
    sDefNC = GetObject("LDAP://contoso.com/rootDSE").Get("defaultNamingContext")

    PoSh $IADsUser = [ADSI]LDAP://contoso.com/ CN=TheCN,OU=TheOU,DC=contoso,DC=com $IADsUser = New-Object System.DirectoryServices.DirectoryEntry("LDAP://contoso.com/ CN=TheCN,OU=TheOU,DC=contoso,DC=com")

    Perl my $oIADsUser = Win32::OLE->GetObject("LDAP://contoso.com/ CN=TheCN,OU=TheOU,DC=contoso,DC=com");

    C++ hr = ADsOpenObject( "LDAP://contoso.com/CN=TheCN,OU=TheOU,DC=contoso,DC=com", Null, Null, ADS_SECURE_AUTHENTICATION, IID_IADs, (void**)&IADsUser);

    .Net DirectoryEntry deIADsUser = new DirectoryEntry("LDAP://contoso.com/ CN=TheCN,OU=TheOU,DC=contoso,DC=com"); SearchResult srIADsUser = new DirectorySearcher(new DirectoryEntry("LDAP://contoso.com/ CN=TheCN,OU=TheOU,DC=contoso,DC=com"),"(&(objectClass=user)(!objectClass=inetOrgPerson)(cn=TheCN))", new String[] {"ADspath","memberOf"}).FindOne();

    * Note: There is actually no bind against AD objects. ADSI exports a GetObject bind - but this is nothing else than a base query against the obejct path in the underlying LDAP APIs.

  • Use IADsOpenDsObject with Flag ADS_READONLY_SERVER

    The ADSI interface IADsOpenDsObject::OpenDsobject gives us the possibility to force ADSI to accept RODCs in an LDAP-Bind as well. This is the only ADSI interface exporting this ability.
    The implementation is there - you guess it - becauseof backward compatibility to NT4 domains with their Backup Domain Controllers (= 'Read Only Domain Controller')
    Samples:

    VBS:

    Const ADS_SECURE_AUTHENTICATION = 1
    Const ADS_READONLY_SERVER = 4
    Const ADS_SERVER_BIND = 512
     
    Set oIADsUser = GetObject("LDAP:").OpenDSObject("LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com", vbNullString, vbNullString, ADS_SECURE_AUTHENTICATION Or ADS_READONLY_SERVER Or ADS_SERVER_BIND)

    .Net:
    DirectoryEntry deIADsUser = new DirectoryEntry(LDAP://contoso.com/ CN=TheCN,OU=TheOU,DC=contoso,DC=com, null, null, Authenticationtype.Secure | Authenticationtype.ReadonlyServer | Authenticationtype.ServerBind);

  • .NET System.DirectoryServices.Protocols & System.DirectoryServices.ActiveDirectory

    In the sample code below you will find an exemplary implementation of System.DirectoryServices.Protocols (S.DS.P) for communicating with AD. S.DS.P is a .Net wrapper library for the LDAP APIs and does not rely on the DCLocator cache or the Schema cache - it's up to the programmer to decide which DC to connect to and to take care of the property value translation into the proper syntax - the Schema cache is out of scope here -> thus we avoid the ADSI trap DCLocator cache and Schema caching.

using System.DirectoryServices.ActiveDirectory;
using System.DirectoryServices.Protocols;
using System.Security.Principal;

namespace SDSP_Play
{

    class Program
    {   

static string foundDC;
static string defNC; static LdapConnection ldapAsyncCon;
static int AsyncPagesRetreived; static long AsyncEntriesOverall;
static bool AsyncComplete;

static void Main(string[] args)
{

//find dc
DiscoverDC();

Console.WriteLine("DC: {0}", foundDC);

//get defaultnamingcontext from rootDSE
DefaultNamingContext(foundDC);

Console.WriteLine("DefaultNamingContext: {0}", defNC);

//get contructed attribute by base search
ReadTokenGroups("theboss");

//get all groups count
PagedSearch(foundDC, defNC, "(objectCategory=group)", 1000);

get all users count
PagedSearch(foundDC, defNC, "(objectCategory=user)", 468);

//get all objects count - asynchronous search
AsyncSearch(foundDC, defNC, "(objectCategory=*)");

AsyncPagesRetreived = 0;

AsyncEntriesOverall = 0;

//get all objects count - asynchronous paged search
PagedAsyncSearch(foundDC, defNC, "(objectCategory=*)", 500, null);

Console.ReadLine(); }

static void DiscoverDC()
{

DirectoryContextdirCtx = new DirectoryContext(DirectoryContextType.Domain);

LocatorOptions locOpts = LocatorOptions.ForceRediscovery | LocatorOptions.WriteableRequired;

string site = ActiveDirectorySite.GetComputerSite().ToString();

foundDC = DomainController.FindOne(dirCtx, site, locOpts).Name;

    }

static void DefaultNamingContext(string DC)
{

LdapDirectoryIdentifier ldapDirectoryIdentifier;
SearchRequest searchRequest;

SearchResponse searchResponse;

SearchResultEntry resultEntry;

ldapDirectoryIdentifier =

newLdapDirectoryIdentifier(DC, 389, true, true);

using (LdapConnection ldapCon =

new LdapConnection(ldapDirectoryIdentifier))

{

searchRequest = new SearchRequest("", "(objectClass=*)",

SearchScope.Base, Attributlist);

searchResponse = (SearchResponse)ldapCon.SendRequest(searchRequest);

foreach (resultEntry in searchResponse.Entries)

                  defNC = resultEntry.Attributes["defaultNamingContext"][0].ToString();

}

}

static string GetSingleObjectPath(string DC, string NC, string Query)
{

string DN = string.Empty;

string[] Attributlist = new string[] { "distinguishedName" };

using (LdapConnection ldapCon = new LdapConnection(DC))
{     

SearchRequest searchRequest;

SearchOptionsControl searchOptions;

SearchResponse searchResponse;

SearchResultEntry resultEntry;

//subtree search to get user path from defaultNamingContext
searchRequest = new SearchRequest(NC,

Query,

SearchScope.Subtree,

Attributlist);

//disable refferals
searchOptions = new SearchOptionsControl(SearchOption.DomainScope);

searchRequest.Controls.Add(searchOptions);

searchResponse = (SearchResponse)ldapCon.SendRequest(searchRequest);

foreach (resultEntry in searchResponse.Entries)
DN = searchResultEntry.Attributes["distinguishedName"][0].ToString();

}

return DN;

        }

static object[] GetConstructedAttribute(string DC, string DN,

string AttributeName, Type DesiredType)

SearchRequest searchRequest;

SearchResponse searchResponse;

SearchResultEntry resultEntry;

string[] Attributlist = new string[] { AttributeName };

using (LdapConnection ldapCon = new LdapConnection(DC))
{

object[] colValues = null;

//base search to get attribue from object

searchRequest = new SearchRequest(DN,

"((objectClass=*))",

SearchScope.Base,

Attributlist);

searchResponse = (SearchResponse)ldapCon.SendRequest(searchRequest);

foreach (SearchResultEntry resultEntry in searchResponse.Entries)
colValues = resultEntry.Attributes[AttributeName].GetValues(DesiredType);

return colValues;

}

}

static void ReadTokenGroups(string sAMAccountName)
{

string ldapFilter = "(&(objectCategory=person)(objectClass=user)" +

"(sAMAccountName=" + sAMAccountName + "))";

string objectDN = GetSingleObjectPath(foundDC, defNC, ldapFilter);

Console.WriteLine("ObjectPath: {0}", objectDN);

object[] colResult = GetConstructedAttribute(foundDC,

objectDN,

"tokenGroups",

Type.GetType("System.Byte[]"));

foreach (byte[] bySID in colResult)

      Console.WriteLine(new SecurityIdentifier(bySID, 0).ToString());

Console.ReadLine(); 

}

static void PagedSearch(string DC, string NC, string Query, int PageSize)
{

int PagesReceived = 0;

long EntriesOverall = 0;

string[] Attributlist = new string[] { "cn" };

using (LdapConnection ldapCon = new LdapConnection(DC))
{

SearchRequest searchRequest;

SearchOptionsControl searchOptions;

PageResultRequestControl pageRequest;

SearchResponse searchResponse;

PageResultResponseControl pageResponse;

Console.WriteLine("\nPaged Query {0}:\n", Query);

searchRequest = new SearchRequest(NC,

Query,

SearchScope.Subtree,

Attributlist);

//disable refferals
searchOptions = new SearchOptionsControl(SearchOption.DomainScope);

searchRequest.Controls.Add(searchOptions);

//activate paging
pageRequest = new PageResultRequestControl(PageSize);

searchRequest.Controls.Add(pageRequest);

while (true)
{

PagesReceived++;

searchResponse =

(SearchResponse)ldapCon.SendRequest(searchRequest);

//retreive PageResultResponseControl

pageResponse =

(PageResultResponseControl)searchResponse.Controls[0];

Console.WriteLine("Page {0} contains {1} entries",

PagesReceived, searchResponse.Entries.Count);

EntriesOverall = EntriesOverall + searchResponse.Entries.Count;

//uncomment the below to display detailed info:

//#######################################################################

//foreach (SearchResultEntry searchResultEntry in searchResponse.Entries)

//{

//    Console.WriteLine("{0}:{1}",

//    searchResponse.Entries.IndexOf(searchResultEntry) + 1,

//    searchResultEntry.DistinguishedName);

//}

//#######################################################################

// page info stored in the cookie - true when there are no more pages

if (pageResponse.Cookie.Length == 0)

break;

//send the received cookie back to pass page pointer

pageRequest.Cookie = pageResponse.Cookie;

}

}

Console.WriteLine("\nWe walked {0} pages with {1} entries",

PagesReceived, EntriesOverall);

Console.ReadKey();

}

static void AsyncSearch(string DC, string NC, string Query) {

SearchRequest searchRequest;

SearchOptionsControl searchOptions;

IAsyncResult asResult;

string[] Attributlist = new string[] { "cn" };

ldapAsyncCon = new LdapConnection(DC);

//increase def timeout of 2 min to keep connection open

ldapAsyncCon.Timeout = new TimeSpan(0, 2, 30);

searchRequest = new SearchRequest(NC, Query, SearchScope.Subtree, Attributlist);

//disable refferals

searchOptions = new SearchOptionsControl(SearchOption.DomainScope);

searchRequest.Controls.Add(searchOptions);

Console.WriteLine("\nSending Async Query {0}:\n", Query);

asResult = ldapAsyncCon.BeginSendRequest(searchRequest,

                                         PartialResultProcessing.NoPartialResultSupport,

                                         AsyncSearchCallBack,

                                         null);

Console.WriteLine("Waiting for results");

Console.ReadLine();

}

static void PagedAsyncSearch(string DC, string NC, string Query, int PageSize, byte[] PageCookie)

{

SearchRequest searchRequest;

SearchOptionsControl searchOptions;

IAsyncResult asResult;

PageResultRequestControl pageRequest;

AsyncComplete = false;

string[] Attributlist = new string[] { "cn" };

ldapAsyncCon = new LdapConnection(DC);

//increase def timeout of 2 min to keep connection open

ldapAsyncCon.Timeout = new TimeSpan(0, 2, 30);

searchRequest = new SearchRequest(NC, Query, SearchScope.Subtree, Attributlist);

//disable efferals

searchOptions = new SearchOptionsControl(SearchOption.DomainScope);

searchRequest.Controls.Add(searchOptions);

//activate paging

pageRequest = new PageResultRequestControl(PageSize);

searchRequest.Controls.Add(pageRequest);

Console.WriteLine("Sending Async Query {0} {1}:",

AsyncPagesRetreived, Query);

if (!(PageCookie == null))

pageRequest.Cookie = PageCookie;

asResult = ldapAsyncCon.BeginSendRequest(searchRequest,

PartialResultProcessing.NoPartialResultSupport,

PagedAsyncSearchCallBack,

null);

Console.WriteLine("Wait Page({0})", AsyncPagesRetreived);

if (AsyncComplete)

Console.WriteLine("StopWait Page({0})", AsyncPagesRetreived);

}

static void AsyncSearchCallBack(IAsyncResult Result)

{

try

{

SearchResponse searchResponse = (SearchResponse)ldapAsyncCon.EndSendRequest(Result);

Console.WriteLine("Entries found: {0}", searchResponse.Entries.Count);

//uncomment the below to display detailed info: //#######################################################################

//foreach (SearchResultEntry searchResultEntry in searchResponse.Entries)

//{

//    Console.WriteLine("{0}:{1}",

//    searchResponse.Entries.IndexOf(searchResultEntry),

//    searchResultEntry.DistinguishedName);

//}

//#######################################################################        

}

catch (Exception Ex)

{ Console.WriteLine("Async Error: {0}", Ex.Message); }

}

static void PagedAsyncSearchCallBack(IAsyncResult Result)

{   

PageResultResponseControl pageResponse;

SearchResponse searchResponse;

SearchResponse searchResponse;

AsyncComplete = true;

searchResponse = (SearchResponse)ldapAsyncCon.EndSendRequest(Result);

//retreive PageResultResponseControl

pageResponse = (PageResultResponseControl)searchResponse.Controls[0];

AsyncPagesRetreived++;

Console.WriteLine("Page {0} contains {1} entries",

AsyncPagesRetreived, searchResponse.Entries.Count);

AsyncEntriesOverall = AsyncEntriesOverall + searchResponse.Entries.Count;

//uncomment the below to display detailed info:

//#######################################################################

//foreach (SearchResultEntry searchResultEntry in searchResponse.Entries)

//{

//    Console.WriteLine("{0}:{1}",

//    searchResponse.Entries.IndexOf(searchResultEntry) + 1,

//    searchResultEntry.DistinguishedName);

//}

//#######################################################################

Console.WriteLine("We walked {0} pages with {1} entries (async)",

AsyncPagesRetreived, AsyncEntriesOverall);

if (!(pageResponse.Cookie.Length == 0))

PagedAsyncSearch(foundDC,

defNC,

"(objectCategory=*)",

500,

pageResponse.Cookie);

}

Hope you had some fun reading this post.

[Edit] 8/12/2014:
Changed

Set oIADsUser = GetObject("LDAP:").OpenDSObject("LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com", Nothing, Nothing, ADS_SECURE_AUTHENTICATION Or ADS_READONLY_SERVER Or ADS_SERVER_BIND)

to

Set oIADsUser = GetObject("LDAP:").OpenDSObject("LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com", vbNullString, vbNullString, ADS_SECURE_AUTHENTICATION Or ADS_READONLY_SERVER Or ADS_SERVER_BIND)

since OPenDSObject interface expects string values as username and password and will fail when passing Nothing as username or password.

 

All the best

Michael

PFE | Have keyboard. Will travel.