Windows Server for beginners. Part 5: LDAP from the inside: the directory through ADSI Edit

All parts of the series “Windows Server for beginners”

Everything that reads or changes the directory — the Active Directory Users and Computers snap-in, the PowerShell module, Exchange, sssd on Linux, a ticketing system that's "integrated with AD" — talks to the controller over LDAP. Active Directory is an LDAP server: version 3, per RFC 4510–4519, with Microsoft extensions described in the open MS-ADTS specification. Snap-ins show the directory edited and translated; LDAP shows it as it is. In this part that's exactly how we'll look at it, and it's the only part of the series where graphical tools — ldp.exe and ADSI Edit — aren't a side note but the subject: they show the raw directory, and next to each of their actions we'll put the same operation in PowerShell.

The second half of the part is about what in the directory breaks after a careless edit and can't be fixed by rolling back. The directory stores both data and its own configuration, and the configuration is protected from the administrator no better than the data.


The protocol

LDAP is a binary protocol over TCP, with messages encoded in ASN.1 BER. The client opens a connection to port 389 (or 636 with TLS, 3268/3269 for the global catalog) and sends requests; each request carries a message number, and responses come back with the same number, so several requests can run over one connection at once. There are nine operations:

Operation What it does
Bind authenticates the connection: who you are. All subsequent operations run with that principal's rights
Unbind closes the connection
Search finds objects: base, scope, filter, attribute list. The only read operation: "reading an object" is a search with base scope
Compare checks whether an attribute equals a value without reading it (this is how group membership is checked without the right to read member)
Add, Delete create and delete an object
Modify add, replace or delete attribute values
ModifyDN rename or move an object
Extended extended operations: StartTLS, WhoAmI and others by OID

Plus controls — add-ons to any operation, also identified by OID: paged results (1.2.840.113556.1.4.319; without it the controller returns at most 1,000 objects), server-side sorting, show deleted objects (1.2.840.113556.1.4.417), return only part of the security descriptor (1.2.840.113556.1.4.801), change notifications. The server reports which controls it supports in RootDSE, which we'll get to shortly.

Bind comes in two kinds. Simple is a DN or UPN and a password in clear text. Without TLS that means sending the password over the network as is; by default the controller rejects simple binds without TLS and logs every attempt as event 2889 in the Directory Service log: "such-and-such client attempted to bind without signing". SASL negotiates a mechanism: GSS-SPNEGO (Kerberos with fallback to NTLM; everything in Windows uses it), GSSAPI (pure Kerberos; Linux clients use it), DIGEST-MD5, EXTERNAL (by a TLS client certificate). After a SASL bind with Kerberos the connection is also signed, and the controller requires this: the "LDAP server signing requirements" setting is set to Require on modern controllers, and channel binding, which ties the TLS session to the authentication, is set to When supported.

A search is defined by four things: the base (the DN to start from), the scope (base — only the object itself, onelevel — its direct children, subtree — the whole subtree), the filter and the attribute list (empty — all non-hidden; * — all user attributes, + — operational ones). The server responds with a stream of SearchResultEntry records and a final SearchResultDone with a result code.


The object name

From the previous parts: an object has a DN. Let's take a closer look.

CN=Alice Smith,OU=Sales,OU=Departments,DC=corp,DC=example,DC=com
└─── RDN ────┘ └─────────── parent DN ───────────────────────────┘

The RDN (relative distinguished name) is the object's name within its parent, attribute=value. For users, groups and computers it's CN, for organizational units OU, for domain components DC. An RDN is unique among siblings, a DN across the whole forest. The attribute the RDN is built from is defined for each class in the schema (rDNAttID), and the RDN value itself is also stored in the name attribute: for a user, cn and name always match.

Special characters in an RDN value are escaped with a backslash: comma, plus, quote, backslash, <, >, ;, =, a space or # at the start, a space at the end. The user "Smith, Alice" is CN=Smith\, Alice,OU=Sales,…. That's why in scripts it's better not to build DNs from strings but to take them from the object.

Case doesn't matter in a DN, and spaces after commas are allowed, but the controller stores and returns DNs in a single canonical form. There's another notation as well — the canonical name: corp.example.com/Departments/Sales/Alice Smith, left to right; ADSI Edit shows it, and the computed attribute canonicalName returns it.

