Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save pduggusa/4913816e3b10678b8b1c5d0a18258f42 to your computer and use it in GitHub Desktop.

Select an option

Save pduggusa/4913816e3b10678b8b1c5d0a18258f42 to your computer and use it in GitHub Desktop.
Device-Code Vishing Detection — Huntress Scope · DugganUSA LLC · 2026-05-05
// ─────────────────────────────────────────────────────────────────────────────
// 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