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.
Hunt rule
▶
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
Research rule
▶
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
Analyst rule
▶
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