Three location-independent identifiers were covered in part 3: objectGUID (all objects, immutable), objectSid (security principals), sAMAccountName and userPrincipalName. In LDAP you can address objects directly by GUID and SID: <GUID=7a1b…> and <SID=S-1-5-21-…> instead of a DN as the search base.


Attributes

An attribute is a name, a syntax and one or more values. Here's what's worth knowing.

Syntaxes. Each attribute in the schema has a value type, and it determines how the value is compared and how to read it:

Syntax Examples What it looks like
Unicode String cn, displayName, mail a string, case-insensitive comparison
Integer userAccountControl, primaryGroupID, logonCount a 32-bit number
Large Integer / Interval pwdLastSet, lastLogonTimestamp, accountExpires, lockoutTime, badPasswordTime a 64-bit number: FILETIME, hundreds of nanoseconds since January 1, 1601 UTC; 0 means "never" or "required", 9223372036854775807 means "never expires"
Generalized Time whenCreated, whenChanged a string 20261015093011.0Z
Distinguished Name member, memberOf, manager, managedBy the DN of another object
Octet String objectGUID, objectSid, userCertificate, thumbnailPhoto bytes
Boolean isDeleted, showInAdvancedViewOnly TRUE / FALSE
NT Security Descriptor nTSecurityDescriptor a binary security descriptor: owner, DACL, SACL

Two dates in two different formats on the same object is a historical fact you have to live with: whenCreated is human-readable, while pwdLastSet needs [DateTime]::FromFileTime().

Bit fields. userAccountControl is 32 flags in one number, and it's the account's most important attribute:

Bit Hex Flag
1 0x0002 ACCOUNTDISABLE — disabled
4 0x0010 LOCKOUT — read-only; the real lockout is in lockoutTime
5 0x0020 PASSWD_NOTREQD — password not required
9 0x0200 NORMAL_ACCOUNT — a regular user
12 0x1000 WORKSTATION_TRUST_ACCOUNT — a computer
13 0x2000 SERVER_TRUST_ACCOUNT — a domain controller
16 0x10000 DONT_EXPIRE_PASSWORD — password never expires
19 0x80000 TRUSTED_FOR_DELEGATION — unconstrained Kerberos delegation
22 0x400000 DONT_REQ_PREAUTH — Kerberos without pre-authentication

A regular enabled user is 512, a disabled one is 514, one with a non-expiring password is 66048. The snap-in shows this as checkboxes, ADSI Edit as a number, and it's easier to get the arithmetic wrong there than you'd think; in PowerShell, Set-ADUser has named parameters -Enabled and -PasswordNeverExpires that do the math for you.

Linked attributes. member on a group and memberOf on a user are one link recorded twice. Only the forward link (member) is stored; the back link (memberOf) is computed from it and isn't replicated separately. Two consequences: you can't change memberOf directly (only through the group's member), and memberOf doesn't show membership in groups of other domains unless the controller is a global catalog. The same goes for manager/directReports.

Constructed attributes. They aren't in the database; the controller computes them on read, and only when explicitly requested: canonicalName, tokenGroups (all SIDs of the user's groups, including nested ones), msDS-User-Account-Control-Computed (here's the honest LOCKOUT flag and the expired-password flag), allowedAttributes, msDS-ReplAttributeMetaData.

Non-replicated attributes. lastLogon, badPwdCount, badPasswordTime and logonCount are stored on each controller separately and don't travel between them: otherwise every user logon would trigger forest-wide replication. To find out when a user last signed in, you have to query all controllers and take the maximum. For a rough estimate there's lastLogonTimestamp: it is replicated, but updated no more often than every 9–14 days, and it's good for finding accounts unused for six months, not for investigations.

Replication metadata. Every object has uSNCreated and uSNChanged — change sequence numbers on the controller you're reading from (they differ between controllers) — whenChanged, and for each attribute, the version, time and originating controller of its last change:

PS C:\> Get-ADReplicationAttributeMetadata -Object 'CN=Alice Smith,OU=Sales,OU=Departments,DC=corp,DC=example,DC=com' -Server dc1 |
    Where-Object AttributeName -in 'pwdLastSet','userAccountControl','mail' |
    Format-Table AttributeName, Version, LastOriginatingChangeTime, LastOriginatingChangeDirectoryServerIdentity -AutoSize

AttributeName      Version LastOriginatingChangeTime LastOriginatingChangeDirectoryServerIdentity
-------------      ------- ------------------------- --------------------------------------------
mail                     2 14.10.2026 16:21:07       CN=NTDS Settings,CN=DC1,CN=Servers,CN=Office,CN=Sites,CN=Configuration,DC=corp,DC=example,DC=com
pwdLastSet               3 15.10.2026 9:30:11        CN=NTDS Settings,CN=DC1,CN=Servers,CN=Office,CN=Sites,CN=Configuration,DC=corp,DC=example,DC=com
userAccountControl       1 01.10.2026 12:00:45       CN=NTDS Settings,CN=DC1,CN=Servers,CN=Office,CN=Sites,CN=Configuration,DC=corp,DC=example,DC=com

This answers "who changed this user's password and when" without auditing: version 3 means the password has been changed three times, the last time on October 15 via dc1. In the part about replication we'll see how controllers resolve conflicts using this metadata.


The schema

The schema is objects too, in the CN=Schema,CN=Configuration,… partition, of two classes: classSchema describes classes, attributeSchema describes attributes.

PS C:\> $schema = (Get-ADRootDSE).schemaNamingContext
PS C:\> Get-ADObject -SearchBase $schema -LDAPFilter '(lDAPDisplayName=pwdLastSet)' -Properties attributeID, attributeSyntax, oMSyntax, isSingleValued, searchFlags, isMemberOfPartialAttributeSet, systemFlags, schemaIDGUID

attributeID                   : 1.2.840.113556.1.4.96
attributeSyntax               : 2.5.5.16
DistinguishedName             : CN=Pwd-Last-Set,CN=Schema,CN=Configuration,DC=corp,DC=example,DC=com
isMemberOfPartialAttributeSet : False
isSingleValued                : True
lDAPDisplayName               : pwdLastSet
Name                          : Pwd-Last-Set
oMSyntax                      : 65
searchFlags                   : 0
systemFlags                   : 17

attributeID is the attribute's OID, globally unique: 1.2.840.113556 is Microsoft, .1.4 is AD attributes. attributeSyntax 2.5.5.16 with oMSyntax 65 is Large Integer. isMemberOfPartialAttributeSet says whether it goes into the global catalog. searchFlags is a bit field: 1 — the attribute is indexed, 4 — it takes part in ANR ("search by any name", see below), 8 — it's preserved when the object is deleted, 128 — confidential (only those with an explicit Control Access right can read it). A class has a list of mandatory (mustContain, systemMustContain) and optional (mayContain) attributes, a parent class (subClassOf) and auxiliary classes (auxiliaryClass) whose attributes it also gets.

The schema is extensible: Exchange adds about one and a half thousand attributes and classes, Entra Connect about ten, and you can describe your own extensions in an LDIF file and load it with ldifde. Two facts about extending it that you need to know before you do it. An extension is irreversible: a created class or attribute can't be deleted, only declared defunct (isDefunct = TRUE) and its name reused. And it is forest-wide: an extension loaded for one application in one domain appears on every controller of every domain. That's why the Schema Admins group is empty by default, and an administrator is added to it only for the duration of the operation.


RootDSE

RootDSE (DSA-specific entry) is a pseudo-object with an empty DN, the root of a particular server's tree. It answers a base-scope search without a bind: it's the only thing a controller gives out anonymously by default, and that's how clients find out who they're dealing with.

PS C:\> Get-ADRootDSE -Server dc1 | Select-Object dnsHostName, serverName, defaultNamingContext, highestCommittedUSN, currentTime, isSynchronized, supportedSASLMechanisms, domainControllerFunctionality

dnsHostName                   : dc1.corp.example.com
serverName                    : CN=DC1,CN=Servers,CN=Office,CN=Sites,CN=Configuration,DC=corp,DC=example,DC=com
defaultNamingContext          : DC=corp,DC=example,DC=com
highestCommittedUSN           : 41237
currentTime                   : 15.10.2026 9:41:08
isSynchronized                : True
supportedSASLMechanisms       : {GSSAPI, GSS-SPNEGO, EXTERNAL, DIGEST-MD5}
domainControllerFunctionality : Windows2025

highestCommittedUSN is the last change number on this controller; replication partners use it to figure out what's new on it. isSynchronized says whether the controller has finished its initial synchronization after startup; while it's FALSE, the controller doesn't advertise itself to clients. supportedControl and supportedCapabilities are lists of OIDs with what the server can do: from the presence of 1.2.840.113556.1.4.800 a client learns that it's talking to Active Directory and not some other LDAP server.


Filters

A search filter is an expression in prefix notation per RFC 4515. The elements:

(attr=value)          equals; case-insensitive for strings
(attr=*)              the attribute is present (presence)
(attr=Ali*)           substring; * can be at the start, middle and end
(attr>=value)         greater than or equal; (attr<=value) less than or equal; no strict < and >
(&(f1)(f2)...)        AND
(|(f1)(f2)...)        OR
(!(f1))               NOT

Special characters in a value are escaped with a hex code: \2a for *, \28 and \29 for parentheses, \5c for \, \00 for null.

Two Microsoft matching rules without which you can't write filters for AD:

  • Bitwise AND 1.2.840.113556.1.4.803 and bitwise OR 1.2.840.113556.1.4.804 for bit fields. Disabled accounts: (userAccountControl:1.2.840.113556.1.4.803:=2).
  • Chain 1.2.840.113556.1.4.1941 for DN attributes: the comparison is transitive. All members of a group, including nested ones: (memberOf:1.2.840.113556.1.4.1941:=CN=Domain Admins,CN=Users,DC=corp,DC=example,DC=com). It's the only way to get nested membership in a single query, and it's expensive.

The controller accepts the standard's approximate match ~=, but executes it as plain equality.

A few working filters:

(&(objectCategory=person)(objectClass=user))                        all users, without computers
(&(objectCategory=computer)(operatingSystem=*Server*))              servers
(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))   enabled ones
(&(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=65536))                         password never expires
(&(objectClass=user)(pwdLastSet=0))                                 must change password at next logon
(&(objectClass=user)(lockoutTime>=1))                               locked out (more precisely, those with a lockout time set)
(&(objectCategory=computer)(lastLogonTimestamp<=133700000000000000))  haven't logged on since a certain date in FILETIME
(anr=ali)                                                           ANR: search by cn, givenName, sn, displayName, sAMAccountName and a few more at once

