Created
May 5, 2026 19:23
-
-
Save pduggusa/4913816e3b10678b8b1c5d0a18258f42 to your computer and use it in GitHub Desktop.
Device-Code Vishing Detection — Huntress Scope · DugganUSA LLC · 2026-05-05
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ───────────────────────────────────────────────────────────────────────────── | |
| // Device-Code Vishing Detection — Huntress Scope | |
| // DugganUSA LLC · Patrick Duggan · 2026-05-05 | |
| // | |
| // This is the attack chain Microsoft published on May 3, 2026 — the same | |
| // vish-chain we'd warned Medtronic about on March 16. Attacker calls victim, | |
| // walks them through microsoft.com/devicelogin, victim enters the attacker's | |
| // code, attacker walks out with a refresh token for the victim's tenant. | |
| // | |
| // Three queries: | |
| // 1. Core detection on SigninLogs (Sentinel) | |
| // 2. Post-auth blast-radius (chained second stage) | |
| // 3. Defender XDR Advanced Hunting variant (no Sentinel needed) | |
| // | |
| // Tunable knobs: | |
| // - highRiskCountries — per-tenant; some have legit ops in PRC/RU | |
| // - baselineWindow — 30d default; bump to 60–90 for sparse-signing users | |
| // - suspicionScore threshold — 40 catches soft signals; 80 only obvious | |
| // - App allowlist — Azure CLI, kubectl, VS Code, Teams Rooms legitimately | |
| // use deviceCode. Add AppId in (...) exclusion list per-tenant. | |
| // | |
| // What this catches in the OpenClaw / vish-chain shape: | |
| // - Most environments have <0.1% deviceCode auths — anomaly fires on the | |
| // protocol alone for users who've never done that flow before | |
| // - Post-auth: attacker grants OAuth consent to a new app, creates inbox | |
| // rules, exfils mail. Query 2 chains those follow-on actions inside a | |
| // 15-min window of the auth event | |
| // | |
| // 95% epistemic ceiling — false-positive rate on any single signal is real. | |
| // The cross-correlation (auth + new-IP + post-auth high-velocity within 15 | |
| // min) is the campaign-instance signature. Operator can rotate IP and | |
| // country; they can't rotate the protocol pattern. | |
| // ───────────────────────────────────────────────────────────────────────────── | |
| // ═════════════════════════════════════════════════════════════════════════════ | |
| // QUERY 1 — Core detection (Sentinel, SigninLogs) | |
| // ═════════════════════════════════════════════════════════════════════════════ | |
| let lookback = 1d; | |
| let baselineWindow = 30d; | |
| let highRiskCountries = dynamic(["RU","CN","KP","IR","BY","SY","CU"]); | |
| // 1a. All successful device-code authentications in the window | |
| let deviceCodeAuths = | |
| SigninLogs | |
| | where TimeGenerated > ago(lookback) | |
| | where AuthenticationProtocol =~ "deviceCode" | |
| or AuthenticationDetails has "deviceCode" | |
| or AuthenticationRequirementPolicies has "deviceCode" | |
| | where ResultType == 0 // successful only | |
| | extend | |
| UPN = tolower(UserPrincipalName), | |
| Country = tostring(LocationDetails.countryOrRegion), | |
| City = tostring(LocationDetails.city), | |
| DeviceOS = tostring(DeviceDetail.operatingSystem), | |
| DeviceBrowser= tostring(DeviceDetail.browser), | |
| TrustType = tostring(DeviceDetail.trustType), | |
| IsCompliant = tobool(DeviceDetail.isCompliant), | |
| IsManaged = tobool(DeviceDetail.isManaged), | |
| AppId = AppId, | |
| AppName = AppDisplayName; | |
| // 1b. 30-day baseline per user — what countries / apps they normally use | |
| let baseline = | |
| SigninLogs | |
| | where TimeGenerated between (ago(baselineWindow) .. ago(lookback)) | |
| | where ResultType == 0 | |
| | extend UPN = tolower(UserPrincipalName), | |
| Country = tostring(LocationDetails.countryOrRegion) | |
| | summarize | |
| normalCountries = make_set(Country, 50), | |
| normalApps = make_set(AppDisplayName, 100), | |
| priorSignIns = count() | |
| by UPN; | |
| // 1c. Score anomalies against baseline | |
| deviceCodeAuths | |
| | join kind=leftouter baseline on UPN | |
| | extend | |
| isCountryAnomaly = Country != "" and not(set_has_element(normalCountries, Country)), | |
| isHighRiskCountry = Country in (highRiskCountries), | |
| isUnmanagedDevice = TrustType !in ("Workplace","AzureAd","ServerAd") or IsManaged != true, | |
| isNonCompliant = IsCompliant != true, | |
| isNewUser = isnull(priorSignIns) or priorSignIns < 5 | |
| | extend suspicionScore = | |
| toint(isCountryAnomaly) * 30 + | |
| toint(isHighRiskCountry) * 50 + | |
| toint(isUnmanagedDevice) * 25 + | |
| toint(isNonCompliant) * 15 + | |
| toint(isNewUser) * 20 | |
| | where suspicionScore >= 40 | |
| | project TimeGenerated, UPN, AppName, AppId, Country, City, IPAddress, | |
| DeviceOS, DeviceBrowser, TrustType, IsManaged, IsCompliant, | |
| suspicionScore, normalCountries, priorSignIns | |
| | order by suspicionScore desc, TimeGenerated desc | |
| // ═════════════════════════════════════════════════════════════════════════════ | |
| // QUERY 2 — Post-auth blast-radius (chained second stage) | |
| // | |
| // The auth itself may not look terrible. The damage is what the token gets | |
| // used for in the 15 minutes after. | |
| // ═════════════════════════════════════════════════════════════════════════════ | |
| let lookback = 1d; | |
| let postAuthWindow = 15m; | |
| let deviceCodeWindow = | |
| SigninLogs | |
| | where TimeGenerated > ago(lookback) | |
| | where AuthenticationProtocol =~ "deviceCode" | |
| | where ResultType == 0 | |
| | project AuthTime = TimeGenerated, | |
| UPN = tolower(UserPrincipalName), | |
| AuthIP = IPAddress, | |
| AuthCountry = tostring(LocationDetails.countryOrRegion); | |
| let suspectFollowOns = | |
| union | |
| (OfficeActivity | |
| | where TimeGenerated > ago(lookback) | |
| | where Operation in ( | |
| "New-InboxRule","Set-InboxRule", | |
| "Set-Mailbox","Add-MailboxPermission", | |
| "New-TransportRule","Add-MailboxFolderPermission", | |
| "Set-MailboxAutoReplyConfiguration", | |
| "FileDownloaded","FileSyncDownloadedFull" | |
| ) | |
| | extend UPN = tolower(UserId), Op = Operation, | |
| Action = "exchange-rule-or-bulk-download"), | |
| (AuditLogs | |
| | where TimeGenerated > ago(lookback) | |
| | where OperationName in ( | |
| "Consent to application", | |
| "Add app role assignment to service principal", | |
| "Add service principal","Update application" | |
| ) | |
| | extend UPN = tolower(InitiatedBy.user.userPrincipalName), | |
| Op = OperationName, Action = "oauth-consent-or-sp-create"), | |
| (CloudAppEvents | |
| | where TimeGenerated > ago(lookback) | |
| | where ActionType in ( | |
| "MailItemsAccessed","FileSyncDownloaded", | |
| "FileDownloaded","SearchQueryInitiatedExchange" | |
| ) | |
| | extend UPN = tolower(AccountObjectId), | |
| Op = ActionType, Action = "mail-or-file-bulk-access"); | |
| deviceCodeWindow | |
| | join kind=inner suspectFollowOns on UPN | |
| | where TimeGenerated between (AuthTime .. (AuthTime + postAuthWindow)) | |
| | project AuthTime, FollowOnTime = TimeGenerated, UPN, AuthIP, AuthCountry, | |
| Op, Action | |
| | order by AuthTime desc, FollowOnTime asc | |
| // ═════════════════════════════════════════════════════════════════════════════ | |
| // QUERY 3 — Defender XDR Advanced Hunting variant (no Sentinel needed) | |
| // ═════════════════════════════════════════════════════════════════════════════ | |
| let lookback = 1d; | |
| AADSignInEventsBeta | |
| | where Timestamp > ago(lookback) | |
| | where AuthenticationProtocol =~ "deviceCode" | |
| | where ErrorCode == 0 | |
| | join kind=leftouter ( | |
| IdentityLogonEvents | |
| | where Timestamp > ago(lookback) | |
| | summarize riskEventCount = count(), | |
| riskTypes = make_set(LogonType, 20) | |
| by AccountUpn = tolower(AccountUpn) | |
| ) on $left.AccountUpn == $right.AccountUpn | |
| | extend | |
| Country = tostring(parse_json(Location).Country), | |
| City = tostring(parse_json(Location).City), | |
| isHighRisk = Country in ("RU","CN","KP","IR","BY","SY") | |
| | where isHighRisk or DeviceTrustType !in ("ManagedAndCompliant","Compliant","Managed") | |
| | project Timestamp, AccountUpn, Application, Country, City, IPAddress, | |
| DeviceTrustType, riskEventCount, riskTypes | |
| | order by Timestamp desc |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment