Attack Chokepoints 1 invariant stage
Each stage is an invariant condition the attacker must satisfy, regardless of tool, variant, or threat actor. Detection at any stage breaks the chain.
1 Multi-Category Graph API Burst ▶
- Kit has successfully obtained session tokens (Tier 1 relay) or device-code-grant tokens
- Graph Activity Logs must be enabled and ingested (not enabled by default in all tenants)
- Operator automation must have access tokens with broad pre-consented scopes (RoleManagement.ReadWrite.Directory, MailboxSettings.ReadWrite, etc.)
- Microsoft Graph Activity Logs (MicrosoftGraphActivityLogs Sentinel table)
- Entra ID Sign-in Logs (for ASN correlation with Tier 2 residential proxy)
Variations 1 variant tracked
Tools and methods that exploit this chokepoint. The list grows. The chokepoint doesn't change.
Tycoon 2FA Operator Console - 5-Category Recon Automation 2023-Q3 Active ▶
# Role Discovery
GET https://graph.microsoft.com/beta/me/transitiveRoleAssignments?$select=roleDefinitionId
GET https://graph.microsoft.com/v1.0/me/memberOf/microsoft.graph.directoryRole
GET https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments?$filter=principalId eq '<id>'
# Cross-Tenant Recon
POST https://graph.microsoft.com/beta/tenantRelationships/getResourceTenants
# Mailbox Recon
GET https://graph.microsoft.com/v1.0/me/mailboxSettings
# Contact Harvesting
GET https://graph.microsoft.com/v1.0/me/contactFolders/contacts?$top=1000
# Org & Licensing
GET https://graph.microsoft.com/v1.0/subscribedSkus
GET https://graph.microsoft.com/v1.0/organization
GET https://graph.microsoft.com/beta/me/appRoleAssignedResources?$top=999&$count=true
- Graph Activity Logs: 4+ endpoint categories from single UPN within 60s
- Graph Activity Logs: $top=999 / $count=true structured query parameters
- Graph Activity Logs: empty c_DeviceId on all requests
- Graph Activity Logs: /beta/ API disproportionate to /v1.0/
- Entra ID Sign-in Logs: residential ASN sign-in 10-20 min after cloud-VPS sign-in same UPN
Detection Strategy
Rules organized by the chokepoint stage they detect. Each stage has one or more rules at different maturity levels.
Log all Graph API requests matching any of the 5 recon categories from Graph ...
Research
▶
requestUri contains_any [transitiveRoleAssignments, directoryRole, roleManagement/directory/roleAssignments, tenantRelationships, mailboxSettings, contactFolders/contacts, subscribedSkus, /organization, appRoleAssignedResources]. No volume/timing filter. Run for one week to identify legitimate admin tooling and build exclusion list.
title: Microsoft Graph API Request to AiTM Post-Compromise Recon Endpoint (Baseline)
id: e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b
status: experimental
description: >
Baseline visibility rule: logs all Microsoft Graph Activity Log requests matching
any endpoint in the five Tycoon 2FA post-compromise reconnaissance categories
(role discovery, cross-tenant, mailbox, contact harvest, org/licensing). High FP
rate from IT admin tooling, compliance platforms, and Microsoft first-party portal
navigation — run for one week to identify legitimate admin tooling and build
exclusion lists before promoting to hunt tier.
REQUIRES Microsoft Graph Activity Logs (MicrosoftGraphActivityLogs) to be enabled
and ingested -- not available by default in all tenants.
references:
- https://www.elastic.co/security-labs/tycoon-2fa-aitm-detection-engineering
author: '@iimp0ster'
date: 2026-05-27
tags:
- attack.discovery
- attack.t1087.004
- attack.t1069.003
- attack.t1526
- detection.maturity.research
logsource:
product: azure
service: MicrosoftGraphActivityLogs
detection:
selection_role_discovery:
RequestUri|contains:
- 'transitiveRoleAssignments'
- 'microsoft.graph.directoryRole'
- 'roleManagement/directory/roleAssignments'
selection_cross_tenant:
RequestUri|contains:
- 'tenantRelationships'
selection_mailbox:
RequestUri|contains:
- 'mailboxSettings'
selection_contacts:
RequestUri|contains:
- 'contactFolders'
selection_org_licensing:
RequestUri|contains:
- 'subscribedSkus'
- '/organization'
- 'appRoleAssignedResources'
condition: >
selection_role_discovery or selection_cross_tenant or selection_mailbox
or selection_contacts or selection_org_licensing
falsepositives:
- IT admin tooling performing role audits (compliance, PAM platforms)
- Microsoft 365 admin portal navigation
- Third-party compliance and governance tools (AvePoint, Varonis, Stealthbits)
- Internal Graph SDK tooling by identity engineering teams
level: informational
4+ distinct Graph API endpoint categories from a single UPN within 60 seconds...
Hunt
▶
Tag each request into one of 5 categories based on requestUri pattern. Alert when same UPN has requests spanning 4+ distinct categories within 60s AND c_DeviceId is empty. Exclude known IT admin service accounts and compliance tool service principals. ES|QL or KQL recommended over Sigma for the category-count aggregation. NOTE: c_sid is NOT the user object ID - pivot on source IP + appId against sign-in logs to identify UPN. Do NOT filter c_sid == user_object_id.
title: Post-AiTM Graph API Multi-Category Recon Burst — 4+ Categories in 60 Seconds
id: f2a3b4c5-d6e7-4f8a-9b0c-1d2e3f4a5b6c
status: experimental
description: >
Detects a single UPN issuing requests spanning 4 or more distinct Graph API endpoint
categories (role, cross-tenant, mailbox, contacts, org/licensing) within 60 seconds
with an empty c_DeviceId. This is the Tycoon 2FA operator console automated
reconnaissance pattern: 20-30+ calls across all 5 categories within 30-60 seconds,
impossible at human portal-navigation speed. Empty c_DeviceId confirms an unmanaged
device (operator's residential proxy), not an IT admin managed device.
NOTE: This rule requires category-tagging aggregation that cannot be expressed in
standard Sigma. Implement via ES|QL or KQL with STATS aggregation.
CRITICAL: c_sid in Graph Activity Logs is NOT the user object ID -- pivot on source
IP + appId against sign-in logs to identify UPN. DO NOT filter c_sid == user_object_id.
REQUIRES Graph Activity Logs enabled and ingested in the tenant.
KQL implementation (Sentinel):
MicrosoftGraphActivityLogs
| extend category = case(
RequestUri has_any ("transitiveRoleAssignments","directoryRole","roleManagement/directory/roleAssignments"), "role",
RequestUri has "tenantRelationships", "cross-tenant",
RequestUri has "mailboxSettings", "mailbox",
RequestUri has "contactFolders", "contacts",
RequestUri has_any ("subscribedSkus","/organization","appRoleAssignedResources"), "org-licensing",
"other")
| where category != "other" and isempty(DeviceId)
| summarize distinct_categories = dcount(category) by UserId, bin(TimeGenerated, 60s)
| where distinct_categories >= 4
references:
- https://www.elastic.co/security-labs/tycoon-2fa-aitm-detection-engineering
author: '@iimp0ster'
date: 2026-05-27
tags:
- attack.discovery
- attack.t1087.004
- attack.t1069.003
- attack.t1526
- detection.maturity.hunt
logsource:
product: azure
service: MicrosoftGraphActivityLogs
detection:
# Category-count aggregation cannot be expressed in standard Sigma.
# Use the KQL implementation in the description for production deployment.
# This stub fires on any recon endpoint with empty DeviceId as a partial signal.
selection:
DeviceId: ''
RequestUri|contains:
- 'transitiveRoleAssignments'
- 'tenantRelationships'
- 'mailboxSettings'
- 'contactFolders'
- 'subscribedSkus'
- 'appRoleAssignedResources'
filter_managed_devices:
DeviceId|exists: true
filter_known_tooling:
# PENDING: Add known compliance/admin tool service principal app IDs
# AppId|contains:
# - '<varonis-app-id>'
# - '<avepoint-app-id>'
AppId: '' # placeholder — remove and populate before production
condition: selection and not filter_managed_devices and not filter_known_tooling
falsepositives:
- Broad-scope compliance tools querying multiple categories in automated scans
- IT admin portal performing multi-step identity audit (document and exclude by appId)
- Identity governance tools with scheduled access reviews
level: high
4+ Graph API categories / 60s with empty c_DeviceId correlated with preceding...
Analyst
▶
Graph recon burst (4+ categories / 60s / empty c_DeviceId) AND same UPN has a preceding sign-in with Node.js UA on OfficeHome/Auth Broker within 20 minutes. Correlated with AiTM relay signal = high-confidence post- compromise recon confirmation.
title: Post-AiTM Graph API Recon Burst Correlated with Preceding AiTM Session Theft
id: a3b4c5d6-e7f8-4a9b-0c1d-2e3f4a5b6c7d
status: experimental
description: >
Detects the Tycoon 2FA two-stage post-compromise kill chain: a 4+ category Graph API
recon burst (20-30+ calls / 60 seconds / empty c_DeviceId) correlated with a
preceding AiTM relay sign-in (Node.js UA on OfficeHome/Auth Broker) from the same UPN
within 20 minutes. The two-stage pattern confirms session theft (Tier 1 cloud-VPS
relay) followed by operator console triage (Tier 2 residential proxy). This is the
highest-confidence post-AiTM detection signal: the category breadth is invariant to
kit rotation because it reflects the operator's need to triage identity value.
NOTE: Requires correlation across Graph Activity Logs and Entra ID sign-in logs.
Category-count aggregation cannot be expressed as standard Sigma.
Implement as KQL multi-table join or SIEM correlation rule with two legs.
CRITICAL: c_sid is NOT the user object ID -- identify UPN via source IP + appId pivot
against sign-in logs; do NOT filter c_sid == user_object_id (returns empty results).
REQUIRES Graph Activity Logs enabled and ingested.
KQL join (Sentinel):
let AiTMSignIn = SigninLogs
| where ResultType == "0"
and UserAgent has_any ("node","axios","undici","node-fetch")
and AppDisplayName has_any ("OfficeHome","Microsoft Authentication Broker")
| project TimeGenerated, UserPrincipalName, AiTMTime = TimeGenerated;
MicrosoftGraphActivityLogs
| extend category = case(
RequestUri has_any ("transitiveRoleAssignments","directoryRole"), "role",
RequestUri has "tenantRelationships", "cross-tenant",
RequestUri has "mailboxSettings", "mailbox",
RequestUri has "contactFolders", "contacts",
RequestUri has_any ("subscribedSkus","/organization","appRoleAssignedResources"), "org-licensing",
"other")
| where category != "other" and isempty(DeviceId)
| summarize distinct_categories = dcount(category), first_recon = min(TimeGenerated) by UserId, bin(TimeGenerated, 60s)
| where distinct_categories >= 4
| join kind=inner AiTMSignIn on $left.UserId == $right.UserPrincipalName
| where first_recon between (AiTMTime .. AiTMTime + 20m)
references:
- https://www.elastic.co/security-labs/tycoon-2fa-aitm-detection-engineering
author: '@iimp0ster'
date: 2026-05-27
tags:
- attack.discovery
- attack.t1087.004
- attack.t1069.003
- attack.t1526
- attack.credential-access
- attack.t1539
- attack.t1078.004
- detection.maturity.analyst
logsource:
product: azure
service: MicrosoftGraphActivityLogs
detection:
# This rule requires correlation across two log tables — use the KQL join above.
# Sigma stub fires on graph burst partial signal only; full correlation requires SIEM.
selection_graph_burst:
DeviceId: ''
RequestUri|contains:
- 'transitiveRoleAssignments'
- 'tenantRelationships'
- 'mailboxSettings'
# CORRELATION: same UPN in SigninLogs with Node.js UA within 1200s preceding — KQL required
condition: selection_graph_burst
falsepositives:
- Admin tooling + coincident Node.js automation from the same UPN (document per tenant)
level: high
OSINT Pivots
N/A - Graph API recon bursts are internal Microsoft log signals