Why objectCategory=person together with objectClass=user: objectClass is multi-valued (top; person; organizationalPerson; user), and computers match it too, because the computer class descends from user; objectCategory is single-valued and indexed, so searching by it is faster. ANR (ambiguous name resolution) is what the search box in Outlook does: one filter expands into an OR over several indexed attributes with a prefix match.

The ActiveDirectory module accepts a filter directly: Get-ADUser -LDAPFilter '(&(objectCategory=person)(pwdLastSet=0))'. Its own -Filter 'Enabled -eq $true -and PasswordNeverExpires -eq $true' is a convenient wrapper that the module translates into LDAP before sending; what exactly it sent can be seen in the controller's log, as shown at the end.


ldp.exe

ldp.exe is an LDAP client from the AD DS tools: it's on every controller and comes with RSAT. It shows exactly what goes over the protocol, without translation.

Connection → Connect: server dc1.corp.example.com, port 389 (or 636 with the SSL checkbox). The right pane shows the whole RootDSE the server returned anonymously. Connection → Bind: by default "Bind as currently logged on user" via GSS-SPNEGO, and the pane will show Authenticated as: 'CORP\alice.admin'. View → Tree, with an empty base DN or DC=corp,DC=example,DC=com — on the left there's a tree that expands with onelevel queries, and double-clicking an object does a base search and prints its attributes:

