Close-up of a golden retriever dog with closed eyes and a peaceful expression.

BloodHound

BloodHound is a tool that analyzes Active Directory to identify relationships between users, groups, machines, and permissions. It collects graph data and visualizes how accounts and privileges are connected. This makes it possible to see potential attack paths, lateral movement routes, and privilege escalation opportunities that exist due to AD configuration.

This is one of the default 101 tool used in red teaming so nailing detection for this will provide quick wins for the blue team.

I will mainly focus on areas to look for in AD and network events as running BloodHound on a monitored endpoint is easily detected. As so the only realistic means of executing BH is through unmonitored endpoints such as a virtual machine.

Thresholds should be modified to match the size of your environment. Enumeration is typically easier to spot if you have many domain controllers. In larger environments, expect enumeration to take longer. It also had the option of stealth mode to throttle and apply jitter to collection methods to evade volumetric triggers.

BloodHound will enumerate shares and therefore create lots of SMB activity. Adjust time bins, connection thresholds, and destination thresholds based on your environment. Filter anything expected.

DeviceNetworkEvents
| where RemotePort == 445 and ActionType in ('ConnectionSuccess', 'ConnectionFailed')
| where InitiatingProcessAccountDomain != 'nt authority'
| summarize ConnectionCount=count(), RemoteIPList=make_set(RemoteIP, 10), RemoteIPCount=dcount(RemoteIP), RemoteURIs=make_set(RemoteUrl, 10)
    by DeviceName,
    InitiatingProcessId,
    InitiatingProcessCommandLine,
    InitiatingProcessAccountName,
    RemotePort,
    bin(TimeGenerated, 1h)
| where ConnectionCount >= 50 or RemoteIPCount >= 50
DeviceNetworkEvents
| where RemotePort == 445 and ActionType in ('ConnectionSuccess', 'ConnectionFailed')
| where InitiatingProcessAccountDomain != 'nt authority'
| summarize ConnectionCount=count(), RemoteIPList=make_set(RemoteIP, 10), RemoteIPCount=dcount(RemoteIP), RemoteURIs=make_set(RemoteUrl, 10)
    by DeviceName,
    InitiatingProcessId,
    InitiatingProcessCommandLine,
    InitiatingProcessAccountName,
    RemotePort,
    bin(TimeGenerated, 30s)
| where ConnectionCount >= 10 or RemoteIPCount >= 10

BloodHound makes use of hardcoded LDAP queries to gather information from Active Directory. These are visible in the sourcecode and are useful for detection. Upon execution, BloodHound will make a test LDAP connection for (objectClass=domain), which matches the single domain object at the root. From my experience, rarely anything else performs this.

If needed, filter any expected tools, processes and service accounts. Excuse the formatting.

DeviceEvents
| where ActionType == 'LdapSearch'
| extend Parsed = parse_json(AdditionalFields)
| extend AttributeList = parse_json(Parsed.AttributeList)
| extend ScopeOfSearch = parse_json(Parsed.ScopeOfSearch)
| extend SearchFilter = parse_json(Parsed.SearchFilter)
| extend DistinguishedName = parse_json(Parsed.DistinguishedName)
| where (SearchFilter =~ 'member=*' and AttributeList has_all ('member', 'range='))
or SearchFilter has_all ('(samaccounttype=268435456)', '(samaccounttype=268435457)', '(samaccounttype=268435457)', '(samaccounttype=536870913)') 
or SearchFilter has_all ('(objectCategory=person)', '(objectClass=user)','(objectClass=msDS-GroupManagedServiceAccount)') 
or SearchFilter has '(userAccountControl:1.2.840.113556.1.4.803:=8192)' 
or (SearchFilter has '(objectclass=trusteddomain)' and InitiatingProcessAccountName !startswith 'svc')
or SearchFilter has_all ('(objectClass=user)', '(objectClass=msDS-GroupManagedServiceAccount)')
or (SearchFilter has_any ('(sAMAccountType=805306368)', '(sAMAccountType=805306369)') 
         and not(SearchFilter has_any ('sAMAccountName=', 'name=', 'displayName=')))
or SearchFilter has_all ('(objectClass=computer)', '(userAccountControl&524288)') 
or SearchFilter has_all ('(objectcategory=groupPolicyContainer)', '(flags=*)')
or SearchFilter has_all ('!(objectClass=groupPolicyContainer)', '(objectClass=container)')
or SearchFilter has '(objectclass=pKICertificateTemplate)'
or SearchFilter has '(objectClass=msPKI-Enterprise-Oid)'
or SearchFilter has '(msDS-AllowedToDelegateTo=*)'
or SearchFilter has '(objectClass=configuration)'
or SearchFilter has '(serviceprincipalname=*)'
or SearchFilter has '(objectClass=domain)'
or SearchFilter has '(primarygroupid=*)'
or SearchFilter has '(gpcfilesyspath=*)'
or SearchFilter has '(schemaidguid=*)'
or DistinguishedName has_any ('CN=Directory Service,CN=Windows NT,CN=Services,CN=Configuration',
'CN=Default Query Policy,CN=Query-Policies,CN=Directory Service,CN=Windows NT,CN=Services,CN=Configuration')
| where InitiatingProcessAccountDomain !in ('nt service', 'nt authority')
| where InitiatingProcessFileName !in ('mmc.exe', 'miiserver.exe')
| project TimeGenerated, DeviceName, SearchFilter, ScopeOfSearch, AttributeList, DistinguishedName, InitiatingProcessAccountName, InitiatingProcessCommandLine,
InitiatingProcessParentFileName, InitiatingProcessAccountDomain
| sort by TimeGenerated desc

In the scenario of BloodHound execution in a virtual machine, you will have to rely on IdentityQueryEvents for LDAP events.

In this logic I have dynamically filtered domain controllers as the source to remove activity from local use such as System Center Configuration Manager and Microsoft Management Console.

let DomainControllers =
DeviceFileEvents
| where TimeGenerated >= ago(200d)
| where FolderPath has @'Windows\SYSVOL'
| distinct DomainController=DeviceName;
let DomainControllersIPs = DeviceNetworkInfo
| where DeviceName has_any (DomainControllers)
| extend Parsed = parse_json(IPAddresses)
| extend IPAddress = tostring(Parsed.[0].IPAddress)
| distinct DCIPs=IPAddress;
IdentityQueryEvents
| where ActionType == 'LDAP query'
| extend Parsed = parse_json(AdditionalFields)
| extend SearchFilter = tostring(Parsed.SearchFilter)
| extend BaseObject = tostring(Parsed.BaseObject)
| extend LdapSearchScope = tostring(Parsed.LdapSearchScope)
| where SearchFilter has 'member=*' 
or SearchFilter has_all ('(samaccounttype=268435456)', '(samaccounttype=268435457)', '(samaccounttype=268435457)', '(samaccounttype=536870913)') 
or SearchFilter has_all ('(objectCategory=person)', '(objectClass=user)','(objectClass=msDS-GroupManagedServiceAccount)') 
or SearchFilter has '(userAccountControl:1.2.840.113556.1.4.803:=8192)' 
or SearchFilter has '(objectclass=trusteddomain)'
or SearchFilter has_all ('(objectClass=user)', '(objectClass=msDS-GroupManagedServiceAccount)')
or (SearchFilter has_any ('(sAMAccountType=805306368)', '(sAMAccountType=805306369)') 
         and not(SearchFilter has_any ('sAMAccountName=', 'name=', 'displayName=')))
or SearchFilter has_all ('(objectClass=computer)', '(userAccountControl&524288)') 
or SearchFilter has_all ('(objectcategory=groupPolicyContainer)', '(flags=*)')
or SearchFilter has_all ('!(objectClass=groupPolicyContainer)', '(objectClass=container)')
or SearchFilter has '(objectclass=pKICertificateTemplate)'
or SearchFilter has '(objectClass=msPKI-Enterprise-Oid)'
or SearchFilter has '(msDS-AllowedToDelegateTo=*)'
or SearchFilter has '(objectClass=configuration)'
or SearchFilter has '(serviceprincipalname=*)'
or SearchFilter has '(objectClass=domain)'
or SearchFilter has '(primarygroupid=*)'
or SearchFilter has '(gpcfilesyspath=*)'
or SearchFilter has '(schemaidguid=*)'
or BaseObject has_any ('CN=Directory Service,CN=Windows NT,CN=Services,CN=Configuration',
'CN=Default Query Policy,CN=Query-Policies,CN=Directory Service,CN=Windows NT,CN=Services,CN=Configuration')
| where DeviceName != DestinationDeviceName
| where IPAddress !in (DomainControllersIPs)
| project TimeGenerated, SearchFilter, BaseObject, LdapSearchScope, DeviceName, DestinationDeviceName
| sort by TimeGenerated desc

Anything excessively querying admin groups and any of the expected default groups Domain Admins, Schema Admins, Enterprise Admins. This can also be applied to DeviceEvents LDAP and 4799, 4798.

