This post serves for those who want to know the basics of LDAP reconnaissance and doing this in an OPSEC-safe manner. It is also a nice cheat sheet for future me. Stay tuned for the bonus at the end!

LDAP syntax

LDAP syntax follow this format in general:

(attribute operator value)
  • Attribute is a field name, e.g., sAMAccountName.
  • Operator is a comparison type, e.g., equal to (=). A list of all operators can be found here.
  • Value: the pattern to match. Allows for wildcards. For example, (sAMAccountName=j*) searches for all usernames starting with “j”.

You can chain more search filters together. For example,

(|(attribute1=value1)(attribute2=value2))

will search for objects where either value1 is the value of attribute1 or value2 is the value of attribute2. For the full syntax documentation, see Microsoft.

Queries

We now list some queries for LDAP reconnaissance. Placeholders are in <>.

The following examples use the ldapsearch BOF from TrustedSec’s Situational Awareness repo. In my lab, I use Havoc as C2 which uses a pretty outdated ldapsearch BOF by default. I have written a new “agressor” script, which supports the latest version of the BOF. The script can be found here.

Important: add --attributes *,ntsecuritydescriptor to get the ACLs field on the objects you are querying. This is helpful in BloodHound. Also, make sure there are no spaces after ntsecuritydescriptor because otherwise the BOF will not display this field.

View domain info

This query allows you to obtain the domain SID among other fields, which can be useful when e.g., crafting diamond tickets.

ldapsearch "(objectClass=domain)" --attributes *,ntsecuritydescriptor

Query single SID

ldapsearch "(objectSid=<SID>)" --attributes *,ntsecuritydescriptor

Query user or group

ldapsearch "(sAMAccountName=<user or group>)" --attributes *,ntsecuritydescriptor

OU Walking

Search for an Organizational Unit (OU) with a specific keyword in the name. After that, you can do as Dominic Chell likes to call OU walking:

“You can consider LDAP to take a hierarchical structure, much like a tree. This structure is composed of various entries, each representing objects like users, groups, or organisational units. Each entry is identified by a unique Distinguished Name (DN). The search base determines the starting point in the directory from where the search for entries begin. The search base, in combination with the search scope, determines which entries are considered during the search. The scope can be set to different levels such as:”

--scopeNameDescription
1BaseSearches only the entry defined as the search base
2One levelSearch all entries in the first level below the base-entry, excluding the base-entry.
3SubtreeSearches the base entry and all entries under it, traversing the entire subtree

“Essentially, OU walking involves slowly mapping out the organisational units in the directory. This hopefully provides sufficient information on identifying where the objects of interest might reside, assuming a tidily structured directory.”

In the following example, we search for all organizational units in OU “Domain Controllers”, but we do not traverse sub-OUs.

ldapsearch "(objectClass=organizationalunit)"
--dn "OU=Domain Controllers,DC=<domain>,DC=<local>"
--scope 1 --attributes *,ntsecuritydescriptor

Unrolling group membership

The query below finds all objects that are members, directly or indirectly of CN=<GroupName>,DC=<domain>,DC=<local>.

ldapsearch
"(memberOf:1.2.840.113556.1.4.1941:=CN=<GroupName>,DC=<domain>,DC=<local>)"
--attributes *,ntsecuritydescriptor

1.2.840.113556.1.4.1941 is a matching rule Object Identifier (OID), which in this case represents LDAP_MATCHING_RULE_IN_CHAIN. From Microsoft docs:

“This rule is limited to filters that apply to the DN. This is a special “extended” match operator that walks the chain of ancestry in objects all the way to the root until it finds a match.”

In other words, it traverses the entire chain of membership, essentially unrolling it.

Note: OID LDAP_MATCHING_RULE_IN_CHAIN is also known as LDAP_MATCHING_RULE_TRANSITIVE_EVAL, according to MS docs. Just Microsoft things I guess.

User Account Control

The userAccountControl is a bitmask that stores various properties and states of a user or computer account. Some examples:

PropertyHex valueDecimal valueDescription
NORMAL_ACCOUNT0x200512Regular user account
DONT_REQ_PREAUTH0x4000004194304ASREProastable
TRUSTED_FOR_DELEGATION0x80000524288Unconstrained delegation
TRUSTED_TO_AUTH_FOR_DELEGATION0x100000016777216Constrained delegation

All possible userAccountControl values can be found here.

Because userAccountControl is a bitmask, we need to apply specific matching rule OIDs, such as LDAP_MATCHING_RULE_BIT_AND aka 1.2.840.113556.1.4.803. This rule is equivalent to a bitwise AND operation.