Expanding base 'CN=Alice Smith,OU=Sales,OU=Departments,DC=corp,DC=example,DC=com'...
Getting 1 entries:
Dn: CN=Alice Smith,OU=Sales,OU=Departments,DC=corp,DC=example,DC=com
accountExpires: 9223372036854775807 (never);
badPasswordTime: 0 (never);
badPwdCount: 0;
cn: Alice Smith;
codePage: 0;
countryCode: 0;
displayName: Alice Smith;
distinguishedName: CN=Alice Smith,OU=Sales,OU=Departments,DC=corp,DC=example,DC=com;
dSCorePropagationData: 0x0 = (  );
givenName: Alice;
instanceType: 0x4 = ( WRITE );
lastLogonTimestamp: 15.10.2026 9:02:11 W. Europe Daylight Time;
logonCount: 14;
memberOf: CN=Sales,OU=Groups,DC=corp,DC=example,DC=com;
name: Alice Smith;
objectCategory: CN=Person,CN=Schema,CN=Configuration,DC=corp,DC=example,DC=com;
objectClass (4): top; person; organizationalPerson; user;
objectGUID: 9c3f1a2b-7d4e-4f60-8a1b-2c3d4e5f6a7b;
objectSid: S-1-5-21-3623811015-3361044348-30300820-1104;
primaryGroupID: 513 = ( GROUP_RID_USERS );
pwdLastSet: 15.10.2026 9:30:11 W. Europe Daylight Time;
sAMAccountName: alice;
sAMAccountType: 805306368 = ( NORMAL_USER_ACCOUNT );
sn: Smith;
userAccountControl: 0x200 = ( NORMAL_ACCOUNT );
userPrincipalName: alice@corp.example.com;
uSNChanged: 41190;
uSNCreated: 12805;
whenChanged: 15.10.2026 9:30:11 W. Europe Daylight Time;
whenCreated: 01.10.2026 12:00:45 W. Europe Daylight Time;

ldp decodes FILETIMEs, SIDs, GUIDs and flags right in the output — that's its main convenience. Browse → Search opens a window with the base, filter, scope and attribute list: it's a handy place to debug filters before putting them into a script. Options → Controls adds controls to a search: with 1.2.840.113556.1.4.417, CN=Deleted Objects with deleted objects appears in the tree. Browse → Modify lets you change attributes, but there's a more convenient tool for that.

In PowerShell the same read is Get-ADObject -Identity $dn -Properties * or Get-ADUser alice -Properties *, except that the module decodes dates into DateTime and SIDs into a SecurityIdentifier object; the raw bytes come from [adsi]"LDAP://$dn" — a .NET wrapper over ADSI that does the same LDAP under the hood.


ADSI Edit

