Azure AD Graph Potential Enumeration (ROADrecon)
Detects an Azure AD Graph (graph.windows.net) burst from a user-agent identifying as "aiohttp" (the default HTTP library used by ROADrecon's "gather" command) where a single calling identity issues many requests in a short window. ROADrecon walks every interesting directory object type via aiohttp, producing a large volume of requests from one user / source IP / UA triple. The combination of "aiohttp" UA with a burst threshold is a structural ROADrecon signature; legitimate first-party Microsoft components do not identify as aiohttp.
Elastic rule (View on GitHub)
1[metadata]
2creation_date = "2026/05/20"
3integration = ["azure"]
4maturity = "production"
5updated_date = "2026/05/20"
6
7[rule]
8author = ["Elastic"]
9description = """
10Detects an Azure AD Graph (graph.windows.net) burst from a user-agent identifying as "aiohttp" (the default HTTP library
11used by ROADrecon's "gather" command) where a single calling identity issues many requests in a short window. ROADrecon
12walks every interesting directory object type via aiohttp, producing a large volume of requests from one
13user / source IP / UA triple. The combination of "aiohttp" UA with a burst threshold is a structural ROADrecon
14signature; legitimate first-party Microsoft components do not identify as aiohttp.
15"""
16false_positives = [
17 """
18 Developer activity using aiohttp against AAD Graph for prototyping. Rare in production tenants and typically
19 low-volume; the burst threshold limits exposure.
20 """,
21 """
22 Authorized red team activity exercising ROADrecon. Document the engagement window and add exceptions on the source
23 IP or calling user.
24 """,
25]
26from = "now-9m"
27language = "esql"
28license = "Elastic License v2"
29name = "Azure AD Graph Potential Enumeration (ROADrecon)"
30note = """## Triage and analysis
31
32### Investigating Azure AD Graph Potential Enumeration (ROADrecon)
33
34This is an ES|QL aggregation rule. Alert documents contain summarized fields per burst window: the calling identity, the tenant, and a one-minute bucket. The alert itself is the signal that something resembling ROADrecon's `gather` walk happened against AAD Graph; the actual investigation happens against the raw `logs-azure.aadgraphactivitylogs-*` events for the same identity and window.
35
36### Possible investigation steps
37
38- Confirm the burst by filtering raw AAD Graph activity for the alerting user, tenant, and time window.
39 - Filter `logs-azure.aadgraphactivitylogs-*` on the alerting user, tenant, and burst window.
40 - ROADrecon's full `gather` walks ~16 directory collections; five or more in a single minute is the structural fingerprint.
41- Tool fingerprint: aiohttp UA plus the hardcoded internal API version.
42 - `user_agent.original` contains `aiohttp`.
43 - `api_version = 1.61-internal` (hardcoded in `gather.py`, returns internal-only fields like `strongAuthenticationDetail`).
44 - No first-party Microsoft component identifies as aiohttp or pins `1.61-internal`.
45- Calling client + auth method: the typical device-code-flow ROADrecon entrypoint.
46 - ROADrecon is usually pointed at the Azure CLI client (`04b07795-…`) via the `-c` flag.
47 - Uses a public-client auth method (no client secret or certificate).
48- HTTP shape distinguishes enumeration from operator follow-on.
49 - `gather` reads only, so GETs dominate.
50 - A 403/404 tail indicates the identity probing endpoints it lacks permission for.
51 - PATCH / POST / DELETE in the same burst means the operator did more than enumerate.
52- Source posture: residential ISP, generic VPS, or anonymising-network egress raises triage priority.
53- Pivot to sign-in logs (`logs-azure.signinlogs-*`) via the sign-in correlation ID on each AAD Graph event to land on the originating token-mint.
54- Pivot to audit logs (`logs-azure.auditlogs-*`) for any directory writes by the same user near the burst that suggest persistence or modification activity.
55- Confirm the activity is not attributable to authorized testing before treating as malicious.
56 - Check for red team engagement, penetration test, or internal tooling validation.
57 - Validate against the engagement window and the operator's known source range.
58
59### Response and remediation
60
61- Enumerate device registrations created by the user during or around the burst window.
62 - `GET /v1.0/users/{id}/registeredDevices` and `GET /v1.0/users/{id}/ownedDevices`.
63 - De-register anything not attributable to a known endpoint via `DELETE /v1.0/devices/{deviceObjectId}`.
64 - Do this BEFORE session revocation: device-bound PRTs survive `revokeSignInSessions`.
65- Revoke refresh tokens and active sessions for the calling user.
66 - `POST /v1.0/users/{id}/revokeSignInSessions`.
67- Temporarily disable the user if the alert is high-confidence or you need to halt further activity while investigation continues.
68 - `PATCH /v1.0/users/{id}` with body `{"accountEnabled": false}`.
69- Audit OAuth grants and app role assignments the user holds; revoke anything minted from a kit-egress or otherwise suspicious source.
70 - `GET /v1.0/oauth2PermissionGrants?$filter=principalId eq '{id}'`, revoke via `DELETE /v1.0/oauth2PermissionGrants/{grantId}`.
71 - `GET /v1.0/users/{id}/appRoleAssignments`, revoke via `DELETE /v1.0/servicePrincipals/{spId}/appRoleAssignedTo/{assignmentId}`.
72- Reset the user's password and audit authentication methods added during the window.
73 - `GET /v1.0/users/{id}/authentication/methods` to list.
74 - Remove anything unexpected via the method-type-specific endpoint.
75- Audit directory writes by the user near the burst and roll back unauthorized changes.
76 - Query `logs-azure.auditlogs-*` for `Register device`, `Update user`, `User registered security info`, role assignment activity by the same user in the window.
77- If the calling application has no legitimate AAD Graph dependency, block further use by that app.
78 - `PATCH /beta/applications/{id}` with body `{"authenticationBehaviors": {"blockAzureADGraphAccess": true}}`.
79 - This property lives on the Graph beta endpoint, not v1.0.
80"""
81references = [
82 "https://github.com/dirkjanm/ROADtools",
83 "https://github.com/dirkjanm/ROADtools/blob/master/roadrecon/roadtools/roadrecon/gather.py",
84 "https://learn.microsoft.com/en-us/graph/migrate-azure-ad-graph-overview",
85]
86risk_score = 47
87rule_id = "80aa6cca-b343-457b-877e-5877cd71a1f8"
88setup = """#### Azure AD Graph Activity Logs
89Requires Azure AD Graph Activity Logs ingested into `logs-azure.aadgraphactivitylogs-*` via the Elastic Azure integration. Enable the `AzureADGraphActivityLogs` diagnostic-settings category on Entra ID.
90"""
91severity = "medium"
92tags = [
93 "Domain: Cloud",
94 "Data Source: Azure",
95 "Data Source: Azure AD Graph",
96 "Data Source: Azure AD Graph Activity Logs",
97 "Use Case: Threat Detection",
98 "Tactic: Discovery",
99 "Resources: Investigation Guide",
100]
101timestamp_override = "event.ingested"
102type = "esql"
103
104query = '''
105from logs-azure.aadgraphactivitylogs-* metadata _id, _version, _index
106
107| where data_stream.dataset == "azure.aadgraphactivitylogs"
108 and to_lower(user_agent.original) like "*aiohttp*"
109
110| eval Esql.target_endpoints = case(
111 url.path like "*/eligibleRoleAssignments*", "eligibleRoleAssignments",
112 url.path like "*/roleAssignments*", "roleAssignments",
113 url.path like "*/users*", "users",
114 url.path like "*/groups*", "groups",
115 url.path like "*/servicePrincipals*", "servicePrincipals",
116 url.path like "*/applications*", "applications",
117 url.path like "*/devices*", "devices",
118 url.path like "*/directoryRoles*", "directoryRoles",
119 url.path like "*/roleDefinitions*", "roleDefinitions",
120 url.path like "*/administrativeUnits*", "administrativeUnits",
121 url.path like "*/contacts*", "contacts",
122 url.path like "*/oauth2PermissionGrants*", "oauth2PermissionGrants",
123 url.path like "*/authorizationPolicy*", "authorizationPolicy",
124 url.path like "*/settings*", "settings",
125 url.path like "*/policies*", "policies",
126 url.path like "*/tenantDetails*", "tenantDetails",
127 "other"
128 )
129| where Esql.target_endpoints != "other"
130
131| eval Esql.time_window = date_trunc(1 minutes, @timestamp)
132
133| stats
134 Esql.request_count = count(*),
135 Esql.distinct_endpoints = count_distinct(Esql.target_endpoints),
136 Esql.api_versions = values(azure.aadgraphactivitylogs.properties.api_version),
137 Esql.app_ids = values(azure.aadgraphactivitylogs.properties.app_id),
138 Esql.user_agent = values(user_agent.original),
139 Esql.http_methods = values(http.request.method),
140 Esql.status_codes = values(http.response.status_code),
141 Esql.source_ips = values(source.ip),
142 Esql.source_asn_orgs = values(source.`as`.organization.name),
143 Esql.source_countries = values(source.geo.country_name),
144 Esql.actor_types = values(azure.aadgraphactivitylogs.properties.actor_type),
145 Esql.client_auth_methods = values(azure.aadgraphactivitylogs.properties.client_auth_method),
146 Esql.session_ids = values(azure.aadgraphactivitylogs.properties.session_id),
147 Esql.sign_in_activity_ids = values(azure.aadgraphactivitylogs.properties.sign_in_activity_id),
148 Esql.scopes = values(azure.aadgraphactivitylogs.properties.scopes),
149 Esql.first_seen = min(@timestamp),
150 Esql.last_seen = max(@timestamp)
151 by
152 user.id,
153 azure.tenant_id,
154 Esql.time_window
155
156| where Esql.distinct_endpoints >= 5
157
158| keep
159 user.id,
160 azure.tenant_id,
161 Esql.*
162'''
163
164[rule.alert_suppression]
165group_by = ["user.id", "azure.tenant_id"]
166duration = {value = 5, unit = "m"}
167missing_fields_strategy = "suppress"
168
169[[rule.threat]]
170framework = "MITRE ATT&CK"
171[[rule.threat.technique]]
172id = "T1069"
173name = "Permission Groups Discovery"
174reference = "https://attack.mitre.org/techniques/T1069/"
175[[rule.threat.technique.subtechnique]]
176id = "T1069.003"
177name = "Cloud Groups"
178reference = "https://attack.mitre.org/techniques/T1069/003/"
179
180
181[[rule.threat.technique]]
182id = "T1087"
183name = "Account Discovery"
184reference = "https://attack.mitre.org/techniques/T1087/"
185[[rule.threat.technique.subtechnique]]
186id = "T1087.004"
187name = "Cloud Account"
188reference = "https://attack.mitre.org/techniques/T1087/004/"
189
190
191[[rule.threat.technique]]
192id = "T1526"
193name = "Cloud Service Discovery"
194reference = "https://attack.mitre.org/techniques/T1526/"
195
196
197[rule.threat.tactic]
198id = "TA0007"
199name = "Discovery"
200reference = "https://attack.mitre.org/tactics/TA0007/"
201
202[rule.investigation_fields]
203field_names = ["user.id", "azure.tenant_id"]
Triage and analysis
Investigating Azure AD Graph Potential Enumeration (ROADrecon)
This is an ES|QL aggregation rule. Alert documents contain summarized fields per burst window: the calling identity, the tenant, and a one-minute bucket. The alert itself is the signal that something resembling ROADrecon's gather walk happened against AAD Graph; the actual investigation happens against the raw logs-azure.aadgraphactivitylogs-* events for the same identity and window.
Possible investigation steps
- Confirm the burst by filtering raw AAD Graph activity for the alerting user, tenant, and time window.
- Filter
logs-azure.aadgraphactivitylogs-*on the alerting user, tenant, and burst window. - ROADrecon's full
gatherwalks ~16 directory collections; five or more in a single minute is the structural fingerprint.
- Filter
- Tool fingerprint: aiohttp UA plus the hardcoded internal API version.
user_agent.originalcontainsaiohttp.api_version = 1.61-internal(hardcoded ingather.py, returns internal-only fields likestrongAuthenticationDetail).- No first-party Microsoft component identifies as aiohttp or pins
1.61-internal.
- Calling client + auth method: the typical device-code-flow ROADrecon entrypoint.
- ROADrecon is usually pointed at the Azure CLI client (
04b07795-…) via the-cflag. - Uses a public-client auth method (no client secret or certificate).
- ROADrecon is usually pointed at the Azure CLI client (
- HTTP shape distinguishes enumeration from operator follow-on.
gatherreads only, so GETs dominate.- A 403/404 tail indicates the identity probing endpoints it lacks permission for.
- PATCH / POST / DELETE in the same burst means the operator did more than enumerate.
- Source posture: residential ISP, generic VPS, or anonymising-network egress raises triage priority.
- Pivot to sign-in logs (
logs-azure.signinlogs-*) via the sign-in correlation ID on each AAD Graph event to land on the originating token-mint. - Pivot to audit logs (
logs-azure.auditlogs-*) for any directory writes by the same user near the burst that suggest persistence or modification activity. - Confirm the activity is not attributable to authorized testing before treating as malicious.
- Check for red team engagement, penetration test, or internal tooling validation.
- Validate against the engagement window and the operator's known source range.
Response and remediation
- Enumerate device registrations created by the user during or around the burst window.
GET /v1.0/users/{id}/registeredDevicesandGET /v1.0/users/{id}/ownedDevices.- De-register anything not attributable to a known endpoint via
DELETE /v1.0/devices/{deviceObjectId}. - Do this BEFORE session revocation: device-bound PRTs survive
revokeSignInSessions.
- Revoke refresh tokens and active sessions for the calling user.
POST /v1.0/users/{id}/revokeSignInSessions.
- Temporarily disable the user if the alert is high-confidence or you need to halt further activity while investigation continues.
PATCH /v1.0/users/{id}with body{"accountEnabled": false}.
- Audit OAuth grants and app role assignments the user holds; revoke anything minted from a kit-egress or otherwise suspicious source.
GET /v1.0/oauth2PermissionGrants?$filter=principalId eq '{id}', revoke viaDELETE /v1.0/oauth2PermissionGrants/{grantId}.GET /v1.0/users/{id}/appRoleAssignments, revoke viaDELETE /v1.0/servicePrincipals/{spId}/appRoleAssignedTo/{assignmentId}.
- Reset the user's password and audit authentication methods added during the window.
GET /v1.0/users/{id}/authentication/methodsto list.- Remove anything unexpected via the method-type-specific endpoint.
- Audit directory writes by the user near the burst and roll back unauthorized changes.
- Query
logs-azure.auditlogs-*forRegister device,Update user,User registered security info, role assignment activity by the same user in the window.
- Query
- If the calling application has no legitimate AAD Graph dependency, block further use by that app.
PATCH /beta/applications/{id}with body{"authenticationBehaviors": {"blockAzureADGraphAccess": true}}.- This property lives on the Graph beta endpoint, not v1.0.
References
Related rules
- Microsoft Graph Multi-Category Reconnaissance Burst
- Entra ID Sign-in BloodHound Suite User-Agent Detected
- Entra ID Sign-in TeamFiltration User-Agent Detected
- Azure Run Command Correlated with Process Execution
- Azure Run Command Script Child Process