Azure AD Graph Access with Suspicious User-Agent

Identifies Azure AD Graph (graph.windows.net) requests originating from user-agent strings associated with offensive tooling, scripting libraries, or generic HTTP clients. First-party Microsoft components calling AAD Graph identify with specific user agents such as "Microsoft Azure Graph Client Library", "Microsoft ADO.NET Data Services", or "Microsoft.OData.Client". Anything outside that recognised set is either a developer prototyping against the legacy API or an enumeration tool walking the directory.

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 = """
 10Identifies Azure AD Graph (graph.windows.net) requests originating from user-agent strings associated with offensive
 11tooling, scripting libraries, or generic HTTP clients. First-party Microsoft components calling AAD Graph identify with
 12specific user agents such as "Microsoft Azure Graph Client Library", "Microsoft ADO.NET Data Services", or
 13"Microsoft.OData.Client". Anything outside that recognised set is either a developer prototyping against the legacy API
 14or an enumeration tool walking the directory.
 15"""
 16false_positives = [
 17    """
 18    Developer activity prototyping against AAD Graph from a workstation may match. Validate via the calling
 19    `azure.aadgraphactivitylogs.properties.app_id` and the signed-in user; legitimate developer use is rare in
 20    production tenants since Microsoft has been steering callers off AAD Graph for years.
 21    """,
 22    """
 23    Authorized red team or penetration test activity using ROADrecon, ROADtools, AADInternalsor similar tooling can match. Add
 24    exceptions on the source IP, signed-in user, or app ID after validation.
 25    """,
 26
 27]
 28from = "now-10m"
 29interval = "9m"
 30language = "esql"
 31license = "Elastic License v2"
 32name = "Azure AD Graph Access with Suspicious User-Agent"
 33note = """## Triage and analysis
 34
 35### Investigating Azure AD Graph Access with Suspicious User-Agent
 36
 37Azure AD Graph (graph.windows.net) is the legacy directory REST API that Microsoft has been retiring for years.
 38Legitimate first-party traffic against it is dominated by a small set of recognisable user agents (`Microsoft.OData.Client`,
 39`Microsoft Azure Graph Client Library`, `Microsoft ADO.NET Data Services`, the Azure portal with a Chrome user agent,
 40and an empty-UA tail from first-party AppIds). Traffic identifying as Python, aiohttp, curl, Go-http-client, or any
 41of the `*hound` enumeration families is almost always either a developer prototype or adversary tooling. This rule
 42flags any such request even at a single event, because tooling samples for AAD Graph are inherently low-volume in
 43normal tenants.
 44
 45### Possible investigation steps
 46
 47- Confirm the matching user agent.
 48    - `user_agent.original` (e.g., `aiohttp`, `AADInternals`, `curl`, `bav2ropc`).
 49- Identify the caller and the calling client.
 50    - `user.id` for the caller, `azure.aadgraphactivitylogs.properties.app_id` for the OAuth client.
 51- Review which directory object types were touched.
 52    - `url.path` (e.g., `/users`, `/policies`, `/servicePrincipals`).
 53- Check the success / failure pattern.
 54    - `http.response.status_code`. Many 4xx responses suggest permission probing.
 55- Cross-reference with the API version.
 56    - `azure.aadgraphactivitylogs.properties.api_version`. A non-Microsoft UA combined with `1.6-internal` or `1.61-internal` is a stronger signal of offensive tooling.
 57- Pivot to sign-in logs (`logs-azure.signinlogs-*`) for the same user / source IP to understand how the token was obtained.
 58- Confirm the activity is not attributable to authorized testing (red team engagement, penetration test, internal tooling validation) before treating as malicious.
 59
 60### Response and remediation
 61
 62- Revoke refresh tokens and active sessions for the calling user.
 63    - `POST /v1.0/users/{id}/revokeSignInSessions`.
 64- Temporarily disable the user if the alert is high-confidence or you need to halt further activity while investigation continues.
 65    - `PATCH /v1.0/users/{id}` with body `{"accountEnabled": false}`.
 66- Check for device registrations created by the user during or around the burst window and remove rogue devices.
 67    - `GET /v1.0/users/{id}/registeredDevices` and `GET /v1.0/users/{id}/ownedDevices`, then `DELETE /v1.0/devices/{deviceObjectId}`.
 68    - Do this BEFORE session revocation: device-bound PRTs survive `revokeSignInSessions`.
 69- If the calling application has no legitimate AAD Graph dependency, block further use by that app.
 70    - `PATCH /beta/applications/{id}` with body `{"authenticationBehaviors": {"blockAzureADGraphAccess": true}}`.
 71    - This property lives on the Graph beta endpoint, not v1.0.
 72- Apply Conditional Access targeting the AAD Graph audience for the affected user population.
 73"""
 74references = [
 75    "https://learn.microsoft.com/en-us/graph/migrate-azure-ad-graph-overview",
 76    "https://github.com/dirkjanm/ROADtools",
 77    "https://www.sophos.com/en-us/research/tampering-with-conditional-access-policies-using-azure-ad-graph-api",
 78    "https://github.com/gerenios/aadinternals",
 79]
 80risk_score = 47
 81rule_id = "3aec394d-ed2a-4f3e-8ed3-4a2adea39f05"
 82setup = """#### Azure AD Graph Activity Logs
 83Requires Azure AD Graph Activity Logs ingested into `logs-azure.aadgraphactivitylogs-*` via the Elastic Azure
 84integration (Azure Event Hub). Enable the `AzureADGraphActivityLogs` diagnostic-settings category on Entra ID.
 85"""
 86severity = "medium"
 87tags = [
 88    "Domain: Cloud",
 89    "Data Source: Azure",
 90    "Data Source: Azure AD Graph",
 91    "Data Source: Azure AD Graph Activity Logs",
 92    "Use Case: Threat Detection",
 93    "Tactic: Discovery",
 94    "Resources: Investigation Guide",
 95]
 96timestamp_override = "event.ingested"
 97type = "esql"
 98
 99query = '''
100from logs-azure.aadgraphactivitylogs-* metadata _id, _version, _index
101
102| where data_stream.dataset == "azure.aadgraphactivitylogs"
103    and azure.aadgraphactivitylogs.properties.actor_type == "User"
104    and user_agent.original is not null
105| eval Esql.ua_lower = to_lower(user_agent.original)
106| where Esql.ua_lower like "*fasthttp*"
107    or Esql.ua_lower like "*aiohttp*"
108    or Esql.ua_lower like "*hound*"
109    or Esql.ua_lower like "*aadinternals*"
110    or Esql.ua_lower like "*go-http-client*"
111    or Esql.ua_lower like "python*"
112    or Esql.ua_lower like "*curl/*"
113    or Esql.ua_lower like "*okhttp*"
114    or Esql.ua_lower like "*axios*"
115    or Esql.ua_lower like "*node-fetch*"
116    or Esql.ua_lower like "*go-resty*"
117    or Esql.ua_lower like "*bav2ropc*"
118    or Esql.ua_lower like "*undici*"
119| keep
120    _id,
121    _version,
122    _index,
123    @timestamp,
124    user.id,
125    source.ip,
126    source.as.organization.name,
127    user_agent.original,
128    azure.aadgraphactivitylogs.properties.app_id,
129    azure.aadgraphactivitylogs.properties.api_version,
130    url.path,
131    http.response.status_code,
132    azure.tenant_id,
133    Esql.ua_lower
134'''
135
136
137[[rule.threat]]
138framework = "MITRE ATT&CK"
139[[rule.threat.technique]]
140id = "T1069"
141name = "Permission Groups Discovery"
142reference = "https://attack.mitre.org/techniques/T1069/"
143[[rule.threat.technique.subtechnique]]
144id = "T1069.003"
145name = "Cloud Groups"
146reference = "https://attack.mitre.org/techniques/T1069/003/"
147
148
149[[rule.threat.technique]]
150id = "T1087"
151name = "Account Discovery"
152reference = "https://attack.mitre.org/techniques/T1087/"
153[[rule.threat.technique.subtechnique]]
154id = "T1087.004"
155name = "Cloud Account"
156reference = "https://attack.mitre.org/techniques/T1087/004/"
157
158
159[[rule.threat.technique]]
160id = "T1526"
161name = "Cloud Service Discovery"
162reference = "https://attack.mitre.org/techniques/T1526/"
163
164
165[rule.threat.tactic]
166id = "TA0007"
167name = "Discovery"
168reference = "https://attack.mitre.org/tactics/TA0007/"
169
170[rule.investigation_fields]
171field_names = [
172    "user.id",
173    "source.ip",
174    "source.as.organization.name",
175    "user_agent.original",
176    "azure.aadgraphactivitylogs.properties.app_id",
177    "azure.aadgraphactivitylogs.properties.api_version",
178    "url.path",
179    "http.response.status_code",
180    "azure.tenant_id",
181]

