Detecting Active Directory Discovery (T1087, T1482, T1069): Hunting BloodHound and SharpHound with KQL and SPL
Almost every hands-on-keyboard intrusion into a Windows estate passes through the same narrow gate: the attacker has a foothold, they have a set of credentials, and they need to know what those credentials are worth. That question is answered by Active Directory discovery — enumerating users, groups, computers, trusts, GPOs, and session data — and in 2026 it is answered overwhelmingly by BloodHound and its collectors (SharpHound, the BloodHound CE collector, and the various Python/Rust reimplementations).
This is one of the highest-leverage detection points in the whole kill chain. Discovery happens before Kerberoasting, before lateral movement, before the domain admin ticket. Catching it buys you hours instead of minutes. It is also chronically under-detected, because most teams write one brittle rule matching the string SharpHound.exe and call the technique covered.
This post walks through four layers of detection for AD discovery, from cheapest-and-most-evadable to slowest-and-most-durable, with working KQL for Microsoft Sentinel / Defender XDR and SPL for Splunk.
What the collector actually does
Before writing rules, know the behaviour you are modelling. A default SharpHound run with --collectionmethod All does roughly four things:
- Bulk LDAP queries against a domain controller on 389/636/3268/3269, pulling users, groups, computers, OUs, GPOs, trusts, and
nTSecurityDescriptorACL data. Maps to T1087.002, T1069.002, T1482, and T1615. - SMB fan-out to every computer object it found, binding to named pipes over
IPC$—srvsvcfor session enumeration,samr/lsarpcfor local group membership,winregfor logged-on users. Maps to T1018 and T1049. - SYSVOL reads for GPO and GPP content.
- A ZIP/JSON artifact written to disk, then staged for exfil.
Steps one and two are the durable signal. The binary name, the file name, and the parent process are all trivially changed; a domain-wide SMB sweep from a single workstation is not.
Layer 1: collector command lines (cheap, brittle, still worth it)
Renaming the binary is easy; rewriting every command-line flag is less common, especially for commodity operators and red teams running stock tooling. Match on argument patterns rather than image names.
DeviceProcessEvents
| where Timestamp > ago(7d)
| where ProcessCommandLine has_any (
'--collectionmethod', '-CollectionMethod', 'Invoke-BloodHound',
'SharpHound', '--zipfilename', '--outputprefix', 'GPOLocalGroup',
'DCOnly', '--ldapusername', '--ldappassword')
| project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine,
InitiatingProcessFileName, InitiatingProcessCommandLine
| order by Timestamp descThe Splunk equivalent, against Sysmon Event ID 1:
index=windows sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" EventCode=1
(CommandLine="*--collectionmethod*" OR CommandLine="*Invoke-BloodHound*"
OR CommandLine="*SharpHound*" OR CommandLine="*--zipfilename*"
OR CommandLine="*--outputprefix*" OR CommandLine="*GPOLocalGroup*")
| table _time host user Image CommandLine ParentImage OriginalFileName
| sort - _timeKeep OriginalFileName in the output — Sysmon reads it from the PE version resource, and a renamed SharpHound.exe frequently still carries its original name there. Treat any hit as high severity and low volume; this rule should fire almost never in a healthy environment.
Layer 2: native recon tooling in bursts
Operators who avoid BloodHound still have to enumerate, and they do it with what is already on the box. A single net group /domain is noise. Five different discovery binaries from one account inside fifteen minutes is a session.
let reconTools = dynamic(['net.exe','net1.exe','nltest.exe','dsquery.exe',
'dsget.exe','setspn.exe','adfind.exe','quser.exe']);
DeviceProcessEvents
| where Timestamp > ago(1d)
| where FileName in~ (reconTools)
| where ProcessCommandLine has_any ('group /domain','user /domain','/domain_trusts',
'/dclist','trustdmp','domain admins','enterprise admins','-spn','objectcategory')
| summarize ToolCount = dcount(FileName),
Tools = make_set(FileName, 10),
Commands = make_set(ProcessCommandLine, 25),
FirstSeen = min(Timestamp), LastSeen = max(Timestamp)
by DeviceName, AccountName, Window = bin(Timestamp, 15m)
| where ToolCount >= 3
| order by ToolCount descIn Splunk, drive this off the Endpoint data model so it stays fast over long windows:
| tstats summariesonly=t count
values(Processes.process) as commands
values(Processes.process_name) as tools
dc(Processes.process_name) as tool_count
from datamodel=Endpoint.Processes
where Processes.process_name IN ("net.exe","net1.exe","nltest.exe","dsquery.exe",
"adfind.exe","setspn.exe","quser.exe")
by _time span=15m Processes.dest Processes.user
| `drop_dm_object_name(Processes)`
| where tool_count >= 3
| sort - tool_countTuning note: server administrators and helpdesk staff will trip this legitimately. Do not suppress by account name alone — suppress by the pair of (account, device) that you have baselined, and alert loudly when a known admin account runs the same burst from a workstation it has never touched before. A setspn -q hit inside the burst is a direct precursor to T1558.003 and should escalate on its own.
Layer 3: LDAP query fingerprints
This is where the collector cannot hide. If you run Microsoft Defender for Identity, the IdentityQueryEvents table gives you domain controller LDAP telemetry directly:
IdentityQueryEvents
| where Timestamp > ago(1d)
| where ActionType == 'LDAP query'
| where Query has_any ('(objectClass=user)','(objectCategory=computer)',
'(objectClass=trustedDomain)','servicePrincipalName=*',
'(objectClass=group)','nTSecurityDescriptor')
| summarize QueryCount = count(),
DistinctFilters = dcount(Query),
Filters = make_set(Query, 15)
by AccountUpn, DeviceName, Window = bin(Timestamp, 10m)
| where QueryCount > 50 and DistinctFilters >= 4
| order by QueryCount descWithout MDI, you can get comparable visibility from the domain controller itself by enabling expensive/inefficient LDAP search logging: set HKLM\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics\15 Field Engineering to 5 and set a low Search Time Threshold (msecs). Event ID 1644 then records the search filter and the client. Parse it in Splunk:
index=wineventlog source="WinEventLog:Directory Service" EventCode=1644
| rex field=Message "Search Filter:\s+(?<search_filter>.+)"
| rex field=Message "Client:\s+(?<client_ip>\d+\.\d+\.\d+\.\d+)"
| bin _time span=10m
| stats count as queries dc(search_filter) as distinct_filters
values(search_filter) as filters by _time, client_ip
| where queries > 100 AND distinct_filters >= 5
| sort - queriesBe deliberate about this one: 1644 logging is verbose and can generate real volume on a busy DC. Enable it on a subset of DCs first, measure the ingest, and set the time threshold high enough that you are not indexing every routine lookup. The payoff is that ACL collection — the nTSecurityDescriptor reads that make BloodHound's attack paths possible — has essentially no other detection surface.
Layer 4: the IPC$ fan-out (highest fidelity)
If you take one rule from this post, take this one. Session and local-group collection requires the collector to bind to named pipes on every computer object in the domain. Object access auditing on file shares (Event ID 5145) captures it, and the resulting pattern — one source account touching srvsvc on hundreds of hosts inside a few minutes — has almost no legitimate analogue outside of a handful of management tools.
SecurityEvent
| where TimeGenerated > ago(1d)
| where EventID == 5145
| where ShareName has 'IPC$'
| where RelativeTargetName in~ ('srvsvc','wkssvc','samr','winreg','lsarpc')
| summarize TargetHosts = dcount(Computer),
Hosts = make_set(Computer, 25),
Pipes = make_set(RelativeTargetName, 6),
Hits = count()
by Account = SubjectUserName, SourceIP = IpAddress, Window = bin(TimeGenerated, 10m)
| where TargetHosts >= 20
| order by TargetHosts descindex=wineventlog EventCode=5145 Share_Name="*IPC$*"
Relative_Target_Name IN ("srvsvc","wkssvc","samr","winreg","lsarpc")
| bin _time span=10m
| stats dc(host) as target_hosts values(Relative_Target_Name) as pipes count as hits
by _time, Account_Name, Source_Address
| where target_hosts >= 20
| sort - target_hostsTwo prerequisites: Audit Detailed File Share must be enabled (it is off by default and it is chatty, so scope it to member servers and workstations rather than every DC), and you need to calibrate the TargetHosts threshold to your estate. Twenty is a reasonable starting point for a mid-sized domain; in a 50,000-endpoint environment you may want 100 to keep vulnerability scanners and inventory agents out of the results. Baseline first, then set the threshold just above your loudest legitimate source, and allowlist that source by account rather than by IP.
Layer 5: decoy objects
Detection engineering does not have to be purely passive. Create a small number of AD objects that no legitimate process should ever touch — a plausibly named disabled service account with an SPN, sitting in an OU nobody administers — put a SACL on them, and alert on any access at all. Zero false positives by construction.
let decoyAccounts = dynamic(['svc_backup_legacy','adm_helpdesk_archive']);
SecurityEvent
| where TimeGenerated > ago(30d)
| where EventID in (4662, 4768, 4769)
| where ObjectName has_any (decoyAccounts)
or ServiceName has_any (decoyAccounts)
or TargetUserName has_any (decoyAccounts)
| project TimeGenerated, EventID, Computer, SubjectUserName, TargetUserName,
ServiceName, IpAddress
| order by TimeGenerated ascA 4769 for the decoy's SPN means someone Kerberoasted a fake account they could only have found through enumeration. A 4662 read means something walked the directory. Either way you have a source IP and an account, and you have it early.
ATT&CK coverage map
| Technique | Primary telemetry | Layer |
|---|---|---|
| T1087.002 — Domain Account Discovery | LDAP filters, net user /domain | 2, 3 |
| T1069.002 — Domain Group Discovery | LDAP group filters, samr pipe | 2, 3, 4 |
| T1482 — Domain Trust Discovery | nltest /domain_trusts, trustedDomain filter | 2, 3 |
| T1018 — Remote System Discovery | Computer-object LDAP, 5145 fan-out | 3, 4 |
| T1049 — System Network Connections Discovery | srvsvc/wkssvc pipe binds | 4 |
| T1615 — Group Policy Discovery | SYSVOL reads, GPO LDAP queries | 1, 3 |
Triage: what to do on a hit
When one of these fires, the goal is to establish scope fast, because discovery is the middle of an intrusion, not the start of one. Work backwards and forwards from the alert:
- Backwards — how did this account get onto this host? Check for interactive vs. network logon type, and pull the process ancestry of the collector. Discovery from a process descended from a browser, Office application, or scripting engine is an initial-access lead.
- Forwards — within the next hour of telemetry, look for SPN requests with RC4 encryption (T1558.003), credential access against LSASS (T1003.001), and remote service or WMI execution (T1021). Discovery is a planning step; the plan executes immediately after.
- Sideways — did the same account enumerate from more than one source host? That usually means the credentials are already being reused rather than freshly stolen.
- Contain the identity, not just the endpoint. Isolating the workstation does nothing if the operator has already harvested a set of credentials from it. Force a password reset and revoke active Kerberos tickets for the account.
Ship layers 1 and 2 this week — they need no new logging. Then make the case for Audit Detailed File Share on a pilot group of servers so you can get layer 4 into production, because that is the rule that survives an operator who renamed the binary, obfuscated the arguments, and ran the collector from memory.