Azure AD Graph Potential Enumeration (ROADrecon)
editAzure AD Graph Potential Enumeration (ROADrecon)
editDetects 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.
Rule type: esql
Rule indices: None
Severity: high
Risk score: 73
Runs every: 5m
Searches indices from: now-9m (Date Math format, see also Additional look-back time)
Maximum alerts per execution: 100
References:
Tags:
- Domain: Cloud
- Data Source: Azure
- Data Source: Azure AD Graph
- Data Source: Azure AD Graph Activity Logs
- Use Case: Threat Detection
- Tactic: Discovery
- Resources: Investigation Guide
- Noise: Unknown
- Performance: Fast
- Rule Type: ES|QL
- Platform: Entra ID
- Domain: Identity
Version: 4
Rule authors:
- Elastic
Rule license: Elastic License v2
Investigation guide
editTriage 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.
The rule anchors on the hardcoded internal API version and a per-user burst rather than the user-agent alone, so it catches all three gather transports:
-
Default:
aiohttpuser-agent across ~16 directory collections. -
--evade: a single/$batchOData endpoint under aMicrosoft ADO.NET Data Servicesuser-agent (defeats user-agent and distinct-endpoint based matching). -
-ua <value>: an arbitrary browser user-agent.
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. -
A default
gatherwalks ~16 collections (many distincturl.path);--evadeconcentrates volume on/$batch(highrequest_count, one path). - Tool fingerprint: the internal API version is the primary anchor.
-
api_version = 1.61-internal(hardcoded ingather.py, returns internal-only fields likestrongAuthenticationDetail). No first-party Microsoft component pins this at volume. -
user_agent.originalis a secondary tell:aiohttp/python(overt),Microsoft ADO.NET Data Serviceswith/$batch(the--evadesignature), or a browser string (-ua). - 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).
- HTTP shape distinguishes enumeration from operator follow-on.
-
gatherreads only, so GETs dominate (the--evade/$batchrequests are POSTs to the batch endpoint). - A 403/404 tail indicates the identity probing endpoints it lacks permission for.
- PATCH / POST / DELETE against directory objects 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. - 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.
Setup
editAzure AD Graph Activity Logs
Requires Azure AD Graph Activity Logs ingested into logs-azure.aadgraphactivitylogs-* via the Elastic Azure integration. Enable the AzureADGraphActivityLogs diagnostic-settings category on Entra ID.
Rule query
editfrom 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.*
Framework: MITRE ATT&CKTM
-
Tactic:
- Name: Discovery
- ID: TA0007
- Reference URL: https://attack.mitre.org/tactics/TA0007/
-
Technique:
- Name: Permission Groups Discovery
- ID: T1069
- Reference URL: https://attack.mitre.org/techniques/T1069/
-
Sub-technique:
- Name: Cloud Groups
- ID: T1069.003
- Reference URL: https://attack.mitre.org/techniques/T1069/003/
-
Technique:
- Name: Account Discovery
- ID: T1087
- Reference URL: https://attack.mitre.org/techniques/T1087/
-
Sub-technique:
- Name: Cloud Account
- ID: T1087.004
- Reference URL: https://attack.mitre.org/techniques/T1087/004/
-
Technique:
- Name: Cloud Service Discovery
- ID: T1526
- Reference URL: https://attack.mitre.org/techniques/T1526/