This is a directory-reading lab, not a BloodHound attack path. Target is a fake lab forest LAB.INTERNAL on an isolated VM DC (dc01.lab.internal). Goal: keep one redacted ldapsearch, one PowerView-shaped listing, and the event IDs those reads generate, so a defender can baseline the same queries. I do not follow an ACL edge to DA, I do not Kerberoast, I do not dump NTDS, I do not chain this into an attack playbook.

1Figure 1. Authenticated LDAP is a database read. The map is the product. Alert on who drew it.
2labuser -> ldapsearch (port 389/636) -> dc01.lab.internal
3events: 4624 (logon)  4662 (object access)  1644 (LDAP query, if enabled)

Lab layout

1labs/ad_recon_lab/          # isolated lab DC, not a real tenant
2  ldap-rootdse.txt
3  ldap-users.txt            # redacted
4  powerview-spn.txt         # redacted, fake names
5  4662.txt
6  1644.txt

Domain: LAB.INTERNAL. DC: dc01.lab.internal (Windows Server 2019, lab). Account: labuser (ordinary user, no extra rights). SIDs below have the unique infix replaced with [REDACTED]. No production names.

Artifact: ldapsearch against the fake DC

RootDSE first — this is what every join and every recon tool reads.

1$ ldapsearch -H ldap://dc01.lab.internal -x -s base -b '' \
2    namingContexts defaultNamingContext dnsHostName
3dn:
4defaultNamingContext: DC=lab,DC=internal
5namingContexts: DC=lab,DC=internal
6namingContexts: CN=Configuration,DC=lab,DC=internal
7namingContexts: CN=Schema,CN=Configuration,DC=lab,DC=internal
8dnsHostName: dc01.lab.internal

Authenticated user listing (password not in the note; bind as labuser). I ask for a short attribute set, not * with nTSecurityDescriptor.

 1$ ldapsearch -H ldaps://dc01.lab.internal \
 2    -D 'CN=labuser,CN=Users,DC=lab,DC=internal' -W \
 3    -b 'DC=lab,DC=internal' -s sub \
 4    '(&(objectCategory=person)(objectClass=user))' \
 5    sAMAccountName userPrincipalName userAccountControl servicePrincipalName memberOf
 6
 7dn: CN=labuser,CN=Users,DC=lab,DC=internal
 8sAMAccountName: labuser
 9userPrincipalName: labuser@lab.internal
10userAccountControl: 512
11memberOf: CN=Helpdesk,OU=T1,DC=lab,DC=internal
12
13dn: CN=alice,OU=T1,DC=lab,DC=internal
14sAMAccountName: alice
15userPrincipalName: alice@lab.internal
16userAccountControl: 512
17memberOf: CN=Helpdesk,OU=T1,DC=lab,DC=internal
18
19dn: CN=svc-web,OU=svc,DC=lab,DC=internal
20sAMAccountName: svc-web
21userPrincipalName: svc-web@lab.internal
22userAccountControl: 512
23servicePrincipalName: HTTP/web01.lab.internal
24servicePrincipalName: HTTP/web01
25
26dn: CN=svc-sql,OU=svc,DC=lab,DC=internal
27sAMAccountName: svc-sql
28userAccountControl: 66048
29servicePrincipalName: MSSQLSvc/sql01.lab.internal:1433
30servicePrincipalName: MSSQLSvc/sql01.lab.internal
31
32# 4 entries  (lab is tiny on purpose)

What this listing reveals, as inventory:

ObjectSignalWhy a defender cares
labuser / aliceUAC=512 (NORMAL_ACCOUNT)people
svc-webSPN HTTP/web01Kerberos service account; roastable class, not a how-to
svc-sqlUAC=66048 (NORMAL + DONT_EXPIRE) + MSSQL SPNlong-lived service
memberOf Helpdesknested T1role group, not DA

I do not then request a TGS for those SPNs. Seeing the SPN in LDAP is the recon; roasting is a different ticket.

Group nest (still LDAP, still fake):

1$ ldapsearch ... -b 'CN=Domain Admins,CN=Users,DC=lab,DC=internal' member
2dn: CN=Domain Admins,CN=Users,DC=lab,DC=internal
3member: CN=Administrator,CN=Users,DC=lab,DC=internal
4member: CN=lab-da,OU=T0,DC=lab,DC=internal
5# labuser is NOT here. nested Helpdesk is not DA. file that, do not hunt a nest to make it true.

Artifact: PowerView-style listing (same fake DC)

I keep a PowerShell shape because that is what IR pastes. This is Get-ADUser / Get-ADComputer on the lab; the column layout matches the old PowerView one-liners people grep for. Data is fake.

 1PS C:\lab> Get-ADUser -Filter * -Properties servicePrincipalName,userAccountControl |
 2            ? { $_.servicePrincipalName } |
 3            select SamAccountName, UserAccountControl, servicePrincipalName
 4
 5SamAccountName UserAccountControl servicePrincipalName
 6-------------- ------------------ --------------------
 7svc-web                      512  {HTTP/web01.lab.internal, HTTP/web01}
 8svc-sql                    66048  {MSSQLSvc/sql01.lab.internal:1433, MSSQLSvc/sql01.lab.internal}
 9
10PS C:\lab> Get-ADComputer -Filter * -Properties userAccountControl,servicePrincipalName |
11            select Name, IPv4Address, userAccountControl
12
13Name  IPv4Address     userAccountControl
14----  -----------     ------------------
15DC01  10.0.10.10                 532480
16WEB01 10.0.10.20                   4096
17SQL01 10.0.10.21                   4096
18# 532480 = SERVER_TRUST_ACCOUNT | TRUSTED_FOR_DELEGATION  (DC default — expected)
19# 4096   = WORKSTATION_TRUST_ACCOUNT
1PS C:\lab> Get-ADGroupMember 'Domain Admins' | select SamAccountName
2Administrator
3lab-da
4
5PS C:\lab> nltest /dclist:LAB
6Get list of DCs in domain 'LAB' from '\\dc01.lab.internal'.
7    dc01.lab.internal [PDC]  [DS] Site: Default-First-Site-Name
8The command completed successfully

