How Far Has It Got?
| # | Stage | ATT&CK | Question | Where to look |
|---|---|---|---|---|
| 1 | Lure | T1566.002 T1566.003 | Who received the device-code lure, when, and through which channel? | EmailUrlInfo with a devicelogin or deviceauth URL; UrlClickEvents; Teams MessageEvents from external threads; failed deviceCode sign-ins (the code expired or the CA block caught it); user reports |
| 2 | Code entered | T1528 | Which users completed a device-code sign-in? | SigninLogs where AuthenticationProtocol == "deviceCode" and ResultType == "0" (a string) |
| 3 | Token use | T1550.001 | Did the attacker use the tokens from another IP, and under which family apps? | Interactive and non-interactive sign-ins for the same user from a different IP and ASN within 48 h, grouped by AppId (the refresh token is FOCI-scoped: Office, Azure CLI, Broker, VS Code, Teams) |
| 4 | Device registration | T1098.005 | Did they register a device to get a Primary Refresh Token? | Entra audit log: Register device, Add device, Add registered owner to device; deviceCode sign-ins with the Authentication Broker AppId; primaryRefreshToken sign-ins from an unknown device id |
| 5 | Consent | T1528 T1098.001 | Did they consent an OAuth app, or add credentials to one? | Entra audit log: Consent to application, Add delegated permission grant, Add service principal, Add service principal credentials; Cazadora |
| 6 | Data access | T1114.002 T1213.002 | What mail and files did they read? | Unified Audit Log (MailItemsAccessed, FileDownloaded, SearchQueryInitiated), by ClientIP and ClientAppId; MicrosoftGraphActivityLogs joined on the sign-in's UniqueTokenIdentifier |
| 7 | Outbound | T1534 | Did they phish others from the account? | EmailEvents with the account as sender; Send, SendAs and Teams MessageSent audit events by ClientIP; message trace |
| 8 | Spread | T1528 | Did other users complete the same flow, or sign in from the attacker's range? | Successful deviceCode sign-ins across the tenant; any sign-in from the attacker's ASN (not only the IP) |
| 9 | Still active | T1550.001 | Is any token still in use after containment? | Refused refreshes from the attacker's ASN (50057 user disabled, 50173 token revoked) prove containment; any success, Graph call or mailbox read after the revocation time plus one hour is a live channel |
If Device Code abuse is suspected
MicrosoftGraphActivityLogs exists only in a Sentinel workspace with the diagnostic setting on. Pull sign-ins (interactive and non-interactive), audit logs, the UAL and the Graph activity for the window, hash the files, and record the hashes in the case. Why: the stage 9 verdict and any notification decision are made on these rows weeks later, when the live tenant no longer has them. Cmdlets are in the Defender tab's "Preserve" step.Get-MgAuditLogSignIn -Filter "userPrincipalName eq '<upn>' and createdDateTime ge <start>Z" -All | Export-Csv signins.csv
Get-MgBetaAuditLogSignIn -Filter "userPrincipalName eq '<upn>' and signInEventTypes/any(t: t eq 'nonInteractiveUser')" -All | Export-Csv noninteractive.csv
Get-MgAuditLogDirectoryAudit -Filter "activityDateTime ge <start>Z" -All | Export-Csv auditlogs.csv
Search-UnifiedAuditLog -StartDate <start> -EndDate <end> -UserIds <upn> -SessionCommand ReturnLargeSet -ResultSize 5000 | Export-Csv ual.csv
Update-MgUser -UserId <upn> -AccountEnabled:$false
Revoke-MgUserSignInSession -UserId <upn>
Confirm-MgRiskyUserCompromised -UserIds @((Get-MgUser -UserId <upn>).Id)
Get-MgUserAuthenticationMethod -UserId <upn> | Export-Csv mfa-<upn>.csv
Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod -UserId <upn> -MicrosoftAuthenticatorAuthenticationMethodId <id>
Remove-MgUserAuthenticationPhoneMethod -UserId <upn> -PhoneAuthenticationMethodId <id>
New-MgUserAuthenticationTemporaryAccessPassMethod -UserId <upn> -BodyParameter @{ lifetimeInMinutes = 60; isUsableOnce = $true }
registrationDateTime, trustType, operatingSystem and the OS build are the attribution evidence — then disable it. The PRT renews about every four hours, so the disable is not instant; the stage 4 PRT hunt confirms when it bit. Disable only the devices from the window; disabling all of them locks out the user's own machines.Get-MgDevice -DeviceId <device object id> -Property Id,DeviceId,DisplayName,RegistrationDateTime,TrustType,OperatingSystem,OperatingSystemVersion,IsCompliant,IsManaged | Export-Csv device.csv
Update-MgDevice -DeviceId <device object id> -AccountEnabled:$false
Get-MgUserOauth2PermissionGrant lists only this user's own consents; an admin-consented grant on the same app does not appear there, so query the grant by client id too, and check the app's own application permissions.Get-MgOauth2PermissionGrant -Filter "clientId eq '<app service principal id>'" -All
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId <app service principal id> -All
Update-MgServicePrincipal -ServicePrincipalId <app service principal id> -AccountEnabled:$false
Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId <grant id>
ForwardingSmtpAddress and ForwardingAddress. Put the mailbox on litigation hold first (needs Exchange Online Plan 2 or the Archiving add-on), export the rules, then remove them — a purged rule is destroyed evidence, and its shape drives the tenant-wide hunt.Set-Mailbox -Identity <upn> -LitigationHoldEnabled $true -LitigationHoldDuration 365
Get-InboxRule -Mailbox <upn> -IncludeHidden | Export-Clixml inboxrules-<upn>.xml
Files.Read.All, the attacker likely ran a bulk download sweep within minutes. Cross-reference the FileDownloaded, FileSyncDownloadedFull and SearchQueryInitiatedSharePoint audit events with the access window, by ClientIP and ClientAppId; the stage 6 Graph-activity hunt lists the exact calls per stolen token.IsThrottled), so a throttled row means more than you can see. Any internal recipient who interacted gets steps 1–6, not a later triage.Add member to role, Add service principal, Add service principal credentials, Add delegated permission grant and Update application – Certificates and secrets management for the window; the stage 5 hunt in each tab runs this. These are the persistence mechanisms that survive everything else — anything found here goes back through step 8.Hunt & Act by Platform
"0", not 0.Hunt
Stage 1 · LureLure delivery and clicks (mail)
let lure = dynamic(["microsoft.com/devicelogin", "oauth2/deviceauth", "/oauth2/v2.0/devicecode"]);
EmailUrlInfo
| where Timestamp > ago(30d) and Url has_any (lure)
| join kind=inner (EmailEvents
| project NetworkMessageId, RecipientEmailAddress, SenderFromAddress, SenderMailFromDomain, Subject, DeliveryAction)
on NetworkMessageId
| join kind=leftouter (UrlClickEvents
| where Timestamp > ago(30d)
| project NetworkMessageId, ClickTime = Timestamp, ClickedBy = AccountUpn, ActionType)
on NetworkMessageId
| project Timestamp, RecipientEmailAddress, SenderFromAddress, SenderMailFromDomain, Subject, Url, DeliveryAction, ClickTime, ClickedBy, ActionType
| order by Timestamp asc
Stage 1 · LureLure delivery in Teams external chats
MessageEvents
| where Timestamp > ago(30d) and IsExternalThread == true
| join kind=inner (MessageUrlInfo
| where Url has_any ("devicelogin", "deviceauth", "devicecode"))
on TeamsMessageId
| project Timestamp, SenderEmailAddress, SenderDisplayName, RecipientDetails, ThreadType, Url
| order by Timestamp asc
Stage 1 · LureDevice-code attempts that did not complete (reach of the lure)
SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol =~ "deviceCode" and ResultType != "0"
| summarize Attempts = count(), First = min(TimeGenerated), Last = max(TimeGenerated),
Codes = make_set(ResultType), Reasons = make_set(ResultDescription, 5)
by UserPrincipalName, AppDisplayName, AppId
| order by First asc
// 53003 = blocked by Conditional Access (the step 7 policy working); 50058 = code entered without a
// completed sign-in; 70016/70020 = the client polled past the code's lifetime.
Stage 2 · Code enteredSuccessful device-code sign-ins
SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol =~ "deviceCode" and ResultType == "0"
| project TimeGenerated, UserPrincipalName, AppDisplayName, AppId, ResourceDisplayName,
IPAddress, AutonomousSystemNumber, UserAgent, SessionId, UniqueTokenIdentifier, CorrelationId
| order by TimeGenerated asc
Stage 3 · Token useToken use from another IP after the device-code sign-in, including FOCI app switches
let foci = dynamic(["d3590ed6-52b3-4102-aeff-aad2292ab01c", // Microsoft Office
"04b07795-8ddb-461a-bbee-02f9e1bf7b46", // Azure CLI
"1950a258-227b-4e31-a9cf-717495945fc2", // Azure PowerShell
"29d9ed98-a469-4536-ade2-f981bc1d605e", // Microsoft Authentication Broker
"aebc6443-996d-45c2-90f0-388ff96faa56", // Visual Studio Code
"1fec8e78-bce4-4aaf-ab1b-5451cc387264"]); // Microsoft Teams
let dc = SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol =~ "deviceCode" and ResultType == "0"
| project UserPrincipalName, DC_Time = TimeGenerated, DC_IP = IPAddress, DC_App = AppId, DC_Session = SessionId;
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d) and ResultType == "0"
| join kind=inner dc on UserPrincipalName
| where TimeGenerated between (DC_Time .. DC_Time + 48h) and IPAddress != DC_IP
| extend Family = iff(AppId in (foci), "FOCI", "other"), SameSession = SessionId == DC_Session
| summarize FirstUse = min(TimeGenerated), Uses = count(), Resources = make_set(ResourceDisplayName, 10),
TokenTypes = make_set(IncomingTokenType), SameSession = max(SameSession)
by UserPrincipalName, DC_IP, AttackerIP = IPAddress, AttackerASN = AutonomousSystemNumber, AppId, AppDisplayName, Family
| order by UserPrincipalName asc, FirstUse asc
Stage 4 · Device registrationDevice objects registered by the victim account in the window
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ("Register device", "Add device", "Add registered owner to device", "Add registered users to device")
| extend Actor = coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)),
ActorIP = tostring(InitiatedBy.user.ipAddress),
Device = tostring(TargetResources[0].displayName), DeviceObjectId = tostring(TargetResources[0].id),
Changes = tostring(TargetResources[0].modifiedProperties)
| where Actor =~ "<upn>" or Changes has "<upn>"
| project TimeGenerated, OperationName, Result, Actor, ActorIP, Device, DeviceObjectId, Changes
| order by TimeGenerated asc
// Changes carries the OS build and trust type; a Windows 10.0.19045 build with no MDM and a hostname that
// does not match your naming standard is the phishing-kit default.
Stage 4 · Device registrationDevice code requested directly as the Authentication Broker (PRT path)
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)
| where AppId == "29d9ed98-a469-4536-ade2-f981bc1d605e"
| where AuthenticationProtocol =~ "deviceCode" or ResourceDisplayName == "Device Registration Service"
| project TimeGenerated, Type, UserPrincipalName, AuthenticationProtocol, ResourceDisplayName, ResultType,
IPAddress, AutonomousSystemNumber, IncomingTokenType, UserAgent, SessionId
| order by TimeGenerated asc
Stage 4 · Device registrationPRT use from a device registered in the window
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d) and UserPrincipalName =~ "<upn>"
| where IncomingTokenType == "primaryRefreshToken"
| extend DeviceId = tostring(DeviceDetail.deviceId), DeviceName = tostring(DeviceDetail.displayName),
OS = tostring(DeviceDetail.operatingSystem), TrustType = tostring(DeviceDetail.trustType),
IsCompliant = tostring(DeviceDetail.isCompliant), IsManaged = tostring(DeviceDetail.isManaged)
| summarize First = min(TimeGenerated), Last = max(TimeGenerated), SignIns = count(), Apps = make_set(AppDisplayName, 10)
by DeviceId, DeviceName, OS, TrustType, IsCompliant, IsManaged, IPAddress, AutonomousSystemNumber
| order by First asc
Stage 5 · ConsentConsent, permission grants and service-principal changes by the victim account
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ("Consent to application", "Add delegated permission grant", "Add app role assignment grant to user",
"Add service principal", "Add service principal credentials", "Add member to role",
"Add eligible member to role")
or OperationName has "Certificates and secrets management"
| extend Actor = coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)),
ActorIP = tostring(InitiatedBy.user.ipAddress),
Target = tostring(TargetResources[0].displayName), TargetId = tostring(TargetResources[0].id),
Changes = tostring(TargetResources[0].modifiedProperties)
| where Actor =~ "<upn>" or Changes has "<upn>"
| project TimeGenerated, OperationName, Result, Actor, ActorIP, Target, TargetId, Changes
| order by TimeGenerated asc
// ConsentAction.Permissions inside Changes lists the scopes; offline_access plus Mail.Read or Files.Read.All
// from an unverified publisher is the pattern.
Stage 6 · Data accessMail, file, search and Teams reads by the victim account, by client and source IP
OfficeActivity
| where TimeGenerated > ago(30d) and UserId =~ "<upn>"
| where Operation in ("MailItemsAccessed", "FileDownloaded", "FileAccessed", "FileSyncDownloadedFull",
"SearchQueryInitiatedExchange", "SearchQueryInitiatedSharePoint",
"MessagesListed", "ChatRetrieved", "MessageRead")
| extend Props = tostring(OperationProperties),
ClientApp = coalesce(ClientAppId, AppId, ClientInfoString)
| extend AccessType = extract(@'"Name":"MailAccessType","Value":"(\w+)"', 1, Props),
Throttled = Props contains '"Name":"IsThrottled","Value":"True"'
| summarize Events = count(), First = min(TimeGenerated), Last = max(TimeGenerated),
Folders = make_set(Folders, 10)
by OfficeWorkload, Operation, ClientIP, ClientApp, AccessType, Throttled
| order by First asc
// ClientIP on Exchange rows can be "[ip]:port"; Client_IPAddress is the bare form.
Stage 6 · Data accessGraph API calls made with the stolen tokens
let tokens = union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d) and UserPrincipalName =~ "<upn>"
| where IPAddress in ("<attacker ip>") or AutonomousSystemNumber in (<attacker asn>)
| distinct UniqueTokenIdentifier;
MicrosoftGraphActivityLogs
| where TimeGenerated > ago(30d)
| where SignInActivityId in (tokens) or IPAddress in ("<attacker ip>")
| extend Path = tostring(parse_url(RequestUri).Path)
| summarize Calls = count(), First = min(TimeGenerated), Last = max(TimeGenerated),
Status = make_set(ResponseStatusCode), Scopes = make_set(Scopes, 5)
by RequestMethod, Path, AppId, IPAddress, UserAgent
| order by First asc
Stage 7 · OutboundMail sent from the account (Defender for Office 365)
EmailEvents
| where Timestamp > ago(30d)
| where SenderFromAddress =~ "<upn>" or SenderMailFromAddress =~ "<upn>"
| where EmailDirection in ("Outbound", "Intra-org")
| summarize Messages = count(), Recipients = dcount(RecipientEmailAddress),
Sample = make_set(RecipientEmailAddress, 20), Subjects = make_set(Subject, 10)
by bin(Timestamp, 1h), EmailDirection
| order by Timestamp asc
Stage 7 · OutboundSend, SendAs and Teams messages by client and source IP (Unified Audit Log)
OfficeActivity
| where TimeGenerated > ago(30d) and UserId =~ "<upn>"
| where Operation in ("Send", "SendAs", "SendOnBehalf", "MessageSent")
| summarize Events = count(), First = min(TimeGenerated), Last = max(TimeGenerated), Threads = dcount(ChatThreadId)
by OfficeWorkload, Operation, ClientIP, ClientInfoString, CommunicationType
| order by First asc
Stage 8 · SpreadOther users: successful device-code sign-ins across the tenant
SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol =~ "deviceCode" and ResultType == "0"
| summarize First = min(TimeGenerated), Last = max(TimeGenerated), SignIns = count(),
Apps = make_set(AppDisplayName), ASNs = make_set(AutonomousSystemNumber), Agents = make_set(UserAgent, 5)
by UserPrincipalName
| order by First asc
Stage 8 · SpreadAnyone else seen from the attacker's ASN
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)
| where AutonomousSystemNumber in (<attacker asn>) or IPAddress in ("<attacker ip>")
| summarize Users = make_set(UserPrincipalName), First = min(TimeGenerated), Last = max(TimeGenerated),
Apps = make_set(AppDisplayName, 10), Outcomes = make_set(ResultType), TokenTypes = make_set(IncomingTokenType)
by AutonomousSystemNumber, IPAddress
| order by First asc
Stage 9 · Still activeRefused and successful token use after the revocation time
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > datetime(<revocation time, UTC>)
| where AutonomousSystemNumber in (<attacker asn>) or IPAddress in ("<attacker ip>")
or UserPrincipalName in~ ("<upn1>", "<upn2>")
| where IncomingTokenType in ("refreshToken", "primaryRefreshToken") or ResultType == "0"
| extend Verdict = case(ResultType == "0", "LIVE: success after revocation",
ResultType in ("50057", "50173", "70008", "50133", "53003"), "contained: refused",
"other failure")
| summarize Attempts = count(), Last = max(TimeGenerated), Codes = make_set(ResultType),
TokenTypes = make_set(IncomingTokenType), Apps = make_set(AppDisplayName, 10)
by Verdict, UserPrincipalName, IPAddress, AutonomousSystemNumber
| order by Verdict asc, Last desc
// 50057 user disabled; 50173 refresh token revoked; 70008 refresh token expired; 50133 session invalid
// after password change; 53003 blocked by Conditional Access.
Stage 9 · Still activeAccess-token use that never reaches the sign-in logs
union
(MicrosoftGraphActivityLogs
| where TimeGenerated > datetime(<revocation time, UTC>)
| where IPAddress in ("<attacker ip>") or UserId in ("<user object id>")
| project TimeGenerated, Source = "Graph", Who = UserId,
What = strcat(RequestMethod, " ", tostring(parse_url(RequestUri).Path)),
Status = tostring(ResponseStatusCode), IPAddress),
(OfficeActivity
| where TimeGenerated > datetime(<revocation time, UTC>)
| where UserId in~ ("<upn1>", "<upn2>") or ClientIP has_any ("<attacker ip>")
| project TimeGenerated, Source = OfficeWorkload, Who = UserId, What = Operation,
Status = ResultStatus, IPAddress = ClientIP)
| order by TimeGenerated asc
Act
Preserve first, then broad, then surgical once the scope is known. Cut every channel in one pass, and verify it held (step 8) before re-enabling anyone.
T+0 · PreserveExport the logs for the window before anything is revoked
# Entra sign-ins (interactive) and audit, Graph v1.0; non-interactive sign-ins need the beta cmdlet
Get-MgAuditLogSignIn -Filter "userPrincipalName eq '<upn>' and createdDateTime ge <start ISO 8601>Z" -All |
Export-Csv signins-<upn>.csv -NoTypeInformation
Get-MgBetaAuditLogSignIn -Filter "userPrincipalName eq '<upn>' and signInEventTypes/any(t: t eq 'nonInteractiveUser') and createdDateTime ge <start>Z" -All |
Export-Csv noninteractive-<upn>.csv -NoTypeInformation
Get-MgAuditLogDirectoryAudit -Filter "activityDateTime ge <start>Z" -All | Export-Csv auditlogs.csv -NoTypeInformation
# Unified Audit Log (Exchange Online PowerShell); 5,000 rows per page, ReturnLargeSet pages to 50,000
Search-UnifiedAuditLog -StartDate <start> -EndDate <end> -UserIds <upn1>,<upn2> -SessionCommand ReturnLargeSet -ResultSize 5000 |
Export-Csv ual-window.csv -NoTypeInformation
# Sentinel tables for the window, including MicrosoftGraphActivityLogs (workspace only)
az monitor log-analytics query -w <workspace id> -o json --analytics-query "
union SigninLogs, AADNonInteractiveUserSignInLogs, AuditLogs, OfficeActivity, MicrosoftGraphActivityLogs
| where TimeGenerated between (datetime(<start>) .. datetime(<end>))" > sentinel-window.json
Get-FileHash signins-<upn>.csv, noninteractive-<upn>.csv, auditlogs.csv, ual-window.csv, sentinel-window.json -Algorithm SHA256
Step 7 · Scope the exception groupWho legitimately uses device code, before you flip the block
SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol =~ "deviceCode" and ResultType == "0"
| summarize SignIns = count(), Users = make_set(UserPrincipalName), Last = max(TimeGenerated),
ASNs = make_set(AutonomousSystemNumber)
by AppDisplayName, AppId
| order by SignIns desc
// Expected breakage without an exception: Azure CLI on headless hosts and CI runners, kubelogin, Azure Arc
// onboarding, Teams Rooms and Surface Hub, Android phones signing in to Teams and Outlook, printers and
// scanners that mail to M365.
Step 7 · Go broadBlock Device Code flow for the whole tenant
Console: Entra admin center › Conditional Access › New policy › Conditions › Authentication flows › Device code flow; Grant › Block access. Exclude the exception group from the previous step and your break-glass accounts. During an incident the state is enabled, not report-only.
New-MgIdentityConditionalAccessPolicy -BodyParameter @{
displayName = "IR - block device code flow"
state = "enabled"
conditions = @{
users = @{ includeUsers = @("All"); excludeGroups = @("<exception group id>")
excludeUsers = @("<break-glass account id>") }
applications = @{ includeApplications = @("All") }
clientAppTypes = @("all")
authenticationFlows = @{ transferMethods = "deviceCodeFlow" }
}
grantControls = @{ operator = "OR"; builtInControls = @("block") }
}
Steps 1–2 · ContainDisable, then revoke, then mark compromised, for every account seen from the attacker
Console: Defender portal › Identities › <user> › Disable user / Revoke session / Mark user as compromised. These actions run in Entra and need an Entra role; the Entra SOC Identity Responder built-in role (introduced July 2026) carries exactly these three, without User Administrator.
"<upn1>","<upn2>" | ForEach-Object {
$u = Get-MgUser -UserId $_ -Property Id
Update-MgUser -UserId $u.Id -AccountEnabled:$false # next refresh fails: 50057
Revoke-MgUserSignInSession -UserId $u.Id # refresh tokens and session cookies: 50173
Confirm-MgRiskyUserCompromised -UserIds @($u.Id) # risk = high; risk-based CA and Identity Protection react
}
Step 3 · ContainExport the MFA methods, remove what the user did not enrol, issue a TAP
Get-MgUserAuthenticationMethod -UserId <upn> |
Select-Object Id, @{n='Type';e={$_.AdditionalProperties['@odata.type']}}, @{n='Detail';e={$_.AdditionalProperties | ConvertTo-Json -Compress}} |
Export-Csv mfa-<upn>.csv -NoTypeInformation
Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod -UserId <upn> -MicrosoftAuthenticatorAuthenticationMethodId <id>
Remove-MgUserAuthenticationPhoneMethod -UserId <upn> -PhoneAuthenticationMethodId <id>
Remove-MgUserAuthenticationSoftwareOathMethod -UserId <upn> -SoftwareOathAuthenticationMethodId <id>
Remove-MgUserAuthenticationFido2Method -UserId <upn> -Fido2AuthenticationMethodId <id>
Remove-MgUserAuthenticationEmailMethod -UserId <upn> -EmailAuthenticationMethodId <id>
New-MgUserAuthenticationTemporaryAccessPassMethod -UserId <upn> -BodyParameter @{ lifetimeInMinutes = 60; isUsableOnce = $true }
Steps 4–5 · ContainExport and disable the attacker's device, disable the app, remove the grants, in the same pass
Disable only the devices registered in the access window; disabling all of them locks out the user's own machines. Get-MgUserOauth2PermissionGrant lists only this user's own consents; an admin-consented (AllPrincipals) grant on the same app does not show there, so query the grant by client id as well. The PRT on the attacker's device renews about every four hours, so the disable takes effect at the next renewal; the stage 4 PRT hunt confirms it.
Get-MgDevice -DeviceId <device object id> -Property Id,DeviceId,DisplayName,RegistrationDateTime,TrustType,OperatingSystem,OperatingSystemVersion,IsCompliant,IsManaged,EnrollmentType,ApproximateLastSignInDateTime |
Export-Csv device-<device object id>.csv -NoTypeInformation
Update-MgDevice -DeviceId <device object id> -AccountEnabled:$false # -DeviceId takes the object id
Get-MgUserOauth2PermissionGrant -UserId <user id> -All # this user's own consents
Get-MgOauth2PermissionGrant -Filter "clientId eq '<app service principal id>'" -All # every grant on the app, both consent types
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId <app service principal id> -All # application permissions the app holds
Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId <app service principal id> -All # users and groups assigned to it
Update-MgServicePrincipal -ServicePrincipalId <app service principal id> -AccountEnabled:$false
Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId <grant id>
Step 6 · PreserveLitigation hold before the rules and forwarding are purged
Exchange Online PowerShell. Litigation hold needs an Exchange Online Plan 2 licence or the Exchange Online Archiving add-on on the mailbox.
Set-Mailbox -Identity <upn> -LitigationHoldEnabled $true -LitigationHoldDuration 365
Get-InboxRule -Mailbox <upn> -IncludeHidden | Export-Clixml inboxrules-<upn>.xml
Get-Mailbox -Identity <upn> | Select-Object ForwardingSmtpAddress, ForwardingAddress, DeliverToMailboxAndForward | Export-Csv forwarding-<upn>.csv
Get-InboxRule -Mailbox <upn> -IncludeHidden | Where-Object { $_.Name -notin @("<rules the user confirms>") } | Remove-InboxRule -Confirm:$false
Set-Mailbox -Identity <upn> -ForwardingSmtpAddress $null -ForwardingAddress $null -DeliverToMailboxAndForward $false
Once scoped · SurgicalIsolate the victim's endpoint only if a file was delivered
Live Response: getfile "C:\Users\<user>\AppData\Local\Microsoft\Edge\User Data\Default\History" and the Chrome equivalent; open the SQLite file offline.
#repo="3pi_microsoft_entra_id") and the Microsoft 365 connector. The Vendor.properties.* names mirror the Entra log JSON; confirm each on one event before you groupBy on it. Falcon endpoint telemetry has no view of Entra sign-ins. Windows: the repo's retention; Entra itself keeps 30 days on P1/P2 and 7 days free.Hunt
Stage 1 · LureDevice-code attempts that did not complete (reach of the lure)
#repo="3pi_microsoft_entra_id"
| Vendor.properties.authenticationProtocol=deviceCode
| Vendor.properties.status.errorCode!=0
| groupBy([Vendor.properties.userPrincipalName, Vendor.properties.appDisplayName, Vendor.properties.status.errorCode], function=[count(), min(@timestamp), max(@timestamp)])
// 53003 = blocked by Conditional Access, 50058 = code entered without a completed sign-in.
Stage 2 · Code enteredSuccessful device-code sign-ins
#repo="3pi_microsoft_entra_id"
| Vendor.properties.authenticationProtocol=deviceCode
| Vendor.properties.status.errorCode=0
| groupBy([Vendor.properties.userPrincipalName, Vendor.properties.appDisplayName, Vendor.properties.appId, Vendor.properties.ipAddress, Vendor.properties.userAgent], function=[count(), min(@timestamp)])
Stage 3 · Token useToken use from another IP after the device-code sign-in, including FOCI app switches
#repo="3pi_microsoft_entra_id"
| Vendor.properties.userPrincipalName="<upn>"
| Vendor.properties.status.errorCode=0
| groupBy([Vendor.properties.ipAddress, Vendor.properties.autonomousSystemNumber, Vendor.properties.appId, Vendor.properties.appDisplayName, Vendor.properties.incomingTokenType], function=[count(), min(@timestamp), max(@timestamp)])
| sort(min(@timestamp))
// The device-code event carries the victim's IP; the next IP, on a different ASN, is the attacker's.
Stage 4 · Device registrationDevice registered by the victim account, and the broker-client device-code variant
#repo="3pi_microsoft_entra_id"
| Vendor.properties.userPrincipalName="<upn>" OR Vendor.properties.targetResources[0].userPrincipalName="<upn>"
| Vendor.operationName=/^(Register device|Add device|Add registered owner to device)$/
// The broker-client variant, all users:
#repo="3pi_microsoft_entra_id"
| Vendor.properties.authenticationProtocol=deviceCode
| Vendor.properties.appId="29d9ed98-a469-4536-ade2-f981bc1d605e"
// Then PRT sign-ins from the new device id: Vendor.properties.incomingTokenType=primaryRefreshToken
// grouped by Vendor.properties.deviceDetail.deviceId. CrowdStrike ships correlation rule templates for
// this chain in Next-Gen SIEM.
Stage 5 · ConsentConsent, permission grants and service-principal changes by the victim account
#repo="3pi_microsoft_entra_id"
| Vendor.operationName=/^(Consent to application|Add delegated permission grant|Add service principal|Add service principal credentials|Add member to role)$/
| "<upn>"
// Free text on the UPN catches both the actor and the target; read the app and the permissions from the
// parsed targetResources.
Stage 6 · Data accessMail and file access by the victim account, by source IP
// Microsoft 365 audit data lands in the Microsoft 365 connector's own repo; field names depend on that
// parser. Search it as free text, then read ClientIP, ClientAppId and MailAccessType per event:
"<upn>" "MailItemsAccessed"
"<upn>" "FileDownloaded"
"<upn>" "SearchQueryInitiatedExchange"
Stage 7 · OutboundMail and Teams messages sent from the account
// Microsoft 365 connector repo, free text:
"<upn>" "Send"
"<upn>" "SendAs"
"<upn>" "MessageSent"
// Group on the client IP field once you have confirmed its name; a send from the attacker ASN is the proof.
Stage 8 · SpreadOther users: device-code sign-ins across the tenant, and anyone from the attacker ASN
#repo="3pi_microsoft_entra_id"
| Vendor.properties.authenticationProtocol=deviceCode
| Vendor.properties.status.errorCode=0
| groupBy([Vendor.properties.userPrincipalName], function=[count(), min(@timestamp), collect([Vendor.properties.appDisplayName])])
// Anyone else from the attacker's ASN, 30 days:
#repo="3pi_microsoft_entra_id"
| Vendor.properties.autonomousSystemNumber=<attacker asn>
| groupBy([Vendor.properties.ipAddress], function=[collect([Vendor.properties.userPrincipalName]), min(@timestamp)])
Stage 9 · Still activeRefused and successful token use after the revocation time
#repo="3pi_microsoft_entra_id"
| Vendor.properties.autonomousSystemNumber=<attacker asn> OR Vendor.properties.userPrincipalName="<upn>"
| Vendor.properties.incomingTokenType=/^(refreshToken|primaryRefreshToken)$/ OR Vendor.properties.status.errorCode=0
| groupBy([Vendor.properties.userPrincipalName, Vendor.properties.ipAddress, Vendor.properties.status.errorCode, Vendor.properties.incomingTokenType], function=[count(), max(@timestamp)])
// Set the time range to start at the revocation time. errorCode 0 = live; 50057/50173 = refused.
Act
Preserve first, then broad, then surgical once the scope is known. Cut every channel in one pass, and verify it held (step 8) before re-enabling anyone.
T+0 · PreserveExport first
Steps 1–7 · ContainRevocation happens in Entra
Run the containment steps on this page: disable and revoke, reset MFA, disable the devices from stage 4, disable the app and remove the consents from stage 5, block Device Code flow tenant-wide.
Steps 1–7 · ContainRun it for every affected account with Fusion SOAR
Falcon Fusion SOAR with the Microsoft Entra ID SOAR actions from the CrowdStrike Marketplace can disable the user, revoke the sessions, reset the password and reset authentication methods. Build and test the workflow before you need it, and check that it disables before it revokes.
Timeline · CollectCollect the victim's browser history (RTR)
Open the History files offline (SQLite).
get "C:\Users\<user>\AppData\Local\Microsoft\Edge\User Data\Default\History"
get "C:\Users\<user>\AppData\Local\Google\Chrome\User Data\Default\History"
| columns *. Deep Visibility has no view of Entra sign-ins. Windows: the Data Lake's retention; Entra itself keeps 30 days on P1/P2 and 7 days free.Hunt
Stage 1 · LureDevice-code attempts that did not complete (reach of the lure)
dataSource.name = '<entra_signin_source>' AND * contains 'deviceCode' AND NOT * contains '"errorCode":0'
| group Events = count() by <user_field>, <error_code_field>
// Discover <error_code_field> first: ... | limit 1 | columns *
Stage 2 · Code enteredSuccessful device-code sign-ins
// Discover the field names once:
dataSource.name = '<entra_signin_source>' AND * contains 'deviceCode'
| limit 1 | columns *
// Then, with the user, app and IP fields you found:
dataSource.name = '<entra_signin_source>' AND * contains 'deviceCode' AND * contains '"errorCode":0'
| group Events = count() by <user_field>, <app_id_field>, <ip_field>
Stage 3 · Token useToken use from another IP after the device-code sign-in, including FOCI app switches
dataSource.name = '<entra_signin_source>' AND * contains '<upn>'
| group Events = count() by <ip_field>, <asn_field>, <app_id_field>, <incoming_token_type_field>
// An IP that is not the one on the device-code event, starting right after it on another ASN, is the
// attacker's; the AppIds under it are the family switches.
Stage 4 · Device registrationDevice registered by the victim account, and the broker-client device-code variant
dataSource.name = '<entra_audit_source>' AND (* contains 'Register device' OR * contains 'Add device' OR * contains 'Add registered owner to device') AND * contains '<upn>'
| columns *
// The broker variant, all users:
dataSource.name = '<entra_signin_source>' AND * contains 'deviceCode' AND * contains '29d9ed98-a469-4536-ade2-f981bc1d605e'
| group Events = count() by <user_field>, <ip_field>
Stage 5 · ConsentConsent, permission grants and service-principal changes by the victim account
dataSource.name = '<entra_audit_source>' AND * contains '<upn>'
AND (* contains 'Consent to application' OR * contains 'Add delegated permission grant' OR * contains 'Add service principal' OR * contains 'Add member to role')
| columns *
Stage 6 · Data accessMail and file access by the victim account, by source IP
dataSource.name = '<m365_source>' AND * contains '<upn>'
AND (* contains 'MailItemsAccessed' OR * contains 'FileDownloaded' OR * contains 'FileAccessed' OR * contains 'SearchQueryInitiatedExchange')
| group Events = count() by <operation_field>, <client_ip_field>, <client_app_id_field>
Stage 7 · OutboundMail and Teams messages sent from the account
dataSource.name = '<m365_source>' AND * contains '<upn>'
AND (* contains '"Operation":"Send"' OR * contains 'SendAs' OR * contains 'MessageSent')
| group Events = count() by <operation_field>, <client_ip_field>
Stage 8 · SpreadOther users: device-code sign-ins across the tenant, and anyone from the attacker ASN
dataSource.name = '<entra_signin_source>' AND * contains 'deviceCode' AND * contains '"errorCode":0'
| group Events = count() by <user_field>
// Then anyone from the attacker's ASN:
dataSource.name = '<entra_signin_source>' AND <asn_field> = <attacker asn>
| group Events = count() by <user_field>, <ip_field>
Stage 9 · Still activeRefused and successful token use after the revocation time
dataSource.name = '<entra_signin_source>' AND (<asn_field> = <attacker asn> OR * contains '<upn>')
| group Events = count() by <user_field>, <ip_field>, <error_code_field>, <incoming_token_type_field>
// Time range: from the revocation time. errorCode 0 = live; 50057/50173 = refused.
Act
Preserve first, then broad, then surgical once the scope is known. Cut every channel in one pass, and verify it held (step 8) before re-enabling anyone.
T+0 · PreserveExport first
Steps 1–7 · ContainRevocation happens in Entra
Run the containment steps on this page. If you run Hyperautomation with an Entra ID integration, check that it offers disable-user and revoke-sessions actions, in that order, before you rely on it.
Timeline · CollectCollect the victim's browser history
Fetch Files from the victim's endpoint: the Edge or Chrome History file (paths as in the CrowdStrike tab). Open it offline.
Hunt
Stage 1 · LureThe page that sent the user to the device-code prompt
Windows.Applications.Chrome.History (globs cover Chrome, Edge, Brave, Vivaldi, Opera)
userRegex = <user>
URLRegex = .
// Sort on visit_time around the devicelogin visit from stage 2; the from_visit column links the referrer.
// The artifact is deprecated in favour of Generic.Forensic.SQLiteHunter, which covers the same files.
Stage 2 · Code enteredThe devicelogin visit (time the code was entered)
Windows.Applications.Chrome.History
userRegex = <user>
URLRegex = (?i)devicelogin|oauth2/deviceauth
// Collect the History files too (Windows.KapeFiles.Targets, target WebBrowsers) for chain of custody.
Act
Preserve first, then broad, then surgical once the scope is known. Cut every channel in one pass, and verify it held (step 8) before re-enabling anyone.
Steps 1–7 · ContainNothing to contain on the endpoint
Timeline · CollectCollect the victim's browser history
Run on the victim's host. Windows.Applications.Chrome.History is deprecated in favour of Generic.Forensic.SQLiteHunter; both read the same History files.
Windows.Applications.Chrome.History
userRegex = <user>
URLRegex = (?i)devicelogin|oauth2/deviceauth
Windows.KapeFiles.Targets
WebBrowsers = Y
azure:monitor:aad, Entra logs via Event Hub) and the Splunk Add-on for Microsoft Office 365 (o365:management:activity, and the message-trace input, o365:graph:messagetrace for Worldwide tenants). Replace index=<entra> / index=<o365> with your indexes. Windows: your index retention; Entra itself keeps 30 days on P1/P2 and 7 days free, the Unified Audit Log 180 days on Audit Standard.Hunt
Stage 1 · LureLure mail by message trace, and device-code attempts that did not complete
index=<o365> sourcetype IN (o365:graph:messagetrace, o365:reporting:messagetrace)
subject="*<lure subject fragment>*" OR src_user="<lure sender>"
| stats dc(recipient) as recipients values(recipient) as recipient_list min(_time) as first by src_user subject
| convert ctime(first)
Stage 1 · LureDevice-code attempts that did not complete (reach of the lure)
index=<entra> sourcetype=azure:monitor:aad category=SignInLogs
"properties.authenticationProtocol"=deviceCode "properties.status.errorCode"!=0
| stats count min(_time) as first values("properties.status.errorCode") as codes by user properties.appDisplayName
| convert ctime(first)
Stage 2 · Code enteredSuccessful device-code sign-ins
index=<entra> sourcetype=azure:monitor:aad category=SignInLogs
"properties.authenticationProtocol"=deviceCode "properties.status.errorCode"=0
| stats min(_time) as first max(_time) as last values(src) as src values(properties.userAgent) as agents
by user properties.appDisplayName properties.appId
| convert ctime(first) ctime(last)
Stage 3 · Token useToken use from another IP after the device-code sign-in, including FOCI app switches
index=<entra> sourcetype=azure:monitor:aad category IN (SignInLogs, NonInteractiveUserSignInLogs)
user="<upn>" "properties.status.errorCode"=0
| eval devicecode=if('properties.authenticationProtocol'="deviceCode", 1, 0)
| stats min(_time) as first max(_time) as last max(devicecode) as devicecode_event
values(properties.appId) as app_ids values(properties.appDisplayName) as apps
values(properties.incomingTokenType) as token_types
by src properties.autonomousSystemNumber category
| sort first | convert ctime(first) ctime(last)
Stage 4 · Device registrationDevice registered by the victim account, and the broker-client device-code variant
index=<entra> sourcetype=azure:monitor:aad
operationName IN ("Register device", "Add device", "Add registered owner to device") properties.result=success
user="<upn>"
| table _time operationName user src properties.targetResources{}.displayName properties.targetResources{}.id
Stage 4 · Device registrationDevice code requested as the Authentication Broker, and PRT sign-ins from the new device
index=<entra> sourcetype=azure:monitor:aad category IN (SignInLogs, NonInteractiveUserSignInLogs)
(("properties.appId"="29d9ed98-a469-4536-ade2-f981bc1d605e" "properties.authenticationProtocol"=deviceCode)
OR (user="<upn>" "properties.incomingTokenType"=primaryRefreshToken))
| stats min(_time) as first max(_time) as last count by user src properties.deviceDetail.deviceId
properties.deviceDetail.trustType properties.deviceDetail.isCompliant properties.deviceDetail.isManaged
| sort first | convert ctime(first) ctime(last)
Stage 5 · ConsentConsent, permission grants and service-principal changes by the victim account
index=<entra> sourcetype=azure:monitor:aad properties.result=success
operationName IN ("Consent to application", "Add delegated permission grant", "Add service principal",
"Add service principal credentials", "Add member to role")
("properties.initiatedBy.user.userPrincipalName"="<upn>" OR "properties.targetResources{}.userPrincipalName"="<upn>")
| rename properties.* as *
| eval permissions = mvindex('targetResources{}.modifiedProperties{}.newValue',
mvfind('targetResources{}.modifiedProperties{}.displayName', "ConsentAction.Permissions"))
| table _time operationName initiatedBy.user.userPrincipalName initiatedBy.user.ipAddress targetResources{}.displayName permissions
Stage 6 · Data accessMail and file access by the victim account, by client and source IP
index=<o365> sourcetype=o365:management:activity user="<upn>"
Operation IN (MailItemsAccessed, FileDownloaded, FileAccessed, SearchQueryInitiatedExchange, SearchQueryInitiatedSharePoint)
| eval client=coalesce(ClientAppId, AppId, ClientInfoString)
| stats count min(_time) as first max(_time) as last values(OperationProperties{}.Value) as props by Operation src client
| sort first | convert ctime(first) ctime(last)
Stage 7 · OutboundMail sent from the account, by message trace and by audit client
index=<o365> sourcetype IN (o365:graph:messagetrace, o365:reporting:messagetrace) src_user="<upn>"
| bin _time span=1h
| stats dc(recipient) as recipients values(subject) as subjects by _time
| sort _time
// Client and IP per send (audit):
index=<o365> sourcetype=o365:management:activity user="<upn>" Operation IN (Send, SendAs, SendOnBehalf, MessageSent)
| stats count min(_time) as first by Operation src ClientInfoString
Stage 8 · SpreadOther users: successful device-code sign-ins across the tenant
index=<entra> sourcetype=azure:monitor:aad category=SignInLogs
"properties.authenticationProtocol"=deviceCode "properties.status.errorCode"=0
| stats min(_time) as first count values(properties.appDisplayName) as apps values(properties.autonomousSystemNumber) as asns by user
| sort first | convert ctime(first)
Stage 8 · SpreadAnyone else seen from the attacker's ASN
index=<entra> sourcetype=azure:monitor:aad category IN (SignInLogs, NonInteractiveUserSignInLogs)
("properties.autonomousSystemNumber"=<attacker asn> OR src IN ("<attacker ip>"))
| stats dc(user) as users values(user) as user_list min(_time) as first values(properties.status.errorCode) as codes
by properties.autonomousSystemNumber src
| convert ctime(first)
Stage 9 · Still activeRefused and successful token use after the revocation time
index=<entra> sourcetype=azure:monitor:aad category IN (SignInLogs, NonInteractiveUserSignInLogs)
earliest="<MM/DD/YYYY:HH:MM:SS revocation time>"
("properties.autonomousSystemNumber"=<attacker asn> OR user="<upn>")
("properties.incomingTokenType" IN (refreshToken, primaryRefreshToken) OR "properties.status.errorCode"=0)
| eval verdict=case('properties.status.errorCode'=0, "LIVE", 'properties.status.errorCode' IN (50057, 50173, 70008, 50133, 53003), "refused", true(), "other")
| stats count max(_time) as last values(properties.status.errorCode) as codes values(properties.appDisplayName) as apps
by verdict user src properties.autonomousSystemNumber
| convert ctime(last)
// Access-token use that never hits sign-ins: index=<o365> user="<upn>" earliest=<revocation time> | stats count by Operation src
Act
Preserve first, then broad, then surgical once the scope is known. Cut every channel in one pass, and verify it held (step 8) before re-enabling anyone.
T+0 · PreserveExport first
Steps 1–7 · ContainHunt here, contain in Entra
With Splunk SOAR, use the MS Graph for Active Directory connector (Splunkbase 6395): it has "disable user", "disable tokens" (invalidates the user's refresh tokens) and "reset password", in that order. The older Azure AD Graph connector is dead: Microsoft retired the Azure AD Graph API in 2025 and the connector's own README says to migrate. Device and consent removal stay in Entra.
logs-azure.signinlogs-*, logs-azure.auditlogs-*) and the Microsoft 365 integration (logs-o365.audit-*). Set the time picker for KQL queries. Wazuh's Office 365 module writes the same Unified Audit Log records under data.office365.* (Operation, UserId, ClientIP). Windows: your ILM policy; Entra itself keeps 30 days on P1/P2 and 7 days free, the Unified Audit Log 180 days on Audit Standard.Hunt
Stage 1 · LureDevice-code attempts that did not complete (reach of the lure)
FROM logs-azure.signinlogs-*
| WHERE @timestamp > NOW() - 30 days
AND azure.signinlogs.properties.authentication_protocol == "deviceCode"
AND event.outcome != "success"
| STATS attempts = COUNT(*), first = MIN(@timestamp), codes = VALUES(azure.signinlogs.properties.status.error_code)
BY azure.signinlogs.properties.user_principal_name, azure.signinlogs.properties.app_display_name
| SORT first
Stage 2 · Code enteredSuccessful device-code sign-ins
FROM logs-azure.signinlogs-*
| WHERE @timestamp > NOW() - 30 days
AND azure.signinlogs.properties.authentication_protocol == "deviceCode"
AND event.outcome == "success"
| STATS first = MIN(@timestamp), ips = VALUES(source.ip), agents = VALUES(user_agent.original)
BY azure.signinlogs.properties.user_principal_name, azure.signinlogs.properties.app_display_name, azure.signinlogs.properties.app_id
| SORT first
Stage 3 · Token useToken use from another IP after the device-code sign-in, including FOCI app switches
FROM logs-azure.signinlogs-*
| WHERE @timestamp > NOW() - 30 days
AND azure.signinlogs.properties.user_principal_name == "<upn>"
AND event.outcome == "success"
| STATS first = MIN(@timestamp), last = MAX(@timestamp), uses = COUNT(*),
protocols = VALUES(azure.signinlogs.properties.authentication_protocol),
apps = VALUES(azure.signinlogs.properties.app_id),
token_types = VALUES(azure.signinlogs.properties.incoming_token_type)
BY source.ip, azure.signinlogs.properties.autonomous_system_number, azure.signinlogs.category
| SORT first
// Prebuilt rule "Entra ID OAuth Device Code Flow with Concurrent Sign-ins" (Elastic Security 8.19) alerts on
// the browser and the polling client appearing in one session with different user agents.
Stage 4 · Device registrationDevice registered by the victim account
data_stream.dataset:"azure.auditlogs"
and azure.auditlogs.operation_name:("Register device" or "Add device" or "Add registered owner to device")
and azure.auditlogs.properties.initiated_by.user.userPrincipalName:"<upn>"
// Columns: azure.auditlogs.properties.target_resources.0.display_name, azure.auditlogs.properties.target_resources.0.id, event.outcome.
// Prebuilt rules (8.19): "Suspicious Microsoft OAuth Flow via Auth Broker to DRS" (same user and session
// from several IPs, broker to Device Registration Service), "Unusual Device Registration in Entra ID",
// "Entra ID RT to PRT Transition from Same User and Device".
Stage 4 · Device registrationDevice code requested as the Authentication Broker, and PRT sign-ins from the new device
FROM logs-azure.signinlogs-*
| WHERE @timestamp > NOW() - 30 days
AND ((azure.signinlogs.properties.app_id == "29d9ed98-a469-4536-ade2-f981bc1d605e"
AND azure.signinlogs.properties.authentication_protocol == "deviceCode")
OR (azure.signinlogs.properties.user_principal_name == "<upn>"
AND azure.signinlogs.properties.incoming_token_type == "primaryRefreshToken"))
| STATS first = MIN(@timestamp), last = MAX(@timestamp), signins = COUNT(*)
BY azure.signinlogs.properties.user_principal_name, source.ip,
azure.signinlogs.properties.device_detail.device_id, azure.signinlogs.properties.device_detail.trust_type,
azure.signinlogs.properties.device_detail.is_compliant, azure.signinlogs.properties.device_detail.is_managed
| SORT first
Stage 5 · ConsentConsent, permission grants and service-principal changes by the victim account
data_stream.dataset:"azure.auditlogs"
and azure.auditlogs.operation_name:("Consent to application" or "Add delegated permission grant"
or "Add service principal" or "Add service principal credentials" or "Add member to role")
and event.outcome:"success"
and azure.auditlogs.properties.initiated_by.user.userPrincipalName:"<upn>"
// Columns: azure.auditlogs.properties.target_resources.0.display_name, azure.auditlogs.properties.target_resources.0.modified_properties, source.ip.
Stage 6 · Data accessMail and file access by the victim account, by client and source IP
FROM logs-o365.audit-*
| WHERE @timestamp > NOW() - 30 days AND user.id == "<upn>"
AND event.action IN ("MailItemsAccessed", "FileDownloaded", "FileAccessed", "SearchQueryInitiatedExchange", "SearchQueryInitiatedSharePoint")
| STATS events = COUNT(*), first = MIN(@timestamp), last = MAX(@timestamp)
BY event.action, source.ip, o365.audit.ClientAppId, o365.audit.ClientInfoString
| SORT first
// Wazuh: rule.groups:office365 and data.office365.UserId:"<upn>" and data.office365.Operation:MailItemsAccessed
Stage 7 · OutboundMail and Teams messages sent from the account, by client and source IP
FROM logs-o365.audit-*
| WHERE @timestamp > NOW() - 30 days AND user.id == "<upn>"
AND event.action IN ("Send", "SendAs", "SendOnBehalf", "MessageSent")
| STATS events = COUNT(*), first = MIN(@timestamp), last = MAX(@timestamp)
BY event.action, source.ip, o365.audit.ClientInfoString
| SORT first
Stage 8 · SpreadOther users: device-code sign-ins across the tenant, and anyone from the attacker ASN
FROM logs-azure.signinlogs-*
| WHERE @timestamp > NOW() - 30 days
AND azure.signinlogs.properties.authentication_protocol == "deviceCode"
AND event.outcome == "success"
| STATS first = MIN(@timestamp), apps = VALUES(azure.signinlogs.properties.app_display_name)
BY azure.signinlogs.properties.user_principal_name
| SORT first
// Anyone else from the attacker's ASN (KQL, both sign-in categories):
// data_stream.dataset:"azure.signinlogs" and azure.signinlogs.properties.autonomous_system_number:<attacker asn>
Stage 9 · Still activeRefused and successful token use after the revocation time
FROM logs-azure.signinlogs-*
| WHERE @timestamp > TO_DATETIME("<revocation time, ISO 8601>")
AND (azure.signinlogs.properties.autonomous_system_number == <attacker asn>
OR azure.signinlogs.properties.user_principal_name == "<upn>")
AND (azure.signinlogs.properties.incoming_token_type IN ("refreshToken", "primaryRefreshToken")
OR event.outcome == "success")
| EVAL verdict = CASE(event.outcome == "success", "LIVE",
azure.signinlogs.properties.status.error_code IN (50057, 50173, 70008, 50133, 53003), "refused",
"other")
| STATS attempts = COUNT(*), last = MAX(@timestamp), codes = VALUES(azure.signinlogs.properties.status.error_code),
apps = VALUES(azure.signinlogs.properties.app_display_name)
BY verdict, azure.signinlogs.properties.user_principal_name, source.ip, azure.signinlogs.properties.autonomous_system_number
| SORT verdict
// Access-token use that never hits sign-ins: FROM logs-o365.audit-* | WHERE user.id == "<upn>" AND @timestamp > <revocation time>
Act
Preserve first, then broad, then surgical once the scope is known. Cut every channel in one pass, and verify it held (step 8) before re-enabling anyone.
T+0 · PreserveExport first
Steps 1–7 · ContainHunt here, contain in Entra
Revoke with this page's containment steps.