Active Directory Enumeration
This page is the unauthenticated half of AD enumeration: what the domain will tell you before you hold a credential, and how to turn that into a BloodHound graph.
Information
The techniques below differ far more in cost than in yield, and the choice between them is usually about what the client can afford to see rather than what you can afford to run. One table, because this is a set of options rather than a flow:
| Technique | Credential needed | What it proves | What it leaves behind |
|---|---|---|---|
| Root DSE bind | None, if anonymous is allowed | Base DN, naming contexts, functional level | One query; 2889 insecure-bind if LDAP is audited |
| kerbrute userenum | None | The name exists — read from the KDC error code | One 4768 per candidate; volume is the only signal |
| lookupsid / RID cycling | None, or any account | Account names plus group context from resolved SIDs | SAMR volume; no per-name failure event |
| SYSVOL GPP read | Any domain user | A cpassword that decrypts to cleartext |
5145 with the file name on the DC — the loudest step here |
| BloodHound DCOnly | Any account | Directory relationships: ACLs, groups, delegation | LDAP reads only, comparable to a directory sync |
| BloodHound All | Any account, admin on targets for sessions | The above plus logon-rights and session edges | Touches every host: 4624 and 5140 across the estate |
Domain Information
Start with the root DSE. An anonymous bind against port 389 returns
defaultNamingContext, namingContexts and
supportedLDAPVersion without any credential, which gives you the base DN for every later
query and reveals whether the forest has more than one naming context (a second domain, or a
configuration partition you did not expect). Two results are findings in themselves: an anonymous bind
that succeeds is a hardening gap, and domainFunctionality tells you the functional level,
which bounds the techniques that will work — a 2008 R2 level estate still permits things a 2016 level
one blocks.
If you are on a domain-joined host rather than a Linux box, %USERDOMAIN%,
%LOGONSERVER% and nltest /dclist answer the same questions with no LDAP
traffic at all, and nltest /domain_trusts gives you the trust list that would otherwise
need an authenticated query. Prefer those when the host is in scope and you want to stay quiet.
# Anonymous root DSE — base DN, naming contexts, functional level
ldapsearch -x -H ldap://DC_IP -b "" -s base defaultNamingContext namingContexts
# From a domain-joined host (no LDAP traffic)
echo %USERDOMAIN%
echo %LOGONSERVER%
nltest /dclist:%USERDOMAIN%
nltest /domain_trusts /all_trustsUser Enumeration
Username discovery without credentials comes down to asking the KDC a question it must answer
differently for a real account than for a fake one. kerbrute userenum sends one AS-REQ per
candidate name and reads the error code: KDC_ERR_C_PRINCIPAL_UNKNOWN means the name does
not exist, while KDC_ERR_PREAUTH_REQUIRED (or a successful AS-REP) means it does. That is
the quietest enumeration available — one packet per name, no failed logons, no lockout counter touched,
because no password is ever guessed. The cost is volume: a large wordlist against a busy DC is visible
as a burst of 4768 events, so pace it.
lookupsid.py is the fallback when Kerberos pre-auth is patched into silence or you want
group context as well: it walks RIDs over the SMB pipe and prints a name for every SID the DC resolves.
The output is worth reading as a hierarchy — RID 500 is the built-in Administrator, the 1000-range is
the original account block, and gaps suggest deleted or recreated accounts. Add the names you recover
to users.txt and keep the file: the AS-REP and Kerberoast steps, and the authenticated page,
all consume it.
# Kerberos user enumeration (no credentials, no lockout risk)
kerbrute userenum -d corp.local --dc DC_IP users.txt
# RID cycling over SMB (fallback, also works with a null session)
lookupsid.py corp.local/guest@TARGET 20000
# Comprehensive unauthenticated pass
enum4linux-ng -U TARGETGroup Enumeration
Without a credential you are limited to what SAMR and the anonymous RPC interfaces will hand over: the group list, and sometimes membership. What matters is whether a privileged group has a member you can reach. The RIDs are the reliable signal rather than the display names, because groups get renamed and localised — 512 is Domain Admins, 518 is Schema Admins, 519 is Enterprise Admins, 544 is the built-in local Administrators group. A group whose name looks innocuous but whose RID is 512 is still Domain Admins.
Nested membership is the part unauthenticated enumeration usually cannot resolve, and it is where the attack paths hide: a helpdesk user is rarely a direct Domain Admin, but they are often a member of a group that is. Once you have any account, resolve the nesting with the queries on LDAP & Active Directory Enumeration or with BloodHound, below, which does it for you.
# Group list over SAMR (null session or any credential)
enum4linux-ng -G TARGET
rpcclient -U '' -N TARGET -c 'enumdomgroups'
# Membership of a specific group
rpcclient -U '' -N TARGET -c 'querygroupmem 0x200'Computer Enumeration
Computer enumeration tells you what to attack next, and the useful fields are the operating system and its build number: the build is a patch-level proxy, so a cluster of machines still on an old build is a cluster that has missed several years of updates. Sort the list by build rather than by name and look for the outliers — the one Server 2008 file server in an otherwise 2022 estate is the finding. Unauthenticated, the sources are the DNS records from the discovery phase, the SMB sweep's OS field and the SRV records that identify DCs and global catalogues. Authenticated LDAP gives you the full list with the OS version, last logon and the delegation attributes, and that query set is on the LDAP page.
# OS and build from an unauthenticated SMB sweep
nxc smb 10.0.0.0/24
# Computers in DNS
nslookup -type=SRV _ldap._tcp.dc._msdcs.corp.localGPO Enumeration
There is exactly one unauthenticated GPO finding worth chasing: SYSVOL is readable by any
authenticated user, and on older estates it is readable more broadly still. The preferences files under
Policies can contain a cpassword — a value encrypted with a key Microsoft
published — which decrypts instantly with gpp-decrypt. Finding it needs no GPO parsing:
search SYSVOL for Groups.xml and look at what comes back.
Anything beyond that — which GPO applies to which computer, who can edit it, and whether a writable GPO gives you a path to a privileged group — needs an authenticated query, and LDAP & Active Directory Enumeration covers it. Treat the GPP password as the finding and the GPO inventory as the follow-up.
# Look for GPP cpassword in SYSVOL
smbclient //DC_IP/SYSVOL -U 'user%pass' -c 'recurse;ls' | grep -i groups.xml
# Decrypt once downloaded
gpp-decrypt "encrypted_password_here"Trust Enumeration
Be precise about what a trust gives you, because it is the most over-reported finding in AD work. The direction is stated from this domain's point of view: an outbound trust means this domain trusts the other one, so users from the other domain can authenticate here; an inbound trust means the other domain trusts this one, so your accounts can reach it; bidirectional is both. Only an inbound or bidirectional trust is a path outward, and only if the trust is not filtered — SID filtering (quarantine) blocks the classic extra-SID attack across a forest trust, and selective authentication blocks it entirely.
Unauthenticated, trust discovery is mostly inference: the domain names that appear in SMB sweep output,
the DNS suffixes in DHCP, and the _msdcs zone boundaries tell you a second domain exists.
The authoritative list — trust type, direction, transitivity and SID filtering state — comes from the
trustedDomain objects, which need a credential; the query is on the
LDAP page,
and the exploitation of an unfiltered trust is on
Trust Attacks.
BloodHound Collection
Tip
Danger
-c All authenticates to every host in the domain and cannot be limited to a subnet, so it
reaches excluded systems and end-user workstations by design. Agree the method in writing before the
run, prefer -c DCOnly on estates with a monitored SOC, and treat the collected JSON as a
copy of the client's directory — it contains staff names, group membership and ACLs, so it lives on
the engagement host and is destroyed on the agreed date.
BloodHound is the bridge between this phase and the authenticated one: it takes whatever context you
have and answers the only question that matters — what is the shortest path from where I am to Domain
Admin, and which edge do I have to abuse first. Choose the collection method by what you can afford to
generate. -c DCOnly reads only the directory and is the quietest useful collection;
-c All adds session, logged-on-user, share and registry collection, which is what produces
the logon-rights and session edges, and which is also the noisiest thing you can run against a large
estate. On Linux, bloodhound-python produces the same JSON from a single credential.
Read the graph in this order: shortest path from your owned principal to Domain Admins, then the
Kerberoastable and AS-REP-roastable users, then the computers with unconstrained delegation, then the
principals with GenericAll or WriteDacl on something interesting. Mark the
accounts you actually control as owned before you run the query, or the path it returns will start
from an account you do not have.
# SharpHound collection
# Full collection (noisy)
.\SharpHound.exe -c All
# Stealthier - skip session collection
.\SharpHound.exe -c DCOnly
# Session collection (requires admin on targets)
.\SharpHound.exe -c Session
# From Linux with bloodhound-python
bloodhound-python -u user -p password -d corp.local -dc DC01.corp.local -c All
# Import resulting JSON files into BloodHound
# Look for:
# - Shortest path to Domain Admin
# - Kerberoastable users
# - AS-REP roastable users
# - Unconstrained delegationImpacket Enumeration
Impacket's enumeration scripts are authenticated tools, so their coverage belongs on the
LDAP page
rather than here. The one you may need during this phase is GetNPUsers.py with
-no-pass and a user list: it asks for an AS-REP for each name and returns a crackable hash
for any account with pre-authentication disabled, which is a credential-free finding if the hashes are
in scope. GetADUsers.py, GetUserSPNs.py and ldapdomaindump all
require a valid account.
What the Defender Sees
The techniques on this page differ enormously in how much they leave behind, and that difference is worth stating in the report because it is the client's detection gap:
- Anonymous root DSE: an insecure bind event (2889) on the DC if plain LDAP is audited. One query, easy to miss, and the fix is to disable anonymous binds.
- kerbrute userenum: a burst of 4768 with
0x6(principal unknown) failures from one source. The volume is the signal — a handful of names looks like a mistyped logon, a thousand does not. - RID cycling: SAMR traffic over SMB, logged as anonymous or guest network logons (4624 type 3) plus RPC volume. No per-name failure event, which is why it survives most SIEM rules.
- SYSVOL GPP read: 5145 with the file name on the DC's file share, and a successful logon by the account that read it. This is the most detectable thing on the page and the one most worth checking for in the client's telemetry.
- BloodHound:
DCOnlyis LDAP reads only;Alladds session collection, which touches every host and produces 4624 and 5140 events across the estate. That is the collection method to agree in advance.
Quick Reference
| Target | Tool | Value |
|---|---|---|
| Base DN and functional level | ldapsearch rootDSE | Anonymous bind status, query base |
| Valid usernames | kerbrute | Account list without a lockout risk |
| Account RIDs | lookupsid.py | Names plus group context |
| GPP password | grep SYSVOL | Cleartext credential from Groups.xml |
| Attack paths | BloodHound | Shortest path from owned to DA |
Evidence Collection
Unauthenticated enumeration is judged on two things: what it proved and what it cost to prove. Capture both.
Anonymous-bind result:
The root DSE response, including whether the bind succeeded anonymously, plus domainFunctionality. An accepted anonymous bind is a finding on its own.
Validated account list: The names kerbrute or RID cycling confirmed, with the method per name. That file is the input to every later credential step, so keep it deduplicated and dated.
GPP password: The full SYSVOL path, the GPO that owns it, the decrypted value and whether the GPO is still linked. A password from an unlinked GPO is a historical artefact, not a live credential.
Privileged-group membership: Direct membership of the groups that matter, keyed by RID (512, 518, 519, 544) rather than display name, plus the note that nesting needs a credential to resolve.
BloodHound dataset: The collection method, the account used, the file set and the Cypher query behind each attack path you report. A path screenshot without the query is not reproducible.
False Positive Identification
-
A null session that lists default shares:
IPC$,NETLOGONandSYSVOLexist on every domain member. The finding is a non-default share you can read or a name that resolves, not the presence of the defaults. - A localised group name: Groups get renamed and translated. A friendly name with RID 512 is still Domain Admins, and a group called "Domain Admins" that does not carry 512 is not.
- A trust that cannot be crossed: An outbound trust lets the other domain authenticate here; it is not a path outward. Report direction and SID-filtering state, or the finding overstates what is reachable.
- BloodHound paths through objects you do not own: The shortest path query will happily start from an account you do not control unless you mark owned principals first. Check the path starts where you actually are.
Testing Checklist
This phase has no exploit, so success is measured by what the directory answered without a credential and what a BloodHound path proved.
Confirm what answers unauthenticated
- ☐ The anonymous LDAP bind either succeeds or returns a specific error; record which, with the DC that answered.
- ☐
kerbrute userenumreturns valid names from AS-REQ errors without a password attempt; count them and keep the file. - ☐ RID cycling (
nxc smb --rid-brute) resolves names for the ranges it may read and stops where it is refused. - ☐ SYSVOL is readable for the account in scope, and any
cpasswordfound is checked against its GPO's link state.
Confirm the reachable facts
- ☐ Privileged groups are keyed by RID (512, 518, 519, 544) so a renamed or localised group is still counted.
- ☐ Each trust is recorded with direction and SID-filtering state together; an outbound trust is not a path outward.
- ☐ BloodHound collection completes with the account and method named, and at least one path query returns a route to a privileged group.
- ☐ Every name in the validated account list names the method that confirmed it, so nothing rests on a guessed username format.
Remediation & Defense
Everything this phase recovers is a hardening gap rather than a vulnerability, which is why the fixes are configuration changes.
Close the credential-free paths
- • Disable anonymous LDAP binds and restrict anonymous SYSVOL access
- • Remove <code>cpassword</code> values from GPP preferences; Microsoft published the key in 2012, so any remaining value is a plaintext password
- • Block unauthenticated SAMR and RID enumeration where the estate permits it, and audit anonymous reads of SYSVOL
Reduce the directory surface
- • Tier privileged accounts so a standard user cannot read the group structure that matters
- • Audit who can read <code>AdminSDHolder</code>, delegation attributes and <code>msDS-KeyCredentialLink</code>
- • Retire legacy functional levels that permit techniques current hardening blocks
Detect the enumeration
- • Alert on bursts of 4768 with status 0x6 and on high-volume SAMR traffic from one source
- • Audit read access to SYSVOL preference files and to delegation attributes
- • Baseline LDAP query volume per account so a full-directory dump stands out