ADSI Edit (adsiedit.msc) is an MMC snap-in that shows the directory as a tree with the full properties of every object. You need it because the Users and Computers snap-in shows a filtered directory: only the containers it considers relevant and only the attributes it has tabs for (the Attribute Editor tab appeared in 2008 and helps, but it won't show objects outside the Domain partition).

Action → Connect to: a choice between four well-known partitions — Default naming context (the domain), Configuration, Schema, RootDSE — or an arbitrary DN, such as DC=DomainDnsZones,DC=corp,DC=example,DC=com. Each partition gets its own connection, and they're shown side by side in the tree. From there it's an ordinary tree; an object's properties list all attributes with their syntax and value, and the "Show only attributes that have values" filter hides the empty ones. Any attribute can be edited, and the snap-in will check the syntax, but not the meaning.

A tour of what Users and Computers doesn't show.

The domain root DC=corp,DC=example,DC=com is an object too, and its attributes are the domain settings:

Attribute What it holds
minPwdLength, pwdHistoryLength, maxPwdAge, minPwdAge, lockoutThreshold, lockoutDuration, pwdProperties the domain password policy. When applied, Default Domain Policy writes its values here, and controllers read them from here, not from the GPO
ms-DS-MachineAccountQuota how many computers a regular user can join to the domain: 10 by default, and it's often set to 0
fSMORoleOwner the DN of the NTDS settings of the controller holding the PDC emulator role
wellKnownObjects, otherWellKnownObjects where the Users, Computers, Deleted Objects, Domain Controllers and other containers live: "role GUID → DN" pairs
msDS-Behavior-Version the domain functional level: 7 is 2016, 10 is 2025
rIDManagerReference a reference to CN=RID Manager$,CN=System

CN=System holds the domain's service objects:

  • CN=Policies — group policy containers CN={GUID} with the attributes displayName, gPCFileSysPath (the path in SYSVOL), versionNumber.
  • CN=Password Settings Container — fine-grained password policies (PSOs).
  • CN=AdminSDHolder — the permission template for protected accounts, covered in the section on risks.
  • CN=RID Manager$ — the RID pool: rIDAvailablePool stores how many identifiers haven't been handed out yet, fSMORoleOwner is the holder of the RID Master role.
  • CN=Infrastructure — fSMORoleOwner of the Infrastructure Master role.
  • CN=DFSR-GlobalSettings — SYSVOL replication.
  • CN=Default Domain Policy, CN=Domain Updates, CN=File Replication Service, CN=IP Security, CN=RpcServices, CN=WinsockServices, CN=ComPartitions — objects nobody touches for years, about which you need to know one thing: they're needed.

CN=Configuration is the forest:

  • CN=Sites — sites, subnets, servers, NTDS Settings objects and replication connections. This branch is the subject of part 10.
  • CN=Partitions — crossRef objects for each partition in the forest with its DNS name and NetBIOS name; fSMORoleOwner here is the Domain Naming Master; msDS-Behavior-Version is the forest functional level. The forest's alternative UPN suffixes are stored in this container's uPNSuffixes.
  • CN=Services → CN=Windows NT → CN=Directory Service — forest-wide directory service parameters: tombstoneLifetime and dSHeuristics, both covered in the section on risks.
  • CN=Services → CN=Public Key Services — the configuration of the forest's certificate authorities, certificate templates (part 25).
  • CN=Services → CN=NetServices — the list of authorized DHCP servers (part 8).
  • CN=Extended-Rights — extended rights: "change password", "reset password", "replicating directory changes" (the very right needed for the DCSync attack and for Entra Connect).
  • CN=WellKnown Security Principals — Everyone, Authenticated Users, SYSTEM and other principals with SIDs like S-1-5-11 that don't belong to any domain.

CN=Deleted Objects (visible only with the deleted objects control) holds tombstones: a deleted object doesn't disappear but is moved here, loses most of its attributes, gets isDeleted = TRUE, lastKnownParent and a \0ADEL:<GUID> suffix in its name, and lives like this for tombstoneLifetime days (180 by default) so the fact of deletion has time to reach every controller. If the AD Recycle Bin is enabled, the object first becomes deleted with its full set of attributes and can be restored, and only later recycled.

PS C:\> Get-ADObject -Filter 'isDeleted -eq $true -and objectClass -eq "user"' -IncludeDeletedObjects -Properties lastKnownParent, whenChanged | Format-Table Name, lastKnownParent, whenChanged

Name                                                       lastKnownParent                                   whenChanged
----                                                       ---------------                                   -----------
Bob Jones
DEL:3d2e1f0a-9b8c-4d7e-6f50-a1b2c3d4e5f6                   OU=Sales,OU=Departments,DC=corp,DC=example,DC=com 14.10.2026 18:02:33

Editing attributes from PowerShell instead of ADSI Edit is Set-ADObject with one of four parameters: -Replace @{attr='value'}, -Add, -Remove, -Clear. The difference between Replace and Add matters for multi-valued attributes: the first replaces the whole list, the second adds a value to the existing ones.


What breaks

The controller checks syntax, mandatory attributes and permissions. It doesn't check that you understand what you're doing. Here is a list of things that break from edits in ADSI Edit and Set-ADObject and can't be undone with Ctrl+Z.

The schema. Any addition is irreversible, as already said. The danger is subtler: an attribute with someone else's OID or an lDAPDisplayName that Exchange or another product will later want to create makes installing that product impossible; turning on indexing in searchFlags for a frequently changing attribute slows down every controller; clearing flag 8 (preserve on deletion) on an attribute needed for restores makes the recycle bin useless. Extensions are tested in a separate forest and then loaded into production via the LDIF that passed the test.

Service containers. CN=System, CN=Infrastructure, CN=RID Manager$, NTDS Settings objects, partitions and the containers from wellKnownObjects have "can't delete" and "can't rename" bits set in systemFlags, and ADSI Edit will refuse. But a domain administrator can clear those bits, and then the controller will obey. A deleted CN=RID Manager$ means no controller in the domain can create a single new object; a deleted NTDS Settings object means the controller stopped being a controller for everyone else; a deleted CN=Users means half of the tools can't create a user. Recovery is from a backup with an authoritative restore (part 20), and that takes hours.

wellKnownObjects. This attribute shows where the default containers are, and it's changed by the redirusr and redircmp utilities when you want new users and computers to go into your own OU rather than CN=Users and CN=Computers. That's the correct way. Editing the attribute by hand to point to a non-existent or unsuitable DN makes it impossible to create objects of that class without explicitly specifying a container, and the error will be cryptic.

dSHeuristics. A string in which each character is a separate switch for the whole forest: the third character controls the List Object right, the seventh controls anonymous LDAP operations (2 lets an anonymous client read the whole directory, not just RootDSE), the ninth controls support for the userPassword attribute as a password, and so on for about thirty positions. The tenth character must be 1 and the twentieth 2, otherwise the string is considered corrupt and stops working entirely. It's edited when you need, say, to hide objects from users without rights (List Object mode) or to let Linux clients read password attributes, and every time — with the MS-ADTS documentation, counting positions, on a test forest.

tombstoneLifetime. The lifetime of deleted objects, 180 days. Lowering it is dangerous: a controller that hasn't replicated for longer than this period will, after returning to the network, start bringing deleted objects back (lingering objects), and cleaning that up is a whole chapter of the part about troubleshooting.

AdminSDHolder. Once an hour, the SDProp process on the controller holding the PDC emulator role takes the security descriptor of the CN=AdminSDHolder,CN=System object and writes it to all members of the protected groups (Domain Admins, Enterprise Admins, Schema Admins, Administrators, Account Operators, Backup Operators, Server Operators, Print Operators) and their computer accounts, disabling inheritance on them and setting adminCount = 1. This is how the directory prevents permissions on administrator accounts from being granted through OUs. Consequences: permissions granted directly on an administrator's account disappear within an hour; changing the ACL on AdminSDHolder changes the permissions on every administrator in the forest at once; a user removed from Domain Admins keeps adminCount = 1 and disabled inheritance until you restore them by hand, and because of this, for example, their password won't sync to Entra ID or delegated password reset won't work for them. To find such accounts: Get-ADUser -LDAPFilter '(adminCount=1)'.

nTSecurityDescriptor. Permissions on an object are an ordinary Windows security descriptor with ACEs for SIDs, extended rights and individual attributes. Two rules. Don't touch the entries for SYSTEM, Enterprise Domain Controllers and Domain Controllers: controllers use them to replicate this object to each other, and an object without them stops replicating. And don't disable inheritance on containers to "remove extra permissions": the default descriptor for a class (defaultSecurityDescriptor in the schema) is designed around inheritance, and half the services will stop seeing what they need. Edit permissions with dsacls or Set-Acl on the AD: drive, reading the result, not with checkboxes.

primaryGroupID and member. The user's primary group (513, Domain Users) isn't stored in member; an attempt to remove the user from Domain Users via member will fail, and so will changing primaryGroupID to a group the user isn't in. The krbtgt account: it must not be deleted, enabled or renamed, and nothing about it can be changed except the password (and changing the password is a separate procedure with its own rules, part 6).

Three protections worth enabling before you need them. The AD Recycle Bin: Enable-ADOptionalFeature 'Recycle Bin Feature' -Scope ForestOrConfigurationSet -Target corp.example.com — irreversible, free, and once enabled, deleted objects are restored with Restore-ADObject with all attributes and memberships. Protection from accidental deletion: a checkbox in the snap-in, also Set-ADObject -ProtectedFromAccidentalDeletion $true; technically it's a Deny ACE for Delete and Delete Subtree for Everyone, and a protected OU can't be deleted or moved until the flag is cleared. It's set by default on OUs created in the snap-in and not set on those created with New-ADOrganizationalUnit without the parameter. And a system state backup of at least one controller, which we'll deal with in part 20.


A hands-on look

Decoding attributes the module doesn't decode by itself:

PS C:\> $u = Get-ADUser alice -Properties pwdLastSet, lastLogon, objectSid, userAccountControl, tokenGroups
PS C:\> [DateTime]::FromFileTime($u.pwdLastSet)
15.10.2026 9:30:11

PS C:\> $u.objectSid.Value
S-1-5-21-3623811015-3361044348-30300820-1104

PS C:\> $uac = $u.userAccountControl
PS C:\> 0..31 | Where-Object { $uac -band (1 -shl $_) } | ForEach-Object { '0x{0:X}' -f (1 -shl $_) }
0x200

PS C:\> $u.tokenGroups | ForEach-Object { (New-Object System.Security.Principal.SecurityIdentifier($_, 0)).Translate([System.Security.Principal.NTAccount]).Value }
CORP\Domain Users
CORP\Sales
BUILTIN\Users

tokenGroups is the list of groups the user will get in a Kerberos ticket, including nested ones and the primary group; it's a constructed attribute and has to be requested explicitly.

The last logon across all controllers, because lastLogon isn't replicated:

PS C:\> Get-ADDomainController -Filter * | ForEach-Object {
    $dc = $_.HostName
    Get-ADUser alice -Server $dc -Properties lastLogon | Select-Object @{n='DC'; e={$dc}}, @{n='LastLogon'; e={[DateTime]::FromFileTime($_.lastLogon)}}
}

DC                   LastLogon
--                   ---------
dc1.corp.example.com 15.10.2026 9:02:11
dc2.corp.example.com 13.10.2026 17:44:50

Which LDAP queries reach the controller and what the module translates them into. A controller has a log of "expensive" and inefficient queries: event 1644 in Directory Service, enabled with a diagnostics key:

PS C:\> Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics' -Name '15 Field Engineering' -Value 5
PS C:\> New-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Parameters' -Name 'Search Time Threshold (msecs)' -Value 1 -PropertyType DWord -Force | Out-Null
PS C:\> Get-ADUser -Filter 'Enabled -eq $true -and PasswordNeverExpires -eq $true' | Out-Null
PS C:\> Get-WinEvent -LogName 'Directory Service' -MaxEvents 1 -FilterXPath '*[System[EventID=1644]]' | Select-Object -ExpandProperty Message

Internal event: A client issued a search operation with the following options.

Client:
    192.168.1.100:51722
Starting node:
    DC=corp,DC=example,DC=com
Filter:
     ( &  ( !  (userAccountControl&2) )  (userAccountControl&65536) )
Search scope:
    subtree
Attribute selection:
    distinguishedName,name,objectClass,objectGUID,objectSid,sAMAccountName,userPrincipalName,givenName,sn,userAccountControl
Server controls:
     1.2.840.113556.1.4.801 (SD flags)
     1.2.840.113556.1.4.319 (paged results)
Visited entries:
    148
Returned entries:
    3
Used indexes:
    idx_userAccountControl:1:N;

This is what -Filter 'Enabled -eq $true -and PasswordNeverExpires' looks like after translation: two bitwise comparisons, paged results, an index on userAccountControl. We set the threshold to 1 ms to see every query; on a production system the threshold is left at the default (30 seconds) or set to a few hundred milliseconds to catch only what really loads the controller — and this is one of the main tools of the troubleshooting part. Don't forget to set 15 Field Engineering back to 0.

And finally, the protocol itself in packets. As in part 2, we capture with pktmon filtered on port 389 while running Get-ADUser alice, open it in Wireshark and see:

No.  Source          Destination    Protocol  Info
1    192.168.1.100   192.168.1.10   LDAP      bindRequest(1) "<ROOT>" sasl
2    192.168.1.10    192.168.1.100  LDAP      bindResponse(1) saslBindInProgress
3    192.168.1.100   192.168.1.10   LDAP      bindRequest(2) "<ROOT>" sasl
4    192.168.1.10    192.168.1.100  LDAP      bindResponse(2) success
5    192.168.1.100   192.168.1.10   LDAP      SASL GSS-API Privacy: searchRequest(3) ...
6    192.168.1.10    192.168.1.100  LDAP      SASL GSS-API Privacy: searchResEntry(3) ... searchResDone(3) success

Two rounds of the SASL bind (GSS-SPNEGO with a Kerberos ticket for ldap/dc1.corp.example.com inside), and then a search Wireshark can no longer read: after a Kerberos bind the data is encrypted. That is the answer to the question of why LDAP needs Kerberos, and the bridge to the next part.


Next — Part 6: Kerberos from the inside. What a realm and a KDC are, why the krbtgt account is needed, how the AS and TGS exchanges work, what's inside a ticket and why it needs a PAC, how the AES and RC4 encryption types differ, what an SPN is and why without one authentication falls back to NTLM, how delegation works, and how to read klist and KRB5KDC_ERR_* errors.

Only registered users can leave a comment.

Sign in or register to leave a comment.