For example, here we query for a regular user account that does not require preauthentication. The userAccountControl value 4194816 is the OR of 512 and 4194304.

ldapsearch "(userAccountControl:1.2.840.113556.1.4.803:=4194816)"
--attributes *,ntsecuritydescriptor

It is therefore logically equivalent to:

ldapsearch "(&(userAccountControl:1.2.840.113556.1.4.803:=512)
(userAccountControl:1.2.840.113556.1.4.803:=4194304))"
--attributes *,ntsecuritydescriptor

Note: in the ldapsearch BOF, you cannot query by hex value, just decimal.

SAM Account Type

It is also possible to search by account type. For example, a group object is 0x10000000, and a machine account is 0x30000001. Note that unlike userAccountControl, this is not a bitmask! All possible values can be found here.

For example, here we search for all machine accounts:

ldapsearch "(sAMAccountType=805306369)" --attributes *,ntsecuritydescriptor

Query LAPS information

This query searches for the ms-Mcs-AdmPwd attribute, which contains the plaintext LAPS password of computer objects.

ldapsearch "(name=ms-Mcs-AdmPwd)"

Please note that by default, only Domain Admins are able to read this property. For more techniques to find passwords, check Hope Walker’s post.

Practical example: Game of Active Directory (GOAD)

Now let’s put the above into practice. We will try to elevate our privileges in domain north.sevenkingkingdoms.local, part of Game of Active Directory (GOAD). We start as user robb.stark. Firstly, we query the Domain Admins group:

Again, make sure that ntsecuritydescriptor does not end with a space, otherwise this field will not be included in the output.

ldapsearch "(sAMAccountname=Domain Admins)" --attributes *,ntsecuritydescriptor
--snip--
objectClass: top, group
cn: Domain Admins
description: Designated administrators of the domain
member: CN=robb.stark,CN=Users,DC=north,DC=sevenkingdoms,DC=local, CN=eddard.stark,CN=Users,DC=north,DC=sevenkingdoms,DC=local, CN=Administrator,CN=Users,DC=north,DC=sevenkingdoms,DC=local
distinguishedName: CN=Domain Admins,CN=Users,DC=north,DC=sevenkingdoms,DC=local
instanceType: 4
whenCreated: 20250617175604.0Z
whenChanged: 20251003201639.0Z
--snip--

We use the BOFHound tool to convert this LDAP data into a BloodHound-compatible zip.

Havoc/data $ bofhound -i loot --parser Havoc --zip

 _____________________________ __    __    ______    __    __   __   __   _______
