Chapter 104

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

Scenario

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

Active Directory LDAP hierarchy: Root DSE (null DN, accessible anonymously): 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 Domain partition (DC=corp,DC=local): CN=Users,DC=corp,DC=local ← users and built-in groups CN=Computers,DC=corp,DC=local ← default computer objects CN=Domain Controllers,DC=corp,DC=local ← DC computer objects OU=Employees,DC=corp,DC=local ← custom OUs (org-specific) OU=Servers,DC=corp,DC=local CN=Builtin,DC=corp,DC=local ← built-in groups (Administrators, etc.) Configuration partition: CN=Sites,CN=Configuration,... ← AD site topology CN=Partitions,... ← trust and domain list Key object classes: user → user accounts (and service accounts) computer → machine accounts (end in $) group → security and distribution groups organizationalUnit → OU containers groupPolicyContainer → GPO objects trustedDomain → domain trust relationships

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

GoalLDAP FilterWhy
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

SignalSourceNotes
High volume of LDAP queries from single source (Event 1644 on DC)DC Directory Service log1644 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 workstationNetwork monitoring / ETWFull domain enum generates megabytes of LDAP responses in seconds
BloodHound-characteristic query patterns (specific attribute sets + timing)DC Directory Service logTools like BHunt-Sensor and Microsoft Defender for Identity detect BloodHound patterns
BloodHound vs Manual LDAP

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.