DNS SRV, same map, still fake:

1$ nslookup -type=SRV _ldap._tcp.dc._msdcs.lab.internal
2_ldap._tcp.dc._msdcs.lab.internal  SRV  0 100 389  dc01.lab.internal

Computer UAC 532480 on DC01 is SERVER_TRUST_ACCOUNT | TRUSTED_FOR_DELEGATION. That combination is the default for domain controllers, not a leftover print server. I file it as expected-on-DC, unexpected-on-WEB01. The other note in this series covers the print leftover.

That is the whole recon product for this notebook: users, SPNs, computers, DA members, DC locator. No ACL bomb, no session hunter, no local-admin collector. A tool that also walks nTSecurityDescriptor on every object is the same LDAP class with a louder 4662 stream — I still do not run it here.

Event IDs on the DC (the defender half)

I turned on Directory Service Access auditing for the lab OU and LDAP-query diagnostics (1644) for one hour. Then I ran the ldapsearch above.

4624 — network logon from the workstation that bound:

 1Event 4624  An account was successfully logged on.
 2Subject:  SYSTEM
 3Logon Type: 3
 4Account Name:        labuser
 5Account Domain:      LAB
 6Logon ID:            0x[REDACTED]
 7Workstation Name:    LABPC01
 8Source Network Address: 10.0.10.50
 9Source Port:         [REDACTED]
10Authentication Package: Kerberos

4662 — object access on a user object (one of many; this is svc-web):

 1Event 4662  An operation was performed on an object.
 2Subject:
 3  Security ID:          S-1-5-21-[REDACTED]-1001
 4  Account Name:         labuser
 5  Account Domain:       LAB
 6  Logon ID:             0x[REDACTED]
 7Object:
 8  Object Server:        DS
 9  Object Type:          user
10  Object Name:          CN=svc-web,OU=svc,DC=lab,DC=internal
11  Handle ID:            0x0
12Access Request Information:
13  Access Mask:          0x10          # Read Property
14  Properties:
15    -- %%{servicePrincipalName}
16    -- %%{userAccountControl}
17    -- %%{memberOf}

A single 4662 is noise. Hundreds of 4662 from labuser on user objects, reading servicePrincipalName and nTSecurityDescriptor, from a workstation that is not an IdM sync engine, is the hunt.

1644 — Field Engineering LDAP query log (noisy; lab only):

1Event 1644  Internal event: ... LDAP search ...
2Starting node: DC=lab,DC=internal
3Filter: (&(objectCategory=person)(objectClass=user))
4Scope:  Subtree
5Attribute selection: sAMAccountName, userPrincipalName, userAccountControl,
6                     servicePrincipalName, memberOf
7Visited entries: 12
8Returned entries: 4
9Client: 10.0.10.50: [REDACTED]

1644 is how I prove the filter, not something I leave on in production all year. Production equivalent: ETW Microsoft-Windows-ActiveDirectory_DomainService with a tighter subscription, or a DC-adjacent network tap that already records LDAP.

4769 I do not expect from this lab step. A TGS for HTTP/web01 after the listing would be the next class. Absence of 4769 after a bulk 4662 is still recon.

Hunt questions I actually file:

  1. Which non-server principals read servicePrincipalName / msDS-AllowedToDelegateTo / nTSecurityDescriptor this week?
  2. Did those hosts also 4624 from a new workstation the same day?
  3. Did 4769 for those SPNs follow from the same Logon ID? (If yes, that is a different ticket — still not a roast walkthrough.)

Mitigation (shrink the map, notice the reader)

  • Tiering: DA never logs on to LABPC01. Helpdesk is T1, not T0.
  • SPNs on user accounts: inventory (svc-web, svc-sql above). Prefer gMSA. I do not “test roastability”.
  • Pre-auth stays on (UAC bit 0x400000 absent in the dump — good).
  • ACL review is a scheduled export, not a surprise from a red team LDAP scrape.
  • Detect: baseline LDAP volume per account. Alert on bulk 4662 from interactive users. Do not alert on the DC’s own SAMR/LDAP.
1# defender grep of the lab exports
2findstr /i "servicePrincipalName nTSecurityDescriptor msDS-AllowedToDelegateTo" 4662.txt

What I file after this lab

  • Forest: LAB.INTERNAL / dc01.lab.internal (fake, isolated)
  • ldapsearch 4 users; SPNs on svc-web and svc-sql only
  • PowerView-style: same two SPN accounts; DC01 UAC 532480; DA = Administrator, lab-da
  • Events: 4624 type 3 from 10.0.10.50; 4662 Read Property on svc-web; 1644 filter objectClass=user returned 4
  • No 4769 in this step
  • Out of scope: Kerberoast, ACL-to-DA path, BloodHound collect, NTDS dump

Commands appendix

1ldapsearch -H ldap://dc01.lab.internal -x -s base -b '' defaultNamingContext
2ldapsearch -H ldaps://dc01.lab.internal -D 'CN=labuser,CN=Users,DC=lab,DC=internal' -W \
3  -b 'DC=lab,DC=internal' '(&(objectCategory=person)(objectClass=user))' \
4  sAMAccountName servicePrincipalName userAccountControl
5# Windows
6# Get-ADUser -Filter * -Properties servicePrincipalName
7# wevtutil qe Security /q:"*[System[(EventID=4662)]]" /c:5 /f:text