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 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