|   _   /  /  __   / |   ____/|  |  |  |  /  __  \  |  |  |  | |  \ |  | |       \
|  |_)  | |  |  |  | |  |__   |  |__|  | |  |  |  | |  |  |  | |   \|  | |  .--.  |
|   _  <  |  |  |  | |   __|  |   __   | |  |  |  | |  |  |  | |  . `  | |  |  |  |
|  |_)  | |  `--'  | |  |     |  |  |  | |  `--'  | |  `--'  | |  |\   | |  '--'  |
|______/   \______/  |__|     |__|  |___\_\________\_\________\|__| \___\|_________\

                            << @coffeegist | @Tw1sm >>

[17:57:44] INFO     Located 4 beacon log files
--snip--
[17:57:44] INFO     JSON files written to current directory
[17:57:44] INFO     Files compressed into bloodhound_20250926_175744.zip

Then, throw the zip in BloodHound.

We can see two SIDs that have WriteOwner privilege on the Domain Admins group. Let’s resolve them:

ldapsearch "(objectSid=S-1-5-32-544)" --attributes *,ntsecuritydescriptor
--snip--
objectClass: top, group
cn: Administrators
description: Administrators have complete and unrestricted access to the computer/domain
member: CN=robb.stark,CN=Users,DC=north,DC=sevenkingdoms,DC=local, CN=catelyn.stark,CN=Users,DC=north,DC=sevenkingdoms,DC=local, CN=eddard.stark,CN=Users,DC=north,DC=sevenkingdoms,DC=local, CN=Enterprise Admins,CN=Users,DC=sevenkingdoms,DC=local, CN=Domain Admins,CN=Users,DC=north,DC=sevenkingdoms,DC=local, CN=localuser,CN=Users,DC=north,DC=sevenkingdoms,DC=local, CN=Administrator,CN=Users,DC=north,DC=sevenkingdoms,DC=local
distinguishedName: CN=Administrators,CN=Builtin,DC=north,DC=sevenkingdoms,DC=local
instanceType: 4
whenCreated: 20250617175530.0Z
whenChanged: 20250617184809.0Z
--snip--

We were not able to resolve the other SID, as it is not part of our current domain. More on this later.

As seen in the image above and the LDAP output, we have discovered a way to escalate privileges: robb.stark is able to add itself to the Domain Admins group through the WriteOwner privilege.

OPSEC considerations

I really recommend checking out Manually Enumerating AD Attack Paths with BOFHound (YouTube) where it’s authors discuss the BOFHound tool, as well as how to perform LDAP reconnaissance with OPSEC in mind. These are the takeaways from the video:

  • Do not use too large queries, reduce results by setting scope (--scope), maximum number of results (--count) and search base (--dn).
  • Do not use too insufficient queries. For example, searching for sAMAccountName=*a requires much more server-side filtering than searching for sAMAccountName=a*.
  • Do not search for specific attributes which could be an IOC, for example, users with an SPN (Kerberoasting):
ldapsearch "(servicePrincipalName=*)"

Instead, get all search results and filter for such attributes offline.

Local group membership and sessions

Merely utilizing LDAP information in BloodHound does not allow you to populate edges such as HasSession, AdminTo, CanRDP, CanPSRemote, and ExecuteDCOM. To this end, one of the authors of BOFHound created/updated BOFs in the Situational Awareness BOF collection. They are explained in his blog post which I recommend checking out.

BONUS: reverse engineering SharpHound for more LDAP queries

I always wanted to reverse SharpHound to find out what queries it uses to build its data. Why not learn from the best, right? To this end, I added a print statement to SharpHound/src/Producers/LdapProducer.cs @ public override async Task Produce()

foreach (var filter in ldapData.Filter.GetFilterList()) {
    foreach (var partitionedFilter in GetPartitionedFilter(filter)) {
+       Console.WriteLine($"About to send LDAP query: Filter={partitionedFilter} Attributes={string.Join(",", ldapData.Attributes ?? Array.Empty<string>())} Domain={domain.Name} SearchBase={Context.SearchBase}");
        await foreach (var result in Context.LDAPUtils.PagedQuery(new LdapQueryParameters() {

And to public override async Task ProduceConfigNC():

foreach (var filter in configNcData.Filter.GetFilterList()) {
+   Console.WriteLine($"About to send LDAP query: Filter={filter} Attributes={string.Join(",", configNcData.Attributes ?? Array.Empty<string>())} Domain={domain.Name} SearchBase={path}");
    await foreach (var result in Context.LDAPUtils.PagedQuery(new LdapQueryParameters() {

Finally, I let it loose in my GOAD lab.

While most of the queries it employs found have been covered above, there are a couple interesting object class (filter objectClass=) queries:

For the following object classes, SharpHound explicitly sets the search base (--dn) to: CN=Configuration,DC=<domain>,DC=<local>

Note: the full SharpHound output can be found here.

But wait, there’s more!

I had a hunch that adding those two print statements was not enough to find all queries SharpHound makes. For example, how does it enumerate domain trusts?. To this end, we grep the SharpHoundCommon repo for trustedDomain:

LDAPFilter = new LdapFilter().AddFilter("(objectClass=trustedDomain)", true)

Bingo. Now that we see how LDAP searches are constructed, we can also grep for AddFilter and "(. This should give some pretty good coverage.

ldapsearch "(objectClass=trustedDomain)"
--snip--
distinguishedName: CN=sevenkingdoms.local,CN=System,DC=north,DC=sevenkingdoms,DC=local
--snip--

As we are currently in north.sevenkingdoms.local, we need to change our distinguished name to DC=sevenkingdoms,DC=local:

ldapsearch "(objectsid=S-1-5-21-2971658996-2234651943-1348665489-519)"
--dn "DC=sevenkingdoms,DC=local"
--snip--
sAMAccountName: Enterprise Admins
--snip--

You could also query across a trust using --hostname:

ldapsearch (objectClass=domain) --attributes *,ntsecuritydescriptor
--hostname dc.trusted.<domain>.<local> --dn "DC=trusted,DC=<domain>,DC=<local>"

Easter egg

While exploring AD LDAP attributes, I found drink: “The drink (Favorite Drink) attribute type specifies the favorite drink of an object (or person).”. Do with it what you will 😆

Sources

These are some sources I based this blog post on, I really recommend checking them out: