Azure AD Graph Potential Enumeration (ROADrecon)

Last updated 22 days ago on 2026-10-09
Created 5 months ago on 2026-05-20

About

Detects a burst of Azure AD Graph (graph.windows.net) directory enumeration by a single identity, keyed on the internal API version that ROADrecon hardcodes (`1.61-internal`, with `1.6-internal` as a related tooling value) plus a per-user request burst. ROADrecon's gather command walks the directory via this internal API version using aiohttp. This rule also detects the "evade" argument that routes every read through a single "/$batch" OData endpoint under a benign "Microsoft ADO.NET Data Services" user-agent. A lower burst threshold applies when a scripting user-agent (aiohttp/python/fasthttp) is present and a higher volume threshold applies otherwise.
Tags
Domain: CloudData Source: AzureData Source: Azure AD GraphData Source: Azure AD Graph Activity LogsUse Case: Threat DetectionTactic: DiscoveryNoise: UnknownPerformance: FastRule Type: ES|QLPlatform: Entra IDDomain: IdentityLanguage: esql
Severity
high
Risk Score
73
MITRE ATT&CK™

Discovery (TA0007)(external, opens in a new tab or window)

False Positive Examples
Legitimate first-party Microsoft components (for example the Azure Graph Client Library used by portal or Office experiences) occasionally call AAD Graph with the internal API version, but at single-digit to low-double-digit volume; the burst thresholds exclude them. Validate the calling `azure.aadgraphactivitylogs.properties.app_id` and user if a low-volume match appears. Developer activity using aiohttp or python HTTP libraries against AAD Graph for prototyping. Rare in production tenants and typically low-volume. Authorized red team activity exercising ROADrecon. Document the engagement window and add exceptions on the source IP or calling user.
License
Elastic License v2(external, opens in a new tab or window)

Definition

Integration Pack
Prebuilt Security Detection Rules
Related Integrations

azure(external, opens in a new tab or window)

Query
text code block:
from logs-azure.aadgraphactivitylogs-* metadata _id, _version, _index | where data_stream.dataset == "azure.aadgraphactivitylogs" and azure.aadgraphactivitylogs.properties.api_version in ("1.61-internal", "1.6-internal") | eval Esql.ua_lower = to_lower(user_agent.original) | eval Esql.scripting_user_agent = Esql.ua_lower like "*aiohttp*" or Esql.ua_lower like "python*" or Esql.ua_lower like "*fasthttp*" | eval Esql.target_endpoint = case( url.path like "*$batch*", "batch", url.path like "*/eligibleRoleAssignments*", "eligibleRoleAssignments", url.path like "*/roleAssignments*", "roleAssignments", url.path like "*/users*", "users", url.path like "*/groups*", "groups", url.path like "*/servicePrincipals*", "servicePrincipals", url.path like "*/applications*", "applications", url.path like "*/devices*", "devices", url.path like "*/directoryRoles*", "directoryRoles", url.path like "*/roleDefinitions*", "roleDefinitions", url.path like "*/administrativeUnits*", "administrativeUnits", url.path like "*/contacts*", "contacts", url.path like "*/oauth2PermissionGrants*", "oauth2PermissionGrants", url.path like "*/authorizationPolicy*", "authorizationPolicy", url.path like "*/settings*", "settings", url.path like "*/policies*", "policies", url.path like "*/tenantDetails*", "tenantDetails", "other" ) | eval Esql.time_window = date_trunc(1 minutes, @timestamp) | stats Esql.request_count = count(*), Esql.distinct_endpoints = count_distinct(url.path), Esql.collections = values(Esql.target_endpoint), Esql.scripting_user_agent_seen = max(case(Esql.scripting_user_agent, 1, 0)), Esql.api_versions = values(azure.aadgraphactivitylogs.properties.api_version), Esql.app_ids = values(azure.aadgraphactivitylogs.properties.app_id), Esql.user_agents = values(user_agent.original), Esql.http_methods = values(http.request.method), Esql.status_codes = values(http.response.status_code), Esql.source_ips = values(source.ip), Esql.source_asn_orgs = values(source.`as`.organization.name), Esql.source_countries = values(source.geo.country_name), Esql.actor_types = values(azure.aadgraphactivitylogs.properties.actor_type), Esql.client_auth_methods = values(azure.aadgraphactivitylogs.properties.client_auth_method), Esql.session_ids = values(azure.aadgraphactivitylogs.properties.session_id), Esql.sign_in_activity_ids = values(azure.aadgraphactivitylogs.properties.sign_in_activity_id), Esql.scopes = values(azure.aadgraphactivitylogs.properties.scopes), Esql.first_seen = min(@timestamp), Esql.last_seen = max(@timestamp) by user.id, azure.tenant_id, Esql.time_window | eval Esql.enum_detected = case( Esql.scripting_user_agent_seen == 1 and Esql.distinct_endpoints >= 5, true, Esql.distinct_endpoints >= 15, true, Esql.request_count >= 150, true, false ) | where Esql.enum_detected == true | keep user.id, azure.tenant_id, Esql.*

Install detection rules in Elastic Security

Detect Azure AD Graph Potential Enumeration (ROADrecon) in the Elastic Security detection engine by installing this rule into your Elastic Stack.

To setup this rule, check out the installation guide for Prebuilt Security Detection Rules(external, opens in a new tab or window).