Triage and analysis

Investigating Azure AD Graph Access with Suspicious User-Agent

Azure AD Graph (graph.windows.net) is the legacy directory REST API that Microsoft has been retiring for years. Legitimate first-party traffic against it is dominated by a small set of recognisable user agents (Microsoft.OData.Client, Microsoft Azure Graph Client Library, Microsoft ADO.NET Data Services, the Azure portal with a Chrome user agent, and an empty-UA tail from first-party AppIds). Traffic identifying as Python, aiohttp, curl, Go-http-client, or any of the *hound enumeration families is almost always either a developer prototype or adversary tooling. This rule flags any such request even at a single event, because tooling samples for AAD Graph are inherently low-volume in normal tenants.

Possible investigation steps

  • Confirm the matching user agent.
    • user_agent.original (e.g., aiohttp, AADInternals, curl, bav2ropc).
  • Identify the caller and the calling client.
    • user.id for the caller, azure.aadgraphactivitylogs.properties.app_id for the OAuth client.
  • Review which directory object types were touched.
    • url.path (e.g., /users, /policies, /servicePrincipals).
  • Check the success / failure pattern.
    • http.response.status_code. Many 4xx responses suggest permission probing.
  • Cross-reference with the API version.
    • azure.aadgraphactivitylogs.properties.api_version. A non-Microsoft UA combined with 1.6-internal or 1.61-internal is a stronger signal of offensive tooling.
  • Pivot to sign-in logs (logs-azure.signinlogs-*) for the same user / source IP to understand how the token was obtained.
  • Confirm the activity is not attributable to authorized testing (red team engagement, penetration test, internal tooling validation) before treating as malicious.

Response and remediation

  • 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}.
  • Check for device registrations created by the user during or around the burst window and remove rogue devices.
    • GET /v1.0/users/{id}/registeredDevices and GET /v1.0/users/{id}/ownedDevices, then DELETE /v1.0/devices/{deviceObjectId}.
    • Do this BEFORE session revocation: device-bound PRTs survive revokeSignInSessions.
  • 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.
  • Apply Conditional Access targeting the AAD Graph audience for the affected user population.

References

Related rules

to-top