IdentityQueryEvents
| where ActionType == 'LDAP query'
| extend Parsed = parse_json(AdditionalFields)
| extend SearchFilter = tostring(Parsed.SearchFilter)
| extend BaseObject = tostring(Parsed.BaseObject)
| extend LdapSearchScope = tostring(Parsed.LdapSearchScope)
| extend SourceComputerOperatingSystem = tostring(Parsed.SourceComputerOperatingSystem)
| where QueryTarget contains "admin"
| where SourceComputerOperatingSystem has 'enterprise'
| summarize TotalQueries=count(), DestList=make_set(DestinationDeviceName,10), DestCount=dcount(DestinationDeviceName), 
TargetsQueried=make_set(QueryTarget, 10), DistinctTargetCount=dcount(QueryTarget), DistinctSearchFilters=dcount(SearchFilter) by DeviceName, bin(TimeGenerated,1h)
| where DistinctTargetCount >=3
| where TargetsQueried has_any ('Domain Admins', 'Schema Admins', 'Enterprise Admins')

4798: A user's local group membership was enumerated.

4799: A security-enabled local group membership was enumerated.

Major key. Adjust thresholds where neccessary.

SecurityEvent
| where EventID in (4799, 4798) and AccountType == 'User'
| summarize TotalCount=count(), UniqueHosts=make_set(Computer), HostCount=dcount(Computer), GroupNames=make_set(TargetAccount), GroupCount=dcount(TargetAccount) 
by Account, CallerProcessName, bin(TimeGenerated, 1m)
| where HostCount > 1 and TotalCount >=5

BloodHound loves to enumerate these exact four groups.

Builtin\Administrators, Builtin\Remote Desktop Users, Builtin\Distributed COM Users, Builtin\Remote Management Users.

SecurityEvent
| where EventID in (4799, 4798) and AccountType == 'User'
| where TargetAccount in ('Builtin\\Administrators', 'Builtin\\Remote Desktop Users', 'Builtin\\Distributed COM Users', 'Builtin\\Remote Management Users')
| summarize TotalCount=count(), HostCount=dcount(Computer), GroupNames=make_set(TargetAccount), GroupCount=dcount(TargetAccount) 
by Account, CallerProcessName, Computer, bin(TimeGenerated, 10s)
| where GroupCount == 4

Here is an example of how to corrolate onto network events.

let NetworkEvents = DeviceNetworkEvents
| where RemotePort in (445, 389, 636)
| where ActionType == 'ConnectionSuccess'
| where isnotempty(InitiatingProcessId) and InitiatingProcessId != 4
| where InitiatingProcessAccountDomain != 'nt authority'
| extend InitiatingProcessAccountName = tolower(InitiatingProcessAccountName)
| summarize NetworkEventsCount=count(), NetworkEventsDistinctRemoteIP=dcount(RemoteIP) by DeviceName, InitiatingProcessId, InitiatingProcessCommandLine, 
InitiatingProcessAccountName, RemotePort, NetworkEventsBin=bin(TimeGenerated, 1m)
| where NetworkEventsDistinctRemoteIP >=5;
let EnumeratedGroup = SecurityEvent
| where EventID in ('4799','4798') and AccountType == 'User'
| extend SubjectUserName = tolower(SubjectUserName)
| summarize EnumeratedEventsCount=count(), EnumeratedHosts=make_set(Computer), EnumeratedHostCount=dcount(Computer), EnumeratedGroupNames=make_set(TargetAccount), 
EnumeratedGroupCount=dcount(TargetAccount) by SubjectUserName, CallerProcessName, EnumeratedEventsBin=bin(TimeGenerated, 1m)
| where EnumeratedHostCount > 1 and EnumeratedEventsCount >=5;
EnumeratedGroup
| join kind=inner NetworkEvents on $left.SubjectUserName == $right.InitiatingProcessAccountName
| project NetworkEventsBin, EnumeratedEventsBin, DeviceName, InitiatingProcessAccountName, InitiatingProcessCommandLine, CallerProcessName, NetworkEventsCount, 
NetworkEventsDistinctRemoteIP, RemotePort, EnumeratedEventsCount, EnumeratedHosts, EnumeratedHostCount, EnumeratedGroupNames, EnumeratedGroupCount
| where abs(datetime_diff('minute', NetworkEventsBin, EnumeratedEventsBin)) <= 2

Groups.xml is a Group Policy Preferences (GPP) file stored in SYSVOL. It is often accessed by BloodHound.

