Azure AD Graph Potential Enumeration (ROADrecon)

edit
IMPORTANT: This documentation is no longer updated. Refer to Elastic's version policy and the latest documentation.

Azure AD Graph Potential Enumeration (ROADrecon)

edit

Detects 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

edit

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.

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: aiohttp user-agent across ~16 directory collections.
  • --evade: a single /$batch OData endpoint under a Microsoft ADO.NET Data Services user-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 gather walks ~16 collections (many distinct url.path); --evade concentrates volume on /$batch (high request_count, one path).
  • Tool fingerprint: the internal API version is the primary anchor.
  • api_version = 1.61-internal (hardcoded in gather.py, returns internal-only fields like strongAuthenticationDetail). No first-party Microsoft component pins this at volume.
  • user_agent.original is a secondary tell: aiohttp/python (overt), Microsoft ADO.NET Data Services with /$batch (the --evade signature), 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 -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 (the --evade /$batch requests 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}/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.

Setup

edit

Azure 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

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