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/09/18"
  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 = 73
 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 = "high"
 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    "Noise: Unknown",
101    "Performance: Fast",
102    "Rule Type: ES|QL",
103    "Platform: Entra ID",
104    "Domain: Identity",
105]
106timestamp_override = "event.ingested"
107type = "esql"
108
109query = '''
110from logs-azure.aadgraphactivitylogs-* metadata _id, _version, _index
111
112| where data_stream.dataset == "azure.aadgraphactivitylogs"
113  and to_lower(user_agent.original) like "*aiohttp*"
114
115| eval Esql.target_endpoints = case(
116    url.path like "*/eligibleRoleAssignments*", "eligibleRoleAssignments",
117    url.path like "*/roleAssignments*",         "roleAssignments",
118    url.path like "*/users*",                   "users",
119    url.path like "*/groups*",                  "groups",
120    url.path like "*/servicePrincipals*",       "servicePrincipals",
121    url.path like "*/applications*",            "applications",
122    url.path like "*/devices*",                 "devices",
123    url.path like "*/directoryRoles*",          "directoryRoles",
124    url.path like "*/roleDefinitions*",         "roleDefinitions",
125    url.path like "*/administrativeUnits*",     "administrativeUnits",
126    url.path like "*/contacts*",                "contacts",
127    url.path like "*/oauth2PermissionGrants*",  "oauth2PermissionGrants",
128    url.path like "*/authorizationPolicy*",     "authorizationPolicy",
129    url.path like "*/settings*",                "settings",
130    url.path like "*/policies*",                "policies",
131    url.path like "*/tenantDetails*",           "tenantDetails",
132    "other"
133  )
134| where Esql.target_endpoints != "other"
135
136| eval Esql.time_window = date_trunc(1 minutes, @timestamp)
137
138| stats
139    Esql.request_count                = count(*),
140    Esql.distinct_endpoints           = count_distinct(Esql.target_endpoints),
141    Esql.api_versions                 = values(azure.aadgraphactivitylogs.properties.api_version),
142    Esql.app_ids                      = values(azure.aadgraphactivitylogs.properties.app_id),
143    Esql.user_agent                   = values(user_agent.original),
144    Esql.http_methods                 = values(http.request.method),
145    Esql.status_codes                 = values(http.response.status_code),
146    Esql.source_ips                   = values(source.ip),
147    Esql.source_asn_orgs              = values(source.`as`.organization.name),
148    Esql.source_countries             = values(source.geo.country_name),
149    Esql.actor_types                  = values(azure.aadgraphactivitylogs.properties.actor_type),
150    Esql.client_auth_methods          = values(azure.aadgraphactivitylogs.properties.client_auth_method),
151    Esql.session_ids                  = values(azure.aadgraphactivitylogs.properties.session_id),
152    Esql.sign_in_activity_ids         = values(azure.aadgraphactivitylogs.properties.sign_in_activity_id),
153    Esql.scopes                       = values(azure.aadgraphactivitylogs.properties.scopes),
154    Esql.first_seen                   = min(@timestamp),
155    Esql.last_seen                    = max(@timestamp)
156  by
157    user.id,
158    azure.tenant_id,
159    Esql.time_window
160
161| where Esql.distinct_endpoints >= 5
162
163| keep
164    user.id,
165    azure.tenant_id,
166    Esql.*
167'''
168
169[rule.alert_suppression]
170group_by = ["user.id", "azure.tenant_id"]
171duration = {value = 5, unit = "m"}
172missing_fields_strategy = "suppress"
173
174[[rule.threat]]
175framework = "MITRE ATT&CK"
176[[rule.threat.technique]]
177id = "T1069"
178name = "Permission Groups Discovery"
179reference = "https://attack.mitre.org/techniques/T1069/"
180[[rule.threat.technique.subtechnique]]
181id = "T1069.003"
182name = "Cloud Groups"
183reference = "https://attack.mitre.org/techniques/T1069/003/"
184
185
186[[rule.threat.technique]]
187id = "T1087"
188name = "Account Discovery"
189reference = "https://attack.mitre.org/techniques/T1087/"
190[[rule.threat.technique.subtechnique]]
191id = "T1087.004"
192name = "Cloud Account"
193reference = "https://attack.mitre.org/techniques/T1087/004/"
194
195
196[[rule.threat.technique]]
197id = "T1526"
198name = "Cloud Service Discovery"
199reference = "https://attack.mitre.org/techniques/T1526/"
200
201
202[rule.threat.tactic]
203id = "TA0007"
204name = "Discovery"
205reference = "https://attack.mitre.org/tactics/TA0007/"
206
207[rule.investigation_fields]
208field_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 gather walks ~16 directory collections; five or more in a single minute is the structural fingerprint.
  • Tool fingerprint: aiohttp UA plus the hardcoded internal API version.
    • user_agent.original contains aiohttp.
    • api_version = 1.61-internal (hardcoded in gather.py, returns internal-only fields like strongAuthenticationDetail).
    • 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 -c flag.
    • Uses a public-client auth method (no client secret or certificate).
  • HTTP shape distinguishes enumeration from operator follow-on.
    • gather reads 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}/registeredDevices and GET /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 via DELETE /v1.0/oauth2PermissionGrants/{grantId}.
    • GET /v1.0/users/{id}/appRoleAssignments, revoke via DELETE /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/methods to 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-* for Register device, Update user, User registered security info, role assignment activity by the same user in the window.
  • 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

to-top