Techniques
Sample rules
Microsoft Graph Multi-Category Reconnaissance Burst
- source: elastic
- technicques:
- T1087
- T1526
Description
Detects Microsoft Graph activity from delegated user tokens (public client, client_auth_method 0) where a single user session and source IP rapidly touches multiple high-value Graph paths indicative of reconnaissance. The query classifies requests into categories such as role discovery, cross-tenant relationship queries, mailbox paths, contact harvesting, and organization or licensing metadata. When three or more distinct categories appear within a short burst window, it suggests a broad enumeration playbook rather than normal application traffic.
Detection logic
from logs-azure.graphactivitylogs-* metadata _id, _version, _index
// Graph calls via delegated user tokens (any status, any method)
| where event.dataset == "azure.graphactivitylogs"
and azure.graphactivitylogs.properties.c_idtyp == "user"
and azure.graphactivitylogs.properties.client_auth_method == 0
// high-value recon endpoints by url.path
| eval Esql.is_role_enum = case(
url.path like "*roleManagement/directory*"
or url.path like "*memberOf/microsoft.graph.directoryRole*"
or url.path like "*transitiveRoleAssignments*",
true,
false
)
| eval Esql.is_cross_tenant_enum = case(
url.path like "*tenantRelationships*"
or url.path like "*getResourceTenants*",
true,
false
)
| eval Esql.is_mailbox_recon = case(
url.path like "*mailboxSettings*"
or url.path like "*mailFolders*"
or url.path like "*messages*"
or url.path like "*inbox*",
true,
false
)
| eval Esql.is_contact_harvest = case(
url.path like "*contacts*"
or url.path like "*contactFolders*",
true,
false
)
| eval Esql.is_org_recon = case(
url.path like "*subscribedSkus*"
or url.path like "*appRoleAssign*"
or (
url.path like "*/organization*"
and not url.path like "*branding*"
and not url.path like "*localizations*"
),
true,
false
)
// Combine: is this request hitting a high-value endpoint?
| eval Esql.is_high_value = case(
Esql.is_role_enum or Esql.is_cross_tenant_enum or Esql.is_mailbox_recon
or Esql.is_contact_harvest or Esql.is_org_recon,
true,
false
)
| where Esql.is_high_value == true
// Classify each hit into a recon category
| eval Esql.recon_category = case(
Esql.is_role_enum, "role_discovery",
Esql.is_cross_tenant_enum, "cross_tenant_recon",
Esql.is_mailbox_recon, "mailbox_recon",
Esql.is_contact_harvest, "contact_harvesting",
Esql.is_org_recon, "org_and_licensing_recon",
"other"
)
// Flag failed requests (recon that errored is still recon)
| eval Esql.is_failed_request = case(
http.response.status_code >= 400, true, false
)
// Aggregate per user + session + source IP
| stats
Esql.total_high_value_calls = count(*),
Esql.distinct_categories = count_distinct(Esql.recon_category),
Esql.distinct_paths = count_distinct(url.path),
Esql.failed_calls = sum(case(Esql.is_failed_request, 1, 0)),
Esql.categories = values(Esql.recon_category),
Esql.sample_paths = values(url.path),
Esql.http_methods = values(http.request.method),
Esql.status_codes = values(http.response.status_code),
Esql.first_seen = min(@timestamp),
Esql.last_seen = max(@timestamp),
Esql.user_agents = values(user_agent.original),
Esql.app_ids = values(azure.graphactivitylogs.properties.app_id)
by
azure.graphactivitylogs.properties.user_principal_object_id,
source.ip,
source.`as`.organization.name,
source.`as`.number,
azure.graphactivitylogs.properties.c_sid,
azure.tenant_id
// Threshold: 3+ distinct recon categories
| where Esql.distinct_categories >= 4 and Esql.total_high_value_calls >= 20
// Burst duration in seconds
| eval Esql.burst_duration_seconds = date_diff("seconds", Esql.first_seen, Esql.last_seen)
| where Esql.burst_duration_seconds <= 60
| keep
azure.graphactivitylogs.properties.user_principal_object_id,
azure.graphactivitylogs.properties.c_sid,
azure.tenant_id,
source.ip,
source.`as`.organization.name,
source.`as`.number,
Esql.*