LDAP Reconnaissance
Comprehensive Active Directory enumeration via LDAP: user/group/computer/ACL/GPO/trust queries that map privilege escalation paths from any domain user account
You've compromised a standard domain user account via phishing. Now you need to understand the domain: who are the Domain Admins? Which service accounts have SPNs? Which users have passwords that never expire? Which computers are domain controllers or have interesting roles? Are there any ACL misconfigurations where low-privilege users have write access to privileged accounts? LDAP answers all of these questions, and any domain user can make these queries — no elevated rights needed. This chapter is the foundation for everything: Kerberoasting lists come from LDAP, BloodHound data comes from LDAP, DCSync targets come from LDAP.
AD LDAP Structure — What You're Searching
LDAP Connection and Authentication
#include <windows.h>
#include <winldap.h>
#include <stdio.h>
#pragma comment(lib, "wldap32.lib")
// Global reusable LDAP context
typedef struct _LDAP_CTX {
LDAP *ld;
WCHAR baseDN[512];
WCHAR configDN[512];
WCHAR dcName[256];
} LDAP_CTX;
BOOL LdapConnect(LDAP_CTX *ctx) {
// NULL = auto-locate DC via DNS SRV records (_ldap._tcp.dc._msdcs.DOMAIN)
ctx->ld = ldap_init(NULL, LDAP_PORT);
if (!ctx->ld) { printf("[-] ldap_init failed\n"); return FALSE; }
ULONG ver = LDAP_VERSION3;
ldap_set_option(ctx->ld, LDAP_OPT_PROTOCOL_VERSION, &ver);
// LDAP_AUTH_NEGOTIATE = Kerberos/NTLM auth as current user
// No credentials needed — uses Windows SSO token
ULONG rc = ldap_bind_s(ctx->ld, NULL, NULL, LDAP_AUTH_NEGOTIATE);
if (rc != LDAP_SUCCESS) {
printf("[-] ldap_bind_s failed: %lu\n", rc);
ldap_unbind(ctx->ld);
return FALSE;
}
// Get naming contexts from rootDSE
WCHAR *rootAttrs[] = { L"defaultNamingContext",
L"configurationNamingContext",
L"dnsHostName", NULL };
LDAPMessage *res = NULL;
ldap_search_s(ctx->ld, NULL, LDAP_SCOPE_BASE, L"(objectClass=*)", rootAttrs, 0, &res);
LDAPMessage *e = ldap_first_entry(ctx->ld, res);
WCHAR **v;
if ((v = ldap_get_values(ctx->ld, e, L"defaultNamingContext"))) {
wcscpy_s(ctx->baseDN, 512, v[0]); ldap_value_free(v);
}
if ((v = ldap_get_values(ctx->ld, e, L"configurationNamingContext"))) {
wcscpy_s(ctx->configDN, 512, v[0]); ldap_value_free(v);
}
if ((v = ldap_get_values(ctx->ld, e, L"dnsHostName"))) {
wcscpy_s(ctx->dcName, 256, v[0]); ldap_value_free(v);
}
ldap_msgfree(res);
wprintf(L"[+] Connected to DC: %s Base: %s\n", ctx->dcName, ctx->baseDN);
return TRUE;
}
User Enumeration — Finding High-Value Targets
void EnumUsers(LDAP_CTX *ctx) {
WCHAR *attrs[] = {
L"sAMAccountName", L"displayName", L"userAccountControl",
L"memberOf", L"adminCount", L"servicePrincipalName",
L"pwdLastSet", L"lastLogon", L"description",
L"mail", L"distinguishedName", NULL
};
// All enabled user accounts
LDAPMessage *res = NULL;
ldap_search_s(ctx->ld, ctx->baseDN, LDAP_SCOPE_SUBTREE,
L"(&(objectClass=user)(objectCategory=person)(!userAccountControl:1.2.840.113556.1.4.803:=2))",
attrs, 0, &res);
DWORD total = (DWORD)ldap_count_entries(ctx->ld, res);
printf("[*] Enabled user accounts: %lu\n", total);
LDAPMessage *e = ldap_first_entry(ctx->ld, res);
while (e) {
WCHAR **sam = ldap_get_values(ctx->ld, e, L"sAMAccountName");
WCHAR **uac = ldap_get_values(ctx->ld, e, L"userAccountControl");
WCHAR **ac = ldap_get_values(ctx->ld, e, L"adminCount");
WCHAR **spns = ldap_get_values(ctx->ld, e, L"servicePrincipalName");
WCHAR **desc = ldap_get_values(ctx->ld, e, L"description");
WCHAR **pwd = ldap_get_values(ctx->ld, e, L"pwdLastSet");
if (sam) {
DWORD uacVal = uac ? (DWORD)_wtol(uac[0]) : 0;
BOOL isAdmin = (ac && _wtol(ac[0]) == 1);
BOOL hasSPN = (spns != NULL);
BOOL noPwdExp = (uacVal & 0x10000); // DONT_EXPIRE_PASSWORD
BOOL noPwdReq = (uacVal & 0x20); // PASSWD_NOTREQD
BOOL noPreAuth = (uacVal & 0x400000); // DONT_REQ_PREAUTH
wprintf(L" %-30s", sam[0]);
if (isAdmin) printf(" [ADMIN]");
if (hasSPN) printf(" [SPN]");
if (noPwdExp) printf(" [NOPWDEXP]");
if (noPreAuth) printf(" [ASREP_VULNERABLE]");
if (desc) wprintf(L" desc='%.*s'", 30, desc[0]);
printf("\n");
}
if (sam) ldap_value_free(sam); if (uac) ldap_value_free(uac);
if (ac) ldap_value_free(ac); if (spns) ldap_value_free(spns);
if (desc) ldap_value_free(desc); if (pwd) ldap_value_free(pwd);
e = ldap_next_entry(ctx->ld, e);
}
ldap_msgfree(res);
}
Group Enumeration — Domain Admins and Privileged Groups
void EnumPrivilegedGroups(LDAP_CTX *ctx) {
// Query for high-privilege built-in and custom groups
const WCHAR *importantGroups[] = {
L"Domain Admins", L"Enterprise Admins", L"Schema Admins",
L"Administrators", L"Account Operators", L"Backup Operators",
L"Group Policy Creator Owners", L"Remote Management Users",
L"DNS Admins", L"Exchange Windows Permissions", NULL
};
for (int i = 0; importantGroups[i]; i++) {
// LDAP_MATCHING_RULE_IN_CHAIN (1.2.840.113556.1.4.1941):
// Recursively enumerate nested group membership
WCHAR filter[512];
_snwprintf_s(filter, 512, _TRUNCATE,
L"(&(objectClass=user)(memberOf:1.2.840.113556.1.4.1941:=CN=%s,CN=Users,%s))",
importantGroups[i], ctx->baseDN);
WCHAR *attrs[] = { L"sAMAccountName", L"userAccountControl", NULL };
LDAPMessage *res = NULL;
ULONG rc = ldap_search_s(ctx->ld, ctx->baseDN, LDAP_SCOPE_SUBTREE,
filter, attrs, 0, &res);
if (rc == LDAP_SUCCESS) {
DWORD count = (DWORD)ldap_count_entries(ctx->ld, res);
wprintf(L"\n[*] %s (%lu members):\n", importantGroups[i], count);
LDAPMessage *e = ldap_first_entry(ctx->ld, res);
while (e) {
WCHAR **sam = ldap_get_values(ctx->ld, e, L"sAMAccountName");
if (sam) { wprintf(L" %s\n", sam[0]); ldap_value_free(sam); }
e = ldap_next_entry(ctx->ld, e);
}
}
ldap_msgfree(res);
}
}
Computer Object Enumeration
void EnumComputers(LDAP_CTX *ctx) {
WCHAR *attrs[] = {
L"name", L"dNSHostName", L"operatingSystem",
L"operatingSystemVersion", L"distinguishedName",
L"userAccountControl", L"lastLogon",
L"servicePrincipalName", NULL
};
LDAPMessage *res = NULL;
ldap_search_s(ctx->ld, ctx->baseDN, LDAP_SCOPE_SUBTREE,
L"(&(objectClass=computer)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))",
attrs, 0, &res);
DWORD total = (DWORD)ldap_count_entries(ctx->ld, res);
printf("[*] Enabled computer objects: %lu\n", total);
LDAPMessage *e = ldap_first_entry(ctx->ld, res);
while (e) {
WCHAR **name = ldap_get_values(ctx->ld, e, L"name");
WCHAR **dns = ldap_get_values(ctx->ld, e, L"dNSHostName");
WCHAR **os = ldap_get_values(ctx->ld, e, L"operatingSystem");
WCHAR **osv = ldap_get_values(ctx->ld, e, L"operatingSystemVersion");
WCHAR **spn = ldap_get_values(ctx->ld, e, L"servicePrincipalName");
WCHAR **uac = ldap_get_values(ctx->ld, e, L"userAccountControl");
if (name) {
DWORD uacVal = uac ? (DWORD)_wtol(uac[0]) : 0;
BOOL isDC = (uacVal & 0x2000); // SERVER_TRUST_ACCOUNT = DC
wprintf(L" %-20s %-50s %s %s%s\n",
name[0],
dns ? dns[0] : L"(no DNS)",
os ? os[0] : L"(unknown OS)",
osv ? osv[0] : L"",
isDC ? L" [DC]" : L"");
}
if (name) ldap_value_free(name); if (dns) ldap_value_free(dns);
if (os) ldap_value_free(os); if (osv) ldap_value_free(osv);
if (spn) ldap_value_free(spn); if (uac) ldap_value_free(uac);
e = ldap_next_entry(ctx->ld, e);
}
ldap_msgfree(res);
}
ACL Enumeration — Finding Privilege Escalation Paths
ACL enumeration is the highest-value LDAP activity. The nTSecurityDescriptor attribute of every AD object contains an ACL listing who has what rights. Finding a user with GenericWrite, WriteDACL, or WriteOwner on a privileged object (Domain Admins, a DC computer object) is an instant privilege escalation path:
void ReadObjectACL(LDAP_CTX *ctx, const WCHAR *objectDN) {
// Request the security descriptor — requires SD_CONTROL flag in LDAP control
WCHAR *attrs[] = { L"nTSecurityDescriptor", NULL };
// Set SD_FLAGS control to request DACL (0x04) + owner (0x01)
// BerVal for SD_FLAGS control: 0x30 0x03 0x02 0x01 0x05 (DACL|OWNER|GROUP)
BYTE sdCtrlVal[] = { 0x30, 0x03, 0x02, 0x01, 0x07 };
LDAPControlW sdControl = {
L"1.2.840.113556.1.4.801", // SD_FLAGS OID
{ 5, (char*)sdCtrlVal },
TRUE
};
PLDAPControlW ctrlArr[] = { &sdControl, NULL };
LDAPMessage *res = NULL;
ldap_search_ext_s(ctx->ld, (PWCHAR)objectDN, LDAP_SCOPE_BASE,
L"(objectClass=*)", attrs, 0,
ctrlArr, NULL, NULL, 0, &res);
LDAPMessage *e = ldap_first_entry(ctx->ld, res);
if (!e) { ldap_msgfree(res); return; }
struct berval **sdVals = ldap_get_values_len(ctx->ld, e, L"nTSecurityDescriptor");
if (sdVals && sdVals[0]) {
SECURITY_DESCRIPTOR *sd = (SECURITY_DESCRIPTOR*)sdVals[0]->bv_val;
DWORD sdLen = (DWORD)sdVals[0]->bv_len;
wprintf(L"[+] ACL for %s (%lu bytes)\n", objectDN, sdLen);
// Parse DACL to find interesting ACEs
// Use Windows API: GetSecurityDescriptorDacl + GetAce
BOOL daclPresent, daclDefault;
PACL dacl = NULL;
if (GetSecurityDescriptorDacl(sd, &daclPresent, &dacl, &daclDefault) && dacl) {
ACL_SIZE_INFORMATION aclInfo;
GetAclInformation(dacl, &aclInfo, sizeof(aclInfo), AclSizeInformation);
for (DWORD i = 0; i < aclInfo.AceCount; i++) {
ACCESS_ALLOWED_ACE *ace;
if (!GetAce(dacl, i, (LPVOID*)&ace)) continue;
// Interesting rights for AD privilege escalation:
// 0x000F01FF = GenericAll
// 0x00000028 = WriteProperty (can change attributes)
// 0x00040000 = WriteDACL
// 0x00080000 = WriteOwner
// 0x00000100 = ListChildren (enumeration)
DWORD mask = ace->Mask;
BOOL interesting =
(mask & 0x000F01FF) || // GenericAll
(mask & 0x00040000) || // WriteDACL
(mask & 0x00080000); // WriteOwner
if (interesting) {
WCHAR *sidStr = NULL;
ConvertSidToStringSidW(&ace->SidStart, &sidStr);
printf(" [!] Interesting ACE: mask=0x%08X SID=%S\n",
mask, sidStr ? sidStr : L"?");
if (sidStr) LocalFree(sidStr);
}
}
}
ldap_value_free_len(sdVals);
}
ldap_msgfree(res);
}
Domain Trust Enumeration
void EnumTrusts(LDAP_CTX *ctx) {
WCHAR *attrs[] = {
L"name", L"trustType", L"trustDirection",
L"trustAttributes", L"flatName", NULL
};
// Trusts are in the System container of the domain partition
WCHAR sysDN[512];
_snwprintf_s(sysDN, 512, _TRUNCATE, L"CN=System,%s", ctx->baseDN);
LDAPMessage *res = NULL;
ldap_search_s(ctx->ld, sysDN, LDAP_SCOPE_ONELEVEL,
L"(objectClass=trustedDomain)", attrs, 0, &res);
DWORD count = (DWORD)ldap_count_entries(ctx->ld, res);
printf("[*] Domain trusts: %lu\n", count);
LDAPMessage *e = ldap_first_entry(ctx->ld, res);
while (e) {
WCHAR **name = ldap_get_values(ctx->ld, e, L"name");
WCHAR **td = ldap_get_values(ctx->ld, e, L"trustDirection");
WCHAR **tt = ldap_get_values(ctx->ld, e, L"trustType");
WCHAR **ta = ldap_get_values(ctx->ld, e, L"trustAttributes");
if (name) {
// trustDirection: 1=outbound, 2=inbound, 3=bidirectional
// trustAttributes: 0x8=transitive, 0x20=cross-forest, 0x40=SID filtering
DWORD dir = td ? (DWORD)_wtol(td[0]) : 0;
DWORD attrs = ta ? (DWORD)_wtol(ta[0]) : 0;
const char *dirStr = (dir==3) ? "bidirectional" :
(dir==2) ? "inbound (they trust us)" :
(dir==1) ? "outbound (we trust them)" : "none";
wprintf(L" [Trust] %s dir=%hs %s%s\n",
name[0], dirStr,
(attrs & 0x20) ? L"[CrossForest]" : L"",
(attrs & 0x40) ? L"[SIDFiltering]" : L"[NO_SID_FILTER]");
}
if (name) ldap_value_free(name); if (td) ldap_value_free(td);
if (tt) ldap_value_free(tt); if (ta) ldap_value_free(ta);
e = ldap_next_entry(ctx->ld, e);
}
ldap_msgfree(res);
}
High-Value Query Cookbook
| Goal | LDAP Filter | Why |
|---|---|---|
| Kerberoastable accounts | (&(objectClass=user)(servicePrincipalName=*)(!samAccountType=805306370)) | All service accounts with SPNs (excluding machine accounts) |
| AS-REP roastable | (&(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=4194304)) | Pre-auth disabled (bit 22 of UAC) |
| Protected admin accounts | (&(objectClass=user)(adminCount=1)) | AdminSDHolder protected — high privilege targets |
| Passwords never expire | (&(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=65536)) | Bit 16 of UAC = password never expires |
| Domain controllers | (&(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192)) | SERVER_TRUST_ACCOUNT bit = DC |
| Unconstrained delegation | (&(|(objectClass=computer)(objectClass=user))(userAccountControl:1.2.840.113556.1.4.803:=524288)) | TRUSTED_FOR_DELEGATION — can impersonate any user to any service |
| Constrained delegation (RBCD/KCD) | (&(objectClass=computer)(msDS-AllowedToDelegateTo=*)) | Has a delegation list — potential for constrained delegation abuse |
| Accounts with description field | (&(objectClass=user)(description=*)(adminCount=1)) | Admins who left passwords in description fields (surprisingly common) |
| Stale accounts (last logon > 90 days ago) | (&(objectClass=user)(lastLogon<=133000000000000000)) | Abandoned accounts often have weak/old passwords |
Detection
| Signal | Source | Notes |
|---|---|---|
| High volume of LDAP queries from single source (Event 1644 on DC) | DC Directory Service log | 1644 requires enabling Expensive/Inefficient LDAP query logging |
| LDAP query for nTSecurityDescriptor attribute (Event 4662) | DC Security Log (4662) | Reading security descriptors — rare for non-management tools; BloodHound signature |
| Large LDAP response volumes from non-admin workstation | Network monitoring / ETW | Full domain enum generates megabytes of LDAP responses in seconds |
| BloodHound-characteristic query patterns (specific attribute sets + timing) | DC Directory Service log | Tools like BHunt-Sensor and Microsoft Defender for Identity detect BloodHound patterns |
BloodHound (and SharpHound) automate everything in this chapter. SharpHound's JSON output feeds the BloodHound GUI, which builds attack path graphs showing the shortest path from compromised user to Domain Admin. Understanding the underlying LDAP queries (this chapter) lets you: write custom collectors that blend in better, understand what BloodHound finds and why, and write LDAP-based detections. The underlying data is the same — BloodHound just automates the queries and visualizes the paths. In a real engagement, run SharpHound; understand it via this chapter.
Q&A
Why can any domain user enumerate the entire AD structure? Shouldn't this require admin rights?
By design, Active Directory grants every domain user read access to most of the directory by default. This is intentional: users need to look up colleagues' contact information, see group memberships, find printers, and locate computers. The defaultSecurityDescriptor on most AD object classes grants Authenticated Users read access to most attributes. This is codified in the built-in ACEs on the domain naming context. Microsoft's position is that AD is an organizational directory (like a phone book) and its contents are inherently semi-public within the organization. The consequence for attackers is that every compromised account — even the most low-privilege temp contractor account — can enumerate the entire user list, group memberships, computer inventory, SPNs, trust relationships, and ACL structures. There is no "non-admin can't enumerate AD" setting. The only way to restrict enumeration is through granular DACL modifications on specific objects or attributes — an approach that breaks many AD-integrated applications and is rarely implemented. This is why LDAP reconnaissance requires zero privilege escalation.
What is the LDAP_MATCHING_RULE_IN_CHAIN OID and why is it important?
The LDAP matching rule OID 1.2.840.113556.1.4.1941 (also called LDAP_MATCHING_RULE_IN_CHAIN) is a Microsoft extension that searches for membership transitively through nested groups. Without it, a query like (memberOf=CN=Domain Admins,...) only finds direct members — users directly in the group. But Domain Admins often contain nested groups (e.g., Domain Admins contains "IT Admins" which contains "John"). Without recursive lookup, you'd miss John. With the IN_CHAIN rule: (memberOf:1.2.840.113556.1.4.1941:=CN=Domain Admins,...) — this finds all users who are members at any depth of the nested group hierarchy. This is critical for complete privilege mapping. BloodHound uses this rule extensively. The performance cost on the DC is significant for deeply nested or large groups, which is also why it's one of the expensive query patterns that Event 1644 tracks — it's a signal in your detection logic for detecting tools like BloodHound.