SecurityEvent
| where EventID == 4663 and AccountType == 'User'
| where ObjectName has_all ('sysvol', 'policies', 'Groups.xml')
DeviceFileEvents
| where FileName == 'Groups.xml' 
| where not (FolderPath has_all (@'Machine\Preferences\Groups\Groups.xml', 'Sysvol')) 
and not (FolderPath has_all (@'ProgramData\Microsoft\Group Policy\History\', @'Machine\Preferences\Groups\Groups.xml'))
SecurityEvent
| where EventID == 4663 and AccountType == 'User'
| where ObjectName has_all ('sysvol', 'policies', 'Groups.xml')

GptTmpl.inf is a security template file stored within each Group Policy Object. Scanning against SYSVOL is typical of BloodHound.

SecurityEvent
| where EventID == 4663 and AccountType == 'User'
| where ObjectName has_all ('SYSVOL', 'SecEdit\\GptTmpl.inf')
| summarize TotalCount=count(), ObjectFile=make_set(ObjectName), ObjectFileCount=dcount(ObjectName), HostList=make_set(Computer), HostCount=dcount(Computer) 
by SubjectUserName, ProcessName, bin(TimeGenerated,5m)
| where ObjectFile > 20

BloodHound exports its results to zip archive with the default filename of BloodHound. However it does have the option to annonymise this.

DeviceFileEvents
| where FileName contains 'hound' and FileName endswith '.zip'
| project TimeGenerated, ActionType, DeviceName, InitiatingProcessAccountName, FolderPath, InitiatingProcessCommandLine, InitiatingProcessParentFileName
| sort by TimeGenerated desc

5140: A network share object was accessed
Additional SMB excessive share enumeration.

SecurityEvent
| where EventID == 5140
| where ShareName has '*' and AccountType == 'User' 
| summarize TotalCount=count(), DistinctShareCount=dcount(ShareName), ShareNameList=make_set(ShareName,10), DistinctSharePathCount=dcount(ShareLocalPath), 
ShareLocalPathCount=make_set(ShareLocalPath,10) by SubjectAccount, bin(TimeGenerated,1m)
| where DistinctShareCount >= 20 or DistinctSharePathCount >= 20
| sort by DistinctSharePathCount desc

5145: A network share object was checked to see whether client can be granted desired access
Again, this can be used for excessive share enumeration but providing more detail.

BloodHound collectors access specific Windows RPC interfaces over SMB pipes to gather this data without needing high privileges, often just as an authenticated domain user. The protocols SAMR, LSARPC, and SRVSVC are commonly accessed during this process.

SecurityEvent
| where EventID == 5145
| where RelativeTargetName in~ ('samr', 'lsarpc', 'srvsvc') and AccountType == 'User'
| summarize count(), FileNames=make_set(RelativeTargetName), DistinctFileNameCount=dcount(RelativeTargetName), ShareName=make_set(ShareName), 
ShareLocalPath=make_set(ShareLocalPath) by SubjectUserName, Computer, bin(TimeGenerated, 1m)
| where DistinctFileNameCount == 3
DeviceEvents
| where ActionType == 'NamedPipeEvent'
| extend Parsed = parse_json(AdditionalFields)
| extend PipeName = tostring(Parsed.PipeName)
| where PipeName has_any ('samr', 'lsarpc', 'srvsvc')
| where InitiatingProcessAccountDomain != 'nt authority'
| summarize PipeNames=make_set(PipeName), PipeCount=dcount(PipeName) by DeviceName, InitiatingProcessFileName, InitiatingProcessAccountName, bin(TimeGenerated,1m)
| where PipeCount == 3

I was going to leave this one out, but this query looks for any process commandline where -d is followed by the domain name or a domain controller. Typical of the arguments supplied to BloodHound. This may lead to good insights.

let DomainControllers =
DeviceFileEvents
| where TimeGenerated >= ago(200d)
| where FolderPath has @"Windows\SYSVOL"
| distinct DomainController=DeviceName;
let DomainControllersIPs = DeviceNetworkInfo
| where DeviceName has_any (DomainControllers)
| extend Parsed = parse_json(IPAddresses)
| extend IPAddress = tostring(Parsed.[0].IPAddress)
| distinct DCIPs=IPAddress;
let DomainName = SecurityEvent
| where EventID == 4625
| where SubjectDomainName !in ('-','WORKGROUP') and SubjectDomainName !startswith "NT " and isnotempty(SubjectDomainName)
| distinct GetDomainName=SubjectDomainName;
DeviceProcessEvents
| where TimeGenerated >= ago(200d)
| extend dc_after_flag = extract(@"(?i)(?:^|\s)-d\s+([^\s""']+)", 1, ProcessCommandLine)
| where isnotempty(dc_after_flag)
| where dc_after_flag in (DomainControllers) or dc_after_flag in (DomainControllersIPs) or dc_after_flag in (DomainName)
| project TimeGenerated, DeviceName, AccountName, FolderPath, ProcessCommandLine, InitiatingProcessCommandLine, InitiatingProcessParentFileName, dc_after_flag
| sort by TimeGenerated desc

HUNTING