Azure AD Graph Potential Enumeration (ROADrecon)
Description
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.
Query · esql
from logs-azure.aadgraphactivitylogs-* metadata _id, _version, _index
| where data_stream.dataset == "azure.aadgraphactivitylogs"
and to_lower(user_agent.original) like "*aiohttp*"
| eval Esql.target_endpoints = case(
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"
)
| where Esql.target_endpoints != "other"
| eval Esql.time_window = date_trunc(1 minutes, @timestamp)
| stats
Esql.request_count = count(*),
Esql.distinct_endpoints = count_distinct(Esql.target_endpoints),
Esql.api_versions = values(azure.aadgraphactivitylogs.properties.api_version),
Esql.app_ids = values(azure.aadgraphactivitylogs.properties.app_id),
Esql.user_agent = 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
| where Esql.distinct_endpoints >= 5
| keep
user.id,
azure.tenant_id,
Esql.*
Investigation fields
Pivot points the source recommends for triage.
user.idazure.tenant_id
Implementation guide
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.
Known false positives
- Developer activity using aiohttp against AAD Graph for prototyping. Rare in production tenants and typically low-volume; the burst threshold limits exposure.
- Authorized red team activity exercising ROADrecon. Document the engagement window and add exceptions on the source IP or calling user.
Analyst notes
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.