62 queries featured across the weekly issues. Each one links to the original in its author's repo — run them read-only first and tune the thresholds to your estate.
Jose Sebastián Canós · Week 35 · Aug 24-31 ·
DeviceImageLoadEvents, DeviceNetworkEvents
Two related Defender for Endpoint hunts from the same author, both keyed on a shared list of domain patterns that show up in tooling and exfiltration rather than in normal traffic: Burp Collaborator (oastify.com, portswigger.net), the whatismyip family, and generated-looking hostnames under CloudFront, KeyCDN, Azure Front Door, Azure cloudapp and AWS API Gateway. The first correlates network events with image load events on the same process, so it surfaces the loaded module behind the connection and filters on FileProfile prevalence and signature state to drop the common and the signed.
let query_frequency = 1h;
let query_period = 14d;
let suspicious_domains = dynamic([
@"d\d[a-z0-9]{12}\.cloudfront\.net",
@"[\-\w]+\-[a-f0-9]{3,5}\.kxcdn\.com",
@"[\-\w]+\-[a-z0-9]{16}\.\w\d\d\.azurefd\.net",
@"[\-\w]+\.[a-z0-9]+\.cloudapp\.azure\.com",
@"[a-z0-9]{10}\.execute\-api\.[a-z]{2}\-[a-z]+\-\d\.amazonaws\.com",
@"portswigger\.net",
@"oastify\.com",
@"whatismyip\.com",
@"whatismyip\.net",
@"whatismyipaddress\.com"
]);
let excluded_urls = dynamic([
"uhf-exp-fd-gbcrdgggfbggh0g3.b02.azurefd.net",
"vs-exp-afd-prod-endpoint-e3b9a0c0h0e8d6fd.b02.azurefd.net"
]);
let excluded_company_names = dynamic([]);
let excluded_original_names = dynamic([]);
DeviceNetworkEvents
| where Timestamp > ago(query_period)
| where RemoteUrl matches regex strcat_array(suspicious_domains, "|") // and not(InitiatingProcessAccountSid in ("S-1-5-18", "S-1-5-20"))
//| where not(RemoteUrl has_any (excluded_urls))
| where not(InitiatingProcessUniqueId == 0)
| project DeviceId, DeviceName, LocalIP, ActionType, RemoteIP, RemotePort, RemoteUrl, Protocol, InitiatingProcessUniqueId
| as _AuxiliarEvents
| join kind=inner (
DeviceImageLoadEvents
| where Timestamp > ago(query_period)
| where not(InitiatingProcessUniqueId == 0) and (isnotempty(SHA1) or isnotempty(SHA256) or isnotempty(MD5)) and DeviceId in (toscalar(_AuxiliarEvents | summarize make_set(DeviceId)))
| project-away DeviceName, ActionType
) on DeviceId, InitiatingProcessUniqueId
| project-away DeviceId1, InitiatingProcessUniqueId1
| summarize
StartTime = arg_min(Timestamp, *),
EndTime = max(Timestamp),
DeviceNamesSample = array_sort_asc(make_set(DeviceName, 100)),
RemoteUrlsSample = array_sort_asc(make_set(RemoteUrl, 100))
by SHA1, SHA256, MD5
| where StartTime > ago(query_frequency)
| invoke FileProfile("SHA1", 1000)
| where not(GlobalPrevalence > 10000)
| where not(GlobalPrevalence > 1000 and GlobalFirstSeen < ago(query_period))
| where not(GlobalPrevalence > 1000 and GlobalFirstSeen < ago(query_frequency) and SignatureState == "SignedValid")
| where not(GlobalPrevalence > 500 and InitiatingProcessVersionInfoCompanyName in (excluded_company_names) and InitiatingProcessVersionInfoOriginalFileName in (excluded_original_names))
| where not(GlobalFirstSeen < ago(5 * 365d))
| project
StartTime,
EndTime,
DeviceNamesSample,
RemoteUrlsSample,
Timestamp = StartTime,
DeviceId,
DeviceName,
LocalIP,
ActionType,
RemoteIP,
RemotePort,
RemoteUrl,
Protocol,
FileName,
FolderPath,
SHA1,
SHA256,
MD5,
FileSize,
GlobalPrevalence,
GlobalFirstSeen,
GlobalLastSeen,
SignatureState,
InitiatingProcessAccountName,
InitiatingProcessAccountSid,
InitiatingProcessAccountUpn,
InitiatingProcessAccountObjectId,
InitiatingProcessFileName,
InitiatingProcessFolderPath,
InitiatingProcessCommandLine,
InitiatingProcessCreationTime,
IsInitiatingProcessRemoteSession,
InitiatingProcessParentFileName,
InitiatingProcessVersionInfoCompanyName,
InitiatingProcessVersionInfoProductName,
InitiatingProcessVersionInfoOriginalFileName,
InitiatingProcessVersionInfoInternalFileName,
InitiatingProcessVersionInfoFileDescription,
InitiatingProcessVersionInfoProductVersion,
InitiatingProcessUniqueId,
ReportId
Jose Sebastián Canós · Week 35 · Aug 24-31 ·
DeviceNetworkEvents
The companion query drops the image load join and groups by the initiating process's version metadata instead, which makes it the lighter of the two and the better starting point if you want to see which uncommon binaries in your estate are reaching those domains at all. It was published on 24 August, a few days ahead of the one above.
let query_frequency = 1h;
let query_period = 14d;
let suspicious_domains = dynamic([
@"d\d[a-z0-9]{12}\.cloudfront\.net",
@"[\-\w]+\-[a-f0-9]{3,5}\.kxcdn\.com",
@"[\-\w]+\-[a-z0-9]{16}\.\w\d\d\.azurefd\.net",
@"[\-\w]+\.[a-z0-9]+\.cloudapp\.azure\.com",
@"[a-z0-9]{10}\.execute\-api\.[a-z]{2}\-[a-z]+\-\d\.amazonaws\.com",
@"portswigger\.net",
@"oastify\.com",
@"whatismyip\.com",
@"whatismyip\.net",
@"whatismyipaddress\.com"
]);
let excluded_company_names = dynamic([]);
let excluded_original_names = dynamic([]);
DeviceNetworkEvents
| where Timestamp > ago(query_period)
| where RemoteUrl matches regex strcat_array(suspicious_domains, "|") // and not(InitiatingProcessAccountSid in ("S-1-5-18", "S-1-5-20"))
| where isnotempty(InitiatingProcessFileName)
| summarize
StartTime = arg_min(Timestamp, *),
EndTime = max(Timestamp),
DeviceNamesSample = array_sort_asc(make_set(DeviceName, 100)),
RemoteUrlsSample = array_sort_asc(make_set(RemoteUrl, 100))
by InitiatingProcessVersionInfoCompanyName, InitiatingProcessVersionInfoProductName, InitiatingProcessVersionInfoOriginalFileName, InitiatingProcessVersionInfoInternalFileName, InitiatingProcessVersionInfoFileDescription
| where StartTime > ago(query_frequency)
| invoke FileProfile("InitiatingProcessSHA1", 1000)
| where not(GlobalPrevalence > 10000)
| where not(GlobalPrevalence > 1000 and GlobalFirstSeen < ago(query_frequency) and SignatureState == "SignedValid")
| where not(GlobalPrevalence > 500 and InitiatingProcessVersionInfoCompanyName in (excluded_company_names) and InitiatingProcessVersionInfoOriginalFileName in (excluded_original_names))
| project
StartTime,
EndTime,
DeviceNamesSample,
RemoteUrlsSample,
Timestamp = StartTime,
DeviceId,
DeviceName,
LocalIP,
ActionType,
RemoteIP,
RemotePort,
RemoteUrl,
Protocol,
InitiatingProcessAccountName,
InitiatingProcessAccountSid,
InitiatingProcessAccountUpn,
InitiatingProcessAccountObjectId,
InitiatingProcessSHA1,
InitiatingProcessSHA256,
InitiatingProcessMD5,
InitiatingProcessFileName,
InitiatingProcessFolderPath,
InitiatingProcessCommandLine,
InitiatingProcessCreationTime,
IsInitiatingProcessRemoteSession,
InitiatingProcessParentFileName,
InitiatingProcessVersionInfoCompanyName,
InitiatingProcessVersionInfoProductName,
InitiatingProcessVersionInfoOriginalFileName,
InitiatingProcessVersionInfoInternalFileName,
InitiatingProcessVersionInfoFileDescription,
InitiatingProcessVersionInfoProductVersion,
GlobalPrevalence,
GlobalFirstSeen,
GlobalLastSeen,
SignatureState,
ReportId
Sergio Albea · Week 34 · Aug 17-24
Hunts Defender XDR telemetry for the macOS Screen Sharing attack path behind CVE-2026-65400, covering privileged Screen Sharing file transfer helpers, SSH session persistence, LaunchDaemon writes, process masquerading, Packet Filter changes and XMRig artefacts — useful for answering whether any of it was already happening before you patched.
let Days = 30d;
let KnownSHA256 = "84006055916e267f7c2f9324f1848563e589e4526a296d4e9e9ce8e2112d357c";
union
( // 1. Initial access / execution: Root Screen Sharing activity, SSH persistence, masquerading and PF modification.
DeviceInfo| where OSPlatform has 'MacOs' | join kind=inner DeviceProcessEvents on DeviceId
| where Timestamp > ago(Days)
| where
FileName =~ "SSFileCopyReceiver" or ProcessCommandLine has "sshd-session -i -R" or ProcessCommandLine has "exec -a com.apple.airportd"
or ProcessCommandLine has "pfctl" or ProcessCommandLine has "com.xmr.miner.plist" or ProcessCommandLine has "sysmond"
| extend HuntingSignal = case(
FileName =~ "SSFileCopyReceiver", "Screen Sharing file transfer", ProcessCommandLine has "sshd-session -i -R",
"SSH remote session activity", ProcessCommandLine has "exec -a com.apple.airportd", "Process masquerading",
ProcessCommandLine has "pfctl", "Packet Filter modification", ProcessCommandLine has "com.xmr.miner.plist",
"LaunchDaemon persistence", ProcessCommandLine has "sysmond", "Hidden miner execution", "Suspicious process activity"
)
| project Timestamp,DeviceName, ActionType, HuntingSignal, FileName, FolderPath, SHA256, ProcessCommandLine, InitiatingProcessFileName
),(// 2. File / persistence activity: SSH keys, hidden files, privileged paths and LaunchDaemon persistence
DeviceInfo| where OSPlatform has 'MacOs' | join kind=inner DeviceFileEvents on DeviceId
| where Timestamp > ago(Days)
| where SHA256 == KnownSHA256
or FolderPath has "/private/var/root/.config" or FolderPath has "/Library/LaunchDaemons" or FolderPath has "/private/var/root/.ssh"
| extend HuntingSignal = case(
SHA256 == KnownSHA256, "Known XMRig SHA256",
FolderPath has "/private/var/root/.config", "Hidden root file activity",
FolderPath has "/Library/LaunchDaemons", "LaunchDaemon activity",
FolderPath has "/private/var/root/.ssh", "Root SSH activity", "Suspicious file activity"
)
| project Timestamp, DeviceName, ActionType, HuntingSignal, FileName, FolderPath, SHA256,
ProcessCommandLine = InitiatingProcessCommandLine,InitiatingProcessFileName
),
(// 3. Network activity: Observed mining pool plus SSH and Screen Sharing traffic.Ports 22/5900 are context only, not malicious by themselves.
DeviceInfo| where OSPlatform has 'MacOs' | join kind=inner DeviceNetworkEvents on DeviceId
| where Timestamp > ago(Days)
| where RemoteUrl =~ "auto.c3pool.org" or RemotePort in (22, 5900)
| extend HuntingSignal = case(
RemoteUrl =~ "auto.c3pool.org", "Observed mining pool",
RemotePort == 5900, "Screen Sharing / VNC traffic",
RemotePort == 22, "SSH traffic","Suspicious network activity"
)
| project Timestamp, DeviceName, ActionType, HuntingSignal, FileName = InitiatingProcessFileName, FolderPath = "", SHA256 = InitiatingProcessSHA256,
ProcessCommandLine = InitiatingProcessCommandLine, InitiatingProcessFileName
)
Benjamin Zulliger · Week 34 · Aug 17-24 ·
DeviceFileEvents, DeviceProcessEvents
Scores SSH port forwarding and tunnelling against a 30-day per-device baseline, weighting listener scope and persistent-shell flags, and pairs it with unauthorised sshd_config writes and GatewayPorts or PermitTunnel appearing on a command line.
let Lookback = 7d;
let BaselineWin = 30d;
// Set to true to filter out noisy local development tunnels on loopback and focus on higher-risk activity
let HighFidelityOnly = true;
let FwdRegex = @"(^|\s)-[LDR] ?(\d{1,5}(:|\s|$)|\*:|0\.0\.0\.0:|localhost:|\[)";
let SshTunnelEvents = (StartTime:datetime, EndTime:datetime) {
DeviceProcessEvents
| where Timestamp between (StartTime .. EndTime)
// Ignore sshd here, as -D and -R are standard daemon flags there
| where FileName in~ ("ssh", "ssh.exe", "plink.exe", "putty.exe")
| where ProcessCommandLine matches regex FwdRegex
| extend TunnelFlag = extract(@"(^|\s)-([LDR]) ?(\d|\*:|0\.0\.0\.0:|localhost:|\[)", 2, ProcessCommandLine)
| extend ForwardSpecs = extract_all(@"(?:^|\s)-[LDR] ?(\S+)", ProcessCommandLine)
| extend AllSpecs = strcat_array(ForwardSpecs, " ")
| extend SshTarget = extract(@"\s([\w\.\-]+@[\w\.\-]+)", 1, ProcessCommandLine)
| extend CmdPattern = replace_regex(ProcessCommandLine, @"\b\d{4,5}\b", "<port>")
};
let TunnelBaseline =
SshTunnelEvents(ago(BaselineWin + Lookback), ago(Lookback))
| distinct DeviceName, AccountName, CmdPattern;
let TunnelFindings =
SshTunnelEvents(ago(Lookback), now())
| extend TunnelType = case(
TunnelFlag == "L", "Local Port Forwarding (-L)",
TunnelFlag == "D", "Dynamic Port Forwarding / SOCKS (-D)",
TunnelFlag == "R", "Remote Port Forwarding (-R)",
"SSH Tunneling / Other")
// Explicit binding requires all four fields (bind:port:host:hostport)
| extend ListenerScope = case(
AllSpecs matches regex @"(^|\s)(\*|0\.0\.0\.0|::):", "Bind to all interfaces",
AllSpecs matches regex @"[\w\.\-]+:\d{1,5}:[\w\.\-]+:\d{1,5}", "Bind to explicit address",
"Loopback (Default)")
| extend PersistentTunnel = ProcessCommandLine matches regex @"(^|\s)-(fN|Nf|N|f)(\s|$)"
// Regular local dev tunnels on loopback are excluded in High-Fidelity mode
| where HighFidelityOnly == false
or ListenerScope != "Loopback (Default)"
or TunnelType startswith "Remote"
| summarize
EventCount = count(),
FirstSeen = min(Timestamp),
LastSeen = max(Timestamp),
SampleCmd = any(ProcessCommandLine),
Parents = make_set(InitiatingProcessFileName, 8),
SshTargets = make_set_if(SshTarget, isnotempty(SshTarget), 8),
ForwardSpecSet = make_set(AllSpecs, 8),
ReportId = any(ReportId),
DeviceId = any(DeviceId)
by DeviceName, AccountName, InitiatingProcessAccountName, FileName,
TunnelType, ListenerScope, PersistentTunnel, CmdPattern
| join kind=leftanti TunnelBaseline on DeviceName, AccountName, CmdPattern
| extend RiskScore =
iff(ListenerScope == "Bind to all interfaces", 40, 0)
+ iff(ListenerScope == "Bind to explicit address", 20, 0)
+ iff(TunnelType startswith "Remote", 30, 0)
+ iff(PersistentTunnel, 15, 0)
+ iff(TunnelType startswith "Dynamic", 10, 0)
| project
Category = "SSH Port Forwarding",
RiskScore,
Signal = TunnelType,
Evidence = strcat(TunnelType, " | ", ListenerScope,
iff(PersistentTunnel, " | persistent tunnel without shell (-N/-f)", "")),
DeviceName,
Account = coalesce(AccountName, InitiatingProcessAccountName),
EventCount,
FirstSeen,
LastSeen,
CLI = SampleCmd,
SampleCmd,
Details = strcat("Specs: ", strcat_array(ForwardSpecSet, ", "),
iff(array_length(SshTargets) > 0, strcat(" | Targets: ", strcat_array(SshTargets, ", ")), ""),
" | Parent: ", strcat_array(Parents, ", ")),
ReportId,
DeviceId;
let ConfigWrites =
DeviceFileEvents
| where Timestamp > ago(Lookback)
| where FileName =~ "sshd_config" or FolderPath has "/etc/ssh/sshd_config.d"
| where ActionType in ("FileCreated", "FileModified", "FileRenamed")
| project Timestamp, DeviceName, Account = InitiatingProcessAccountName,
Cmd = InitiatingProcessCommandLine, Path = FolderPath,
Signal = "sshd_config modified", Score = 35, ReportId, DeviceId;
let GatewayPortsCmd =
DeviceProcessEvents
| where Timestamp > ago(Lookback)
| where ProcessCommandLine has_cs "GatewayPorts" or ProcessCommandLine has_cs "PermitTunnel"
| where ProcessCommandLine !contains "GatewayPorts=no" and ProcessCommandLine !contains "GatewayPorts no"
| project Timestamp, DeviceName, Account = InitiatingProcessAccountName,
Cmd = ProcessCommandLine, Path = FolderPath,
Signal = "GatewayPorts/PermitTunnel in command line", Score = 45, ReportId, DeviceId;
let ConfigFindings =
union ConfigWrites, GatewayPortsCmd
// Adjust admin and deployment accounts based on your environment
| where Account !in~ ("puppet", "ansible", "salt")
and Cmd !has "cloud-init" and Cmd !has "unattended-upgrade"
| summarize
EventCount = count(),
FirstSeen = min(Timestamp),
LastSeen = max(Timestamp),
SampleCmd = any(Cmd),
Paths = make_set(Path, 4),
RiskScore = max(Score),
ReportId = any(ReportId),
DeviceId = any(DeviceId)
by DeviceName, Account, Signal
| project
Category = "sshd_config Exposure",
RiskScore,
Signal,
Evidence = strcat(Signal, " by ", Account),
DeviceName,
Account,
EventCount,
FirstSeen,
LastSeen,
CLI = SampleCmd,
SampleCmd,
Details = strcat("Paths: ", strcat_array(Paths, ", ")),
ReportId,
DeviceId;
union TunnelFindings, ConfigFindings
| extend Severity = case(RiskScore >= 40, "High", RiskScore >= 20, "Medium", "Low")
| project-reorder Severity, RiskScore, Category, Evidence, DeviceName, Account,
EventCount, FirstSeen, LastSeen, CLI, SampleCmd, Details
| sort by RiskScore desc, LastSeen desc
Benjamin Zulliger · Week 33 · Aug 10-17 ·
IdentityQueryEvents, IntuneDeviceComplianceOrg
Correlates non-compliant or health-flagged Intune devices with LDAP reconnaissance from the same host, pairing device posture with directory enumeration.
let Lookback = 1d;
let BinSize = 15m;
let RiskyDevices = materialize(
IntuneDeviceComplianceOrg
| where TimeGenerated > ago(7d)
| summarize arg_max(TimeGenerated, DeviceHealthThreatLevel, ComplianceState) by DeviceName
| where DeviceHealthThreatLevel in~ ("Low", "Medium", "High")
or ComplianceState =~ "Noncompliant"
| extend DeviceKey = tolower(tostring(split(DeviceName, ".")[0]))
| distinct DeviceKey, DeviceHealthThreatLevel, ComplianceState
);
// Attributes split by signal strength instead of a flat has_any
let RxHigh = @"ms-mcs-admpwd|mslaps-(encrypted)?password|ntsecuritydescriptor|msds-allowedtodelegateto|msds-keycredentiallink|sidhistory|unixuserpassword";
let RxMed = @"serviceprincipalname|admincount|msds-managedpassword|gplink|scriptpath";
let RxLow = @"useraccountcontrol|memberof|member|primarygroupid|grouptype";
// LDAP matching rules and bitmask filters, almost exclusively seen from offensive tooling
let RxTool = @"1\.2\.840\.113556\.1\.4\.1941|1\.2\.840\.113556\.1\.4\.803:=(4194304|524288|16777216|8192)";
IdentityQueryEvents
| where TimeGenerated > ago(Lookback)
| where ActionType == "LDAP query"
| where isnotempty(Query) and isnotempty(DeviceName)
| where AccountName !endswith "$" // Computer accounts need their own baseline
| extend DeviceKey = tolower(tostring(split(DeviceName, ".")[0]))
| lookup kind=inner RiskyDevices on DeviceKey
| extend q = tolower(Query)
| extend
ToolHit = extract(RxTool, 0, q),
Score = iff(q matches regex RxHigh, 5, 0)
+ iff(q matches regex RxMed, 2, 0)
+ iff(q matches regex RxLow, 1, 0)
+ iff(q matches regex RxTool, 5, 0)
+ iff(q contains "objectcategory=person" and q contains "(&", 1, 0)
| where Score > 0
| summarize
Events = count(),
DistinctQueries = dcount(Query),
Targets = dcount(QueryTarget),
MaxScore = max(Score),
TotalScore = sum(Score),
ToolIndicators = make_set_if(ToolHit, isnotempty(ToolHit), 10),
SampleQueries = make_set(substring(Query, 0, 200), 8),
IPs = make_set(IPAddress, 5),
TargetDCs = make_set(DestinationDeviceName, 5),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by bin(TimeGenerated, BinSize), DeviceKey, AccountUpn, AccountDisplayName, DeviceHealthThreatLevel, ComplianceState
// Two separate triggers: single high confidence hit OR bulk enumeration
| where MaxScore >= 5 or (TotalScore >= 15 and DistinctQueries >= 20)
| extend Verdict = case(
array_length(ToolIndicators) > 0, "High: LDAP matching rule or bitmask filter, typical for offensive tooling",
MaxScore >= 5, "High: access to highly sensitive AD attributes (LAPS/ACL/delegation)",
DistinctQueries >= 50, "Medium: broad AD enumeration in a short time window",
"Low: elevated LDAP activity, review context")
| order by MaxScore desc, DistinctQueries desc
Jay Kerai · Week 32 · Aug 3-10 ·
AzureActivity
Audits write and delete operations against Log Analytics workspace tables, covering the tamper path that removes the evidence rather than the alert.
//For auditing Write/Delete actions upon LAW Tables
AzureActivity
| where TimeGenerated > ago(90d)
| where OperationNameValue contains "MICROSOFT.OPERATIONALINSIGHTS/WORKSPACES/TABLES/"
| extend TableName = Properties_d.resource
| project-reorder TimeGenerated, TableName, Caller
Jose Sebastián Canós · Week 31 · Jul 27-Aug 3 ·
CloudAppEvents
Lists user-reported phishing submissions where automated investigation returned no threats found, the queue where genuine phishing quietly ends up.
CloudAppEvents
| where ActionType == "AirInvestigationData"
| extend
Status = tostring(RawEventData["Status"]),
InvestigationId = tostring(RawEventData["InvestigationId"]),
InvestigationType = tostring(RawEventData["InvestigationType"]),
InvestigationName = tostring(RawEventData["InvestigationName"]),
Data = todynamic(tostring(RawEventData["Data"]))
| where InvestigationType == "SubmissionInvestigation" and InvestigationName has "User reported message as malicious" and Status == "No threats found"
| extend
AlertType = tostring(Data["AlertType"]),
AlertId = strcat("fa", tostring(Data["ProviderAlertId"])),
InternetMessageId = tostring(Data["Entities"][0]["InternetMessageId"]),
NetworkMessageId = tostring(Data["Entities"][0]["NetworkMessageId"]),
SenderFromAddress = tostring(Data["Entities"][0]["Sender"]),
RecipientEmailAddress = tostring(Data["Entities"][0]["Recipient"]),
Subject = tostring(Data["Entities"][0]["Subject"])
| where AlertType == "b26a5770-0c38-434a-9380-3a3c2c27bbb3" and isnotempty(NetworkMessageId) and isnotempty(SenderFromAddress) and isnotempty(RecipientEmailAddress) and isnotempty(Subject)
Sergio Albea · Week 30 · Jul 20-27 ·
EmailEvents
Flags emoji in email subjects joined to URL click events, a filter-evasion trick that shows up in phishing waves.
// Sergio Albea 17-03-2026 ©️
EmailEvents
| where Timestamp > ago(7d)
| where isnotempty(Subject)
| extend Icons = extract_all(@"([\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}])", Subject)
| where isnotempty(Icons)
| join kind=inner UrlClickEvents on NetworkMessageId
| where UserLevelPolicy !has 'Allow'
| where OrgLevelPolicy !has 'Allow'
| extend SenderIP = iff(isnotempty( SenderIPv4),SenderIPv4,SenderIPv6)
| extend geo_ip = tostring(geo_info_from_ip_address(SenderIP).country)
//| where Subject contains "⚠️"
| summarize Distinct_Recipients=dcount(RecipientEmailAddress),make_set(RecipientEmailAddress),Emails=count() by Subject,SenderIP,geo_ip,ActionType, Workload, Url, ThreatTypes, LatestDeliveryLocation
| order by Emails, Distinct_Recipients
David Alonso · Week 30 · Jul 20-27 ·
AzureActivity
Surfaces service principals that accessed a Key Vault for the first time in seven days, the probing step after a service principal is compromised.
// Hunt : Hunt - Service Principals Accessing Key Vault for the First Time Within 7 Days
// Tactics : CredentialAccess
// MITRE : T1552.001
// Purpose : Surfaces identities that accessed a Key Vault for the first time in the last 7 days. Compromised service principals and attacker-created SPs often probe Key Vaults for secrets/certs after initial access. Investigate any SP not expected to require KV access.
//==========================================================================================
let AllKVAccess = AzureActivity
| where TimeGenerated > ago(90d)
| where OperationNameValue startswith "MICROSOFT.KEYVAULT/VAULTS/"
| where ActivityStatusValue =~ "Success"
| extend VaultPath = tostring(strcat(SubscriptionId, "/", ResourceGroup, "/", tostring(split(ResourceId, "/")[8])))
| summarize FirstAccessEver = min(TimeGenerated) by Caller, VaultPath;
AzureActivity
| where TimeGenerated > ago(7d)
| where OperationNameValue startswith "MICROSOFT.KEYVAULT/VAULTS/"
| where ActivityStatusValue =~ "Success"
| extend VaultPath = tostring(strcat(SubscriptionId, "/", ResourceGroup, "/", tostring(split(ResourceId, "/")[8])))
// FIX: join on BOTH Caller and VaultPath. The previous version joined on Caller only, so a
// caller newly accessing vault A would also surface all of their long-standing vault B access.
| join kind=inner AllKVAccess on Caller, VaultPath
| where FirstAccessEver > ago(7d)
| project TimeGenerated, Caller, Operation = OperationNameValue, ResourceId, CallerIpAddress, SubscriptionId, ResourceGroup, VaultPath, FirstAccessEver
| order by FirstAccessEver desc
Jose Sebastián Canós · Week 29 · Jul 13-20
Measures the gap between ingestion times per table across the whole workspace, which is how you spot a connector that has silently gone quiet.
let query_period = 7d;
union *
| where TimeGenerated > ago(query_period)
| distinct Type, bin(IngestionTime = ingestion_time(), 30m)
| sort by Type asc, IngestionTime asc
| extend Difference = iff(Type == prev(Type), IngestionTime - prev(IngestionTime), 0s)
| summarize Frequency = max(Difference) by Type
| extend Frequency = iff(Frequency == 0s, query_period, Frequency)
| lookup kind=leftouter (
union *
| where TimeGenerated > ago(query_period)
| summarize percentiles(IngestionDelay = ingestion_time() - TimeGenerated, 50, 80, 95, 99) by Type
) on Type
Jose Sebastián Canós · Week 28 · Jul 6-13 ·
DeviceNetworkEvents
Matches the CloudFront domain pattern used by several loader families where msiexec or an installer process is the parent.
DeviceNetworkEvents
| where RemoteUrl matches regex @"d\d[a-z0-9]{12}\.cloudfront\.net" and (InitiatingProcessParentFileName has "msiexec.exe" or InitiatingProcessCommandLine has "/Install")
| project
TimeGenerated,
DeviceName,
LocalIP,
ActionType,
InitiatingProcessParentCreationTime,
InitiatingProcessParentFileName,
InitiatingProcessCreationTime,
InitiatingProcessAccountUpn,
InitiatingProcessFolderPath,
InitiatingProcessCommandLine,
Protocol,
RemoteUrl,
RemoteIP,
RemotePort
Benjamin Zulliger · Week 28 · Jul 6-13 ·
AuditLogs
Extracts the exclusion changes from Conditional Access policy create and update events, so a quietly added exempt user or group does not go unnoticed.
// CAP Exludes
AuditLogs
| where OperationName in ("Update conditional access policy", "Create conditional access policy")
| where Result == "success"
| mv-expand TargetResource = TargetResources
| mv-expand ModifiedProperty = TargetResource.modifiedProperties
| where ModifiedProperty.displayName == "PolicyDetail"
| extend OldPolicy = todynamic(tostring(ModifiedProperty.oldValue))
| extend NewPolicy = todynamic(tostring(ModifiedProperty.newValue))
// Extract Excludes
| extend OldExclusions = OldPolicy.conditions.users.excludeGroups
| extend NewExclusions = NewPolicy.conditions.users.excludeGroups
| extend OldExcludedUsers = OldPolicy.conditions.users.excludeUsers
| extend NewExcludedUsers = NewPolicy.conditions.users.excludeUsers
// check for added Excludes
| where array_length(NewExclusions) > array_length(OldExclusions)
or array_length(NewExcludedUsers) > array_length(OldExcludedUsers)
| project TimeGenerated, InitiatedBy = Identity, PolicyName = TargetResource.displayName, OldExclusions, NewExclusions, OldExcludedUsers, NewExcludedUsers
Ali Hussein · Week 27 · Jun 29-Jul 6 ·
IdentityInfo, SigninLogs
Builds a list of admin accounts, learns the ASNs they normally sign in from, and flags admin sign-ins arriving from a new network.
let admins=(IdentityInfo
| where AssignedRoles contains "admin" or GroupMembership has "Admin"
| summarize by tolower(AccountUPN));
//admins
let known_asns = (
SigninLogs
| where TimeGenerated between(ago(14d)..ago(1d))
| where ResultType == 0
| summarize by AutonomousSystemNumber);
//known_asns
SigninLogs
| where TimeGenerated > ago(1d)
| where ResultType == 0
| where tolower(UserPrincipalName) in (admins)
| where AutonomousSystemNumber !in (known_asns)
| project-reorder TimeGenerated, UserPrincipalName, UserAgent, IPAddress, AutonomousSystemNumber
| extend AccountName = tostring(split(UserPrincipalName, "@")[0]), AccountUPNSuffix = tostring(split(UserPrincipalName, "@")[1])
Jay Kerai · Week 27 · Jun 29-Jul 6 ·
IdentityInfo
Finds identities holding Entra admin roles that are synchronised from on-premises Active Directory, the hybrid dependency Microsoft has been advising teams to break.
IdentityInfo //Advanced Hunting table but can be ingested in sentinel
| where TimeGenerated > ago(30d) //Will capture user if any change occured in last 30 days to user
| where (isnotempty(AccountDomain))
| where (isnotempty(tostring(AssignedRoles)))
| where tostring(AssignedRoles) contains "admin"
| where IdentityEnvironment == @"Hybrid"
| sort by TimeGenerated desc
| summarize by AccountUpn, OnPremObjectId, tostring(AssignedRoles), AccountDomain
Jay Kerai · Week 26 · Jun 22-29 ·
IdentityInfo
Lists accounts Defender for Identity classifies as service accounts but that are neither service principals nor managed service accounts, the ones running on a password somebody set years ago.
IdentityInfo
| where Timestamp > ago(30d)
| where Type == @"ServiceAccount"
| extend ["Service On-Prem Sid"] = OnPremObjectId
| extend ["Service Principal Name"] = iff(IdentityEnvironment == "OnPremises",replace_string(strcat(AccountName, "@",AccountDomain),"$",""), AccountUpn )
| where parse_json(UserAccountControl)[0] != 'WorkstationTrustAccount' //Exclude gMSA/dMSA
| where not (ChangeSource == @"System-UserPersistence" and isempty(CloudSid)) //Exclude Service Principals
| summarize arg_max(Timestamp,*) by AccountObjectId,CloudSid,['Service On-Prem Sid']
Benjamin Zulliger · Week 26 · Jun 22-29 ·
CloudAppEvents
Correlates Dynamics 365 role and team modifications with off-network access inside a short window, aimed at privilege escalation in the business application layer.
let CorporateIPRange = "147.86.0.0/16";
let ThreatWindow = 10m;
let SuspiciousInquiries =
CloudAppEvents
| where TimeGenerated > ago(1d)
| where Application == "Microsoft Dynamics 365"
| where not(ipv4_is_in_range(IPAddress, CorporateIPRange ))
| where IsAdminOperation == 0
| where ActionType in ("RetrieveUserPrivileges", "RetrieveUserPrivilegeByPrivilegeName", "RetrievePrivilegeMaxDepthFromTeamRoles")
| project TargetTime = TimeGenerated, AccountId, IPAddress, CorrelationId = tostring(parse_json(RawEventData).CorrelationId);
CloudAppEvents
| where TimeGenerated > ago(1d)
| where Application == "Microsoft Dynamics 365"
| where not(ipv4_is_in_range(IPAddress, CorporateIPRange ))
| where ActionType has_any ("Update", "Create") and (ObjectName has "role" or ObjectName has "team" or parse_json(RawEventData).EntityName has_any ("role", "systemuserroles", "teamroles"))
| project ModificationTime = TimeGenerated, AccountId, ActionType, ObjectName, RawEventData
| join kind=inner SuspiciousInquiries on AccountId
| where ModificationTime between (TargetTime .. (TargetTime + ThreatWindow))
| project ModificationTime, AccountId, ActionType, ObjectName, IPAddress, TargetTime
Bert-Jan Pals · Week 25 · Jun 15-22 ·
DeviceFileEvents
Watches for executable and script file types written into C:\Users\Public, a staging directory that almost nothing legitimate writes binaries to.
// The start of the folderpath in the Public directory.
let PublicFolder = @'C:\Users\Public';
// List with Executable File Extensions, can be adjusted or changed.
let ExecutableFileExtensions = dynamic(['bat', 'cmd', 'com', 'cpl', 'ex', 'exe', 'jse', 'msc','ps1', 'reg', 'vb', 'vbe', 'ws', 'wsf', 'hta', 'js']);
// Prevalence Threshold, if the file exceeds this threshold it is likely to be benign.
let FilePrevalenceThreshold = 250;
DeviceFileEvents
| where FolderPath contains PublicFolder
// Extract File Extension from the filename.
| extend FileExtension = tostring(extract(@'.*\.(.*)', 1, FileName))
// Only list Files that are executable
| where FileExtension in~ (ExecutableFileExtensions)
| invoke FileProfile('SHA256', 10000)
// Filter based on FilePrevalenceThreshold
| where GlobalPrevalence <= FilePrevalenceThreshold
| project Timestamp, DeviceName, FileExtension, FolderPath, GlobalPrevalence, Signer, Publisher, ReportId, DeviceId
Alex Verboon · Week 25 · Jun 15-22 ·
AgentsInfo
Lists local AI agents and their MCP servers from the AgentsInfo table, an inventory most estates do not have yet.
AgentsInfo
| where Platform == @"LocalAgents"
| extend AgentInfo = parse_json(RawAgentInfo).localAgentMetadata
| where isnotempty( AgentInfo)
| extend DeviceName = tostring(AgentInfo.deviceName)
| where isnotempty( column_ifexists("McpServers",""))
| mv-expand McpServers
| extend MCP_Name = tostring( McpServers.name)
| extend MCP_Type = tostring(McpServers.type)
| extend MCP_Endpoint = tostring(McpServers.endpoint)
| project MCP_Name, MCP_Type, MCP_Endpoint, Name, DeviceName
| summarize Devices = make_set(DeviceName), TotalDevices = dcount(DeviceName,4) by MCP_Name, MCP_Type, MCP_Endpoint
Jay Kerai · Week 24 · Jun 8-15 ·
DeviceEvents
Catches the VMAccess extension resetting a local administrator password on an Azure VM, a supported feature that is also a clean path onto a machine you do not have credentials for.
//JsonVMAccessExtension.exe refers to the VMAccess Extension that can reset the Built-in administrator account/add new accounts. This applies to any Azure VM/AVD
//Ref: https://learn.microsoft.com/en-us/troubleshoot/azure/virtual-machines/windows/reset-rdp#reset-the-local-admin-account-password
DeviceEvents
| where TimeGenerated >ago(90d)
//| where AdditionalFields.PipeName == "\\Device\\NamedPipe\\wkssvc" or AdditionalFields.PipeName == "\\Device\\NamedPipe\\srvsvc"
| where AdditionalFields.RemoteClientsAccess == "AcceptRemote"
| where InitiatingProcessCommandLine == "JsonVMAccessExtension.exe \"enable\"" //Azure VM account Extension
| where InitiatingProcessAccountName == "system" //Extension runs as system
Benjamin Zulliger · Week 24 · Jun 8-15 ·
DeviceInfo, DeviceTvmSoftwareInventory
Finds Windows devices running WSL without the Defender for Endpoint WSL plugin installed, a coverage gap that hides Linux activity on Windows endpoints.
let ActiveWindowsDevices =
DeviceInfo
| where Timestamp > ago(30d)
| where OSPlatform startswith "Windows"
| summarize arg_max(Timestamp, *) by DeviceId
| where isnotempty(DeviceName)
| where OnboardingStatus == "Onboarded"
| project DeviceId, DeviceName, OSPlatform, OSVersion;
// Identify all devices that have the WSL plugin installed
let DevicesWithWslPlugin =
DeviceTvmSoftwareInventory
| where SoftwareName has "Defender for Endpoint plug-in for WSL" or SoftwareName has "DefenderPluginForWSL"
| summarize by DeviceId;
// Combine the tables and display "yes" or "no"
ActiveWindowsDevices
| extend WslPluginInstalled = iif(DeviceId in (DevicesWithWslPlugin), "yes", "no")
| sort by DeviceName asc
// Optional Filter for Devices with missing WSL Plugin
//| where WslPluginInstalled == "no"
// Microsoft Defender for Endpoint plug-in for Windows Subsystem for Linux (WSL) https://learn.microsoft.com/en-us/defender-endpoint/mde-plugin-wsl
Thomas Naunheim · Week 23 · Jun 1-8 ·
MicrosoftGraphActivityLogs
Enriches Microsoft Graph activity from first-party apps with a sensitive-permission classification pulled in at query time.
// Get a list of first party apps with activity to Microsoft Graph and enriched classification
let SensitiveMsGraphPermissions = externaldata(AppId: guid, AppRoleId: guid, AppRoleDisplayName: string, Category: string, EAMTierLevelName: string, EAMTierLevelTagValue: string)["https://raw.githubusercontent.com/Cloud-Architekt/AzurePrivilegedIAM/main/Classification/Classification_MsGraphAppRoles.json"] with (format='multijson')
| where isnotempty(EAMTierLevelName)
// Optional: Filtering for Control Plane only
//| where EAMTierLevelName == "ControlPlane"
| distinct AppRoleDisplayName;
_GetWatchlist('WorkloadIdentityInfo')
| where IsFirstPartyApp == "true"
| extend Identity = tostring(ServicePrincipalObjectId)
| join kind=inner (
MicrosoftGraphActivityLogs
| where TimeGenerated >ago(365d)
| where Roles has_any (SensitiveMsGraphPermissions)
| extend Roles = split(Roles, ' ')
| extend Identity = ServicePrincipalId
) on Identity
| summarize AppRoleScope = make_set( Roles ), TotalResponseSizeBytes = sum(ResponseSizeBytes) by AppDisplayName, ServicePrincipalObjectId, AppId, AppOwnerTenantId, ServicePrincipalType, SignInAudience
Jay Kerai · Week 23 · Jun 1-8 ·
AzureActivity
Lists VM and deployment write operations across 90 days of AzureActivity, the starting point for rogue or unbudgeted VM hunting.
//Could be useful as part of rogue VM creation hunting, could add queries to check the tags and ensure is compliant with Org Tagging
let operationList = dynamic(["microsoft.compute/virtualmachines/write", "microsoft.resources/deployments/write"]);
//
AzureActivity
| where TimeGenerated > ago(90d)
| where OperationNameValue in~ (operationList)
| where ActivitySubstatusValue == "Created"
| extend ProvisioningState = parse_json(tostring(parse_json(tostring(parse_json(Properties).responseBody)).properties)).provisioningState,
VM_ID = parse_json(tostring(parse_json(tostring(parse_json(Properties).responseBody)).properties)).vmId,
ImageReference_Offer = parse_json(tostring(parse_json(tostring(parse_json(tostring(parse_json(tostring(Properties_d.responseBody)).properties)).storageProfile)).imageReference)).offer,
ImageReference_Exact_version = parse_json(tostring(parse_json(tostring(parse_json(tostring(parse_json(tostring(Properties_d.responseBody)).properties)).storageProfile)).imageReference)).exactVersion,
ImageReference_SKU = parse_json(tostring(parse_json(tostring(parse_json(tostring(parse_json(tostring(Properties_d.responseBody)).properties)).storageProfile)).imageReference)).sku
//Only search for Windows Server
| where ImageReference_Offer == "WindowsServer"
Alex Verboon · Week 22 · May 25-Jun 1 ·
CloudAppEvents
Tracks changes to the Teams external access allowed-domains list, a federation setting that quietly widens who can message your users.
CloudAppEvents
| where ActionType == "TeamsAdminAction"
| where RawEventData has "AllowedDomains"
| extend ModifiedProperties = parse_json(RawEventData).ModifiedProperties
| mv-apply ModifiedProperties on
(
where ModifiedProperties.Name == "AllowedDomains"
| project
PropertyName = tostring(ModifiedProperties.Name),
NewValue = tostring(ModifiedProperties.NewValue),
OldValue = tostring(ModifiedProperties.OldValue)
)
| project
TimeGenerated,
ActionType,
AccountDisplayName,
PropertyName,
OldValue,
NewValue,
RawEventData
Benjamin Zulliger · Week 22 · May 25-Jun 1 ·
DeviceLogonEvents
Filters NTLM network logons down to devices the exposure graph marks as critical, which is where an NTLM relay actually hurts.
let NetworkLogons = DeviceLogonEvents
| where Timestamp > ago(4h)
| where LogonType == "Network"
| where Protocol == "NTLM"
| extend ShortDeviceName = toupper(split(DeviceName, ".")[0]);
NetworkLogons
| join kind=inner ( ExposureGraphNodes
| where Categories has "device"
| where isnotnull(NodeProperties.rawData.criticalityLevel)
| extend ShortNodeName = toupper(split(NodeName, ".")[0])
| extend TargetCriticalityScore = toint(NodeProperties.rawData.criticalityLevel.criticalityLevel)
| extend TargetCriticalityRule = tostring(NodeProperties.rawData.criticalityLevel.ruleName)
| project ShortNodeName, TargetCriticalityScore, TargetCriticalityRule
) on $left.ShortDeviceName == $right.ShortNodeName
| where TargetCriticalityScore >= 3
David Alonso · Week 21 · May 18-25 ·
DataSecurityBehaviors, DataSecurityEvents
Joins anomalous Insider Risk behaviours to the concrete file events behind them for the same user and window, turning a behaviour score into evidence.
// XDR-4 - Behavior -> Event drill-through
// Run in: security.microsoft.com -> Hunting -> Advanced hunting
// Joins anomalous Insider Risk behaviors with concrete file events on same user, same window
DataSecurityBehaviors
| where Timestamp > ago(7d) and IsAnomalous == 1
| project BehaviorId, AccountUpn, ActionTypeBehavior=ActionType, Categories, StartTime, EndTime
| join kind=inner (
DataSecurityEvents
| where Timestamp > ago(7d)
| project Timestamp, AccountUpn, ActionType, ObjectId, ApplicationNames, Workload, ActivityId
) on AccountUpn
| where Timestamp between (StartTime .. EndTime)
| project ActionTypeBehavior, Categories, AccountUpn, Timestamp, ActionType, ObjectId, ApplicationNames, Workload
| order by AccountUpn, Timestamp asc
Bert-Jan Pals · Week 21 · May 18-25 ·
AuditLogs
Parses triggered PIM alerts out of AuditLogs and assigns severity, covering standing role assignments made outside PIM.
AuditLogs
| where OperationName =~ "Triggered PIM alert"
| where Category =~ "RoleManagement"
| extend AlertDescription = TargetResources[0].displayName
| extend Severity = case(
AlertDescription =~ "Roles are being assigned outside of Privileged Identity Management" or AlertDescription =~ "Role assigned outside of PIM", "High",
AlertDescription =~ "Potential stale accounts in a privileged role", "Medium",
AlertDescription =~ "Administrators aren't using their privileged roles", "Low",
AlertDescription =~ "Roles don't require multifactor authentication for activation", "Low",
AlertDescription =~ "The organization doesn't have Microsoft Entra ID P2 or Microsoft Entra ID Governance", "Low",
AlertDescription =~ "There are too many Global Administrators", "Low",
AlertDescription =~ "Roles are being activated too frequently", "Low",
"Unknown"
)
| project-reorder TimeGenerated, OperationName, Category, AlertDescription, Severity
Michalis Michalos · Week 20 · May 11-18 ·
Usage
Estimates what each table would cost in the data lake tier versus analytics, with the per-GB prices exposed as variables you set to your own contract.
let AnalyticsPricePerGB = 2.48; // Example price per GB for Analytics
let DataLakePricePerGB = 0.05; // Example price per GB for Data Lake
Usage
| where TimeGenerated >= startofday(ago(30d))
| where IsBillable == true
| summarize IngestedGB = round(sum(Quantity) / 1000.0, 2) by DataType, Plan
| where Plan == "Analytics"
| extend CurrentCost = round(IngestedGB * AnalyticsPricePerGB, 2)
| extend IfDataLakeCost = round(IngestedGB * DataLakePricePerGB, 2)
| extend PotentialSavings = round(CurrentCost - IfDataLakeCost, 2)
| where PotentialSavings > 0
| project DataType, IngestedGB, CurrentCost, IfDataLakeCost, PotentialSavings
| order by PotentialSavings desc
Alex Verboon · Week 20 · May 11-18 ·
CopilotActivity
Expands Security Copilot activity into the individual resources each session touched, which is the audit trail question that comes up as soon as Copilot is rolled out.
CopilotActivity
| extend Parsed = parse_json(LLMEventData)
| mv-expand Resource = Parsed.AccessedResources
| extend Action = tostring(Resource.Action)
| extend Id = tostring(Resource.id)
| extend Name = tostring(Resource.Name)
| extend Type = tostring(Resource.Type)
| where Name == "Block"
| extend DetectionName = extract(@"blocked by ['""]([^'""]+)['""] detection", 1, Action)
| project TimeGenerated, DetectionName, Action, Id, Name, Type, SrcIpAddr, Workload, AppHost, AppIdentity, LLMEventData
| sort by TimeGenerated
Bert-Jan Pals · Week 19 · May 4-11 ·
DeviceNetworkEvents
Four lines for mshta.exe talking to a public IP, which on most estates is either a red team or a real intrusion.
DeviceNetworkEvents
| where InitiatingProcessFileName =~ "mshta.exe"
| where RemoteIPType == "Public" or not(ipv4_is_private(RemoteIP))
| project-reorder TimeGenerated, InitiatingProcessCommandLine, RemoteUrl, RemoteIP, DeviceName, InitiatingProcessAccountUpn
Jay Kerai · Week 18 · Apr 27-May 4 ·
SigninLogs
Reports the device trust type behind inbound B2B guest sign-ins, showing how much of your guest access arrives from unmanaged devices.
//Shouthout johannesblog.com for the idea
SigninLogs
//| where AppDisplayName =~ "Microsoft Teams"
| extend TrustType = tostring(DeviceDetail.trustType)
| where CrossTenantAccessType == @"b2bCollaboration"
| where AADTenantId != HomeTenantId //exclude B2b outbound
| where UserType == "Guest"
| project TimeGenerated, UserPrincipalName, AppDisplayName, IPAddress, TrustType,
DeviceId = tostring(DeviceDetail.deviceId),
DeviceName = tostring(DeviceDetail.displayName),
OperatingSystem = tostring(DeviceDetail.operatingSystem),
Browser = tostring(DeviceDetail.browser),
ConditionalAccessStatus, ResultType, ResultDescription
| order by TimeGenerated desc
Benjamin Zulliger · Week 18 · Apr 27-May 4 ·
DeviceNetworkEvents, DeviceProcessEvents
Chains LNK execution to UNC path access to outbound SMB in one query, modelling the NTLM credential leak path used against CVE-2026-32202.
// APT28 Kill Chain for CVE-2026-32202
// https://thehackernews.com/2026/04/microsoft-confirms-active-exploitation.html
// LNK → CPL/UNC → SMB → NTLM Exfil
let timeframe = 7d;
let InitialAccess_LNK = DeviceProcessEvents
| where TimeGenerated > ago(timeframe)
| where InitiatingProcessCommandLine has ".lnk"
| project DeviceId, LNK_TriggerTime = TimeGenerated, LNK_CommandLine = InitiatingProcessCommandLine;
let Execution_UNC = DeviceProcessEvents
| where TimeGenerated > ago(timeframe)
| where ProcessCommandLine matches regex @"\\\\[a-zA-Z0-9\-\.]{4,}\\"
| where ProcessCommandLine has_any (".cpl", ".dll", ".exe")
| project DeviceId, UNC_LoadTime = TimeGenerated, UNC_CommandLine = ProcessCommandLine;
let Exfiltration_SMB = DeviceNetworkEvents
| where TimeGenerated > ago(timeframe)
| where RemotePort == 445
| where not(ipv4_is_private(RemoteIP))
| project DeviceId, SMB_ConnectTime = TimeGenerated, RemoteIP;
InitialAccess_LNK
| join kind=inner Execution_UNC on DeviceId
| where UNC_LoadTime between (LNK_TriggerTime .. (LNK_TriggerTime + 2m))
| join kind=inner Exfiltration_SMB on DeviceId
| where SMB_ConnectTime between (UNC_LoadTime .. (UNC_LoadTime + 2m))
| project
DeviceId,
LNK_Time = LNK_TriggerTime,
UNC_Time = UNC_LoadTime,
SMB_Time = SMB_ConnectTime,
LNK_CommandLine,
UNC_CommandLine,
RemoteIP
| extend
AlertTitle = "APT28 Kill Chain: LNK→UNC→SMB (CVE-2026-32202)",
Severity = "Critical",
MITRE = "T1566.001 → T1187 → T1557.001"
Alex Verboon · Week 17 · Apr 20-27 ·
IdentityDirectoryEvents
Surfaces Group Policy password policy changes from Defender for Identity directory events, including which policy and which domain was touched.
IdentityDirectoryEvents
| where ActionType == @"Group Policy settings were changed"
| extend Info = parse_json(AdditionalFields)
| extend MachinePolicies = tostring(Info.MachinePolicies),
GroupPolicyName = tostring(Info.GroupPolicyName),
GroupPolicyId = tostring(Info.GroupPolicyId),
DomainName = tostring(Info.DomainName),
Category = tostring(Info.Category),
AttackTechniques = tostring(Info.AttackTechniques)
| project TimeGenerated, DomainName, GroupPolicyName, GroupPolicyId, MachinePolicies, Category, AttackTechniques
| mv-expand PolicyEntry = split(MachinePolicies, ",") to typeof(string)
| extend FullPath = tostring(split(PolicyEntry, "=")[0]),
PolicyValue = tostring(split(PolicyEntry, "=")[1])
| extend PathParts = split(FullPath, @"\"),
PolicyName = tostring(split(FullPath, @"\")[-1])
| extend PolicyPath = strcat_array(array_slice(PathParts, 0, array_length(PathParts) - 2), @"\")
| where PolicyPath == @"Account Policies\PasswordPolicy"
| summarize Settings = make_bag(pack(PolicyName, PolicyValue))
by TimeGenerated, GroupPolicyId, GroupPolicyName, DomainName, AttackTechniques, Category, PolicyPath
Jay Kerai · Week 17 · Apr 20-27 ·
CloudAppEvents
Extracts new Tenant Allow Block List entries with their notes, expiry and defanged URL, so allow-list additions get reviewed rather than forgotten.
CloudAppEvents
| where ActionType == "New-TenantAllowBlockListItems"
| extend Notes = extract(@'"Notes","Value":"(.*?)"', 1, tostring(ActivityObjects))
| extend Url = replace_string(extract(@'Name":"Entries","Value":"(.*?)"', 1, tostring(ActivityObjects)), ".", "[.]")
| extend Expiration = replace_string(extract(@'"Name":"ExpirationDate","Value":"(.*?)"', 1, tostring(ActivityObjects)), ".", "[.]")
//| project-reorder Notes, Url, Expiration
//| project TimeGenerated, ObjectName, Notes, Url, Expiration, IPAddress
Alex Verboon · Week 16 · Apr 13-20 ·
AzureDiagnostics
Turns Automation Account job logs into per-runbook start, end and status rows, so a runbook that suddenly starts failing or running at odd hours is visible.
AzureDiagnostics
| where Category == 'JobLogs'
| extend RunbookName = RunbookName_s
| project TimeGenerated,RunbookName,ResultType,CorrelationId,JobId_g
| summarize StartTime = minif(TimeGenerated,ResultType == 'Started'),EndTime = minif(TimeGenerated,ResultType in ('Completed','Failed','Failed')),
Status = tostring(parse_json(make_list_if(ResultType,ResultType in ('Completed','Failed','Stopped')))[0]) by JobId_g,RunbookName
| extend DurationSec = datetime_diff('second', EndTime,StartTime)
| join kind=leftouter (AzureDiagnostics
| where Category == "JobStreams"
| where StreamType_s == "Error"
| summarize TotalErrors = dcount(StreamType_s) by JobId_g, StreamType_s)
on $left. JobId_g == $right. JobId_g
| extend HasErrors = iff(StreamType_s == 'Error',true,false)
| project StartTime, EndTime, DurationSec,RunbookName,Status,HasErrors,TotalErrors,JobId_g
Jay Kerai · Week 16 · Apr 13-20 ·
DeviceEvents
Hunts the named pipe pattern behind the RedSun technique, which abuses a Defender detection flow to escalate to SYSTEM.
//https://github.com/Nightmare-Eclipse/RedSun
//Uses a Defender detection to escalate to system. Note I couldn't get this exploit in MDE. It will fail to request a batch oplock on the update file.
// The binary/code supplied in repo uses an EICAR signature but a threatactor could subs this for anything so long as defender is triggered.
//Code will back out if real time monitoring is not enabled.
DeviceEvents
| where ActionType contains "NamedPipeEvent"
| where parse_json(AdditionalFields)["RemoteClientsAccess"] == 'AcceptRemote'
//| where parse_json(AdditionalFields)["PipeName"] == @'\Device\NamedPipe\REDSUN' //Low Fidelity Named Pipe (Line 518 in Code) Name can be changed but included here
| where parse_json(AdditionalFields)["DesiredAccess"] == '1704351' //Technically Access Mask can be modified
| where InitiatingProcessAccountName != @"system" and InitiatingProcessAccountName != "network service" and AccountSid != @"S-1-5-18"//PoC is from unprivileged User
| where parse_json(AdditionalFields)["FileOperation"] == 'File created'
| where InitiatingProcessVersionInfoCompanyName != @"Microsoft Corporation" //File created that created the namedpipe is the RedSun.exe binary (filename can be renamed)
David Alonso · Week 15 · Apr 6-13 ·
Usage
Breaks 30 days of workspace ingestion down per solution and separates auxiliary tables, the query to run before any Sentinel cost conversation.
let _AuxiliaryTables = dynamic([
"OfficeActivity", "AzureActivity", "Heartbeat",
"SentinelHealth", "SecurityAlert", "SecurityIncident", "Operation"
]);
Usage
| where TimeGenerated > ago(30d)
| where IsBillable == true
| where DataType !in (_AuxiliaryTables)
| summarize
TotalGB = round(sum(Quantity) / 1024.0, 2),
TableCount = dcount(DataType),
DailyAvgGB = round(avg(Quantity) / 1024.0, 4)
by Solution
| extend EstMonthlyCostUSD = round(DailyAvgGB * 30 * 2.76, 2)
| order by TotalGB desc
Nathan Hutchinson · Week 15 · Apr 6-13 ·
SigninLogs
Charts which MFA methods your tenant actually uses, which is how you find out how much of the estate is still on SMS before you start enforcing.
// Bar chart showing all MFA types used
SigninLogs
| where AuthenticationRequirement == "multiFactorAuthentication"
| where ResultType == 0
| project AuthenticationDetails
| extend ['MFA Method'] = tostring(parse_json(AuthenticationDetails)[1].authenticationMethod)
| summarize Count=count() by ['MFA Method']
| where ['MFA Method'] != "Previously satisfied" and isnotempty(['MFA Method'])
| sort by Count desc
| render barchart with (title="Types of MFA Methods used")
Ali Hussein · Week 14 · Mar 30-Apr 6 ·
DeviceProcessEvents
Correlates history-rewriting git commands with suspicious node.exe activity on the same device, aimed at developer workstation supply chain tampering.
let GitAbuse = DeviceProcessEvents
| where ProcessCommandLine has_any ("git commit --amend", "--no-verify", "git push -f", "git push --force", "git config --local")
| project DeviceId, GitTime=Timestamp, DeviceName, AccountName, GitCmd=ProcessCommandLine;
let SuspiciousNode = DeviceProcessEvents
| where FileName in~ ("node.exe", "node")
| where ProcessCommandLine has_any (".woff2", ".woff", ".ttf", ".otf", ".eot")
| where InitiatingProcessFileName in~ ("Code.exe", "code", "cmd.exe", "powershell.exe", "bash", "sh", "zsh")
or InitiatingProcessCommandLine has_any ("Code.exe", "code", ".vscode", "tasks.json", "folderOpen")
| project DeviceId, NodeTime=Timestamp, NodeCmd=ProcessCommandLine, InitiatingProcessFileName, InitiatingProcessCommandLine;
GitAbuse
| join kind=inner SuspiciousNode on DeviceId
| where NodeTime between (GitTime - 7d .. GitTime + 7d)
| project DeviceName, AccountName, GitTime, GitCmd, NodeTime, NodeCmd, InitiatingProcessFileName, InitiatingProcessCommandLine
| order by NodeTime desc
Ali Hussein · Week 13 · Mar 23-30 ·
DeviceNetworkEvents
Three lines that flag node.exe reaching out to blockchain infrastructure domains, a command and control channel that hides inside ordinary developer traffic.
DeviceNetworkEvents
| where InitiatingProcessFileName in~ ("node.exe", "node")
| where RemoteUrl has_any ("trongrid.io", "aptoslabs.com")
Benjamin Zulliger · Week 13 · Mar 23-30 ·
DeviceTvmSecureConfigurationAssessment, DeviceTvmSecureConfigurationAssessmentKB
Summarises the TVM secure configuration assessment per device against the configuration knowledge base, giving a readable posture gap list rather than raw IDs.
DeviceTvmSecureConfigurationAssessment
| join kind=leftouter (
DeviceTvmSecureConfigurationAssessmentKB
| project ConfigurationId, ConfigurationName
) on ConfigurationId
| summarize
Total = count(),
Compliant = countif(IsCompliant == 1),
NonCompliant = countif(IsCompliant == 0),
NotApplicable = countif(IsApplicable == 0)
by ConfigurationId, ConfigurationName
| order by NonCompliant desc
Jose Sebastián Canós · Week 12 · Mar 16-23 ·
SecurityEvent
Compares each account's authentication over a 14-day period against a watchlist of domain controllers to surface first-time or unusual authentication paths.
let query_frequency = 1h;
let query_period = 14d;
let _DomainControllers = toscalar(
_GetWatchlist("Service-PrivateCorporateServices")
| where Service == "DomainController"
| summarize make_list(HostName)
);
SecurityEvent
| where TimeGenerated > ago (query_period)
| where EventID == 4624 and AccountType == "User" and Computer has_any (_DomainControllers)
| summarize arg_min(TimeGenerated, *) by
LogonTypeName,
AuthenticationPackageName,
LmPackageName,
EmptyIpAddress = IpAddress in ("-", ""),
EmptyWorkstationName = WorkstationName in ("-", ""),
ElevatedToken,
IsAnonymousLogon = TargetAccount == @"NT AUTHORITY\ANONYMOUS LOGON"
| where TimeGenerated > ago(query_frequency)
| project
TimeGenerated,
Computer,
Account,
AccountType,
Activity,
LogonTypeName,
AuthenticationPackageName,
LmPackageName,
KeyLength,
EmptyIpAddress,
EmptyWorkstationName,
ElevatedToken,
IsAnonymousLogon
David Alonso · Week 12 · Mar 16-23 ·
OfficeActivity, Operation
Audits 30 days of Exchange inbox and transport rule changes with forwarding, redirect and hiding indicators pre-calculated, the standard opening move on a suspected BEC.
// Hunt : M365 - Inbox Rule and Transport Rule Audit (30d)
// Purpose : Full audit of all Exchange inbox and transport rules created or modified
// in the past 30 days, pre-calculated for forwarding, redirect and hiding
// indicators to support BEC and exfiltration investigations.
// Tables : OfficeActivity
// Period : P30D
//==========================================================================================
let LookbackDays = 30d;
OfficeActivity
| where TimeGenerated > ago(LookbackDays)
| where RecordType == "ExchangeAdmin"
| where Operation in (
"New-InboxRule", "Set-InboxRule", "Remove-InboxRule",
"New-TransportRule", "Set-TransportRule",
"Enable-TransportRule", "Disable-TransportRule",
"Remove-TransportRule", "New-JournalRule")
| extend Params = tostring(Parameters)
| extend
HasForward = Params has_any ("ForwardTo", "ForwardAsAttachmentTo", "RedirectTo", "RedirectMessageTo"),
HasHide = Params has_any ("DeleteMessage", "MoveToFolder", "MarkAsRead"),
HasBypass = Params has_any ("ExceptIfSenderDomainIs", "SetHeaderName"),
ExternalAddress = extract(@"(ForwardTo|RedirectTo|RedirectMessageTo).*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})", 2, Params),
FolderTarget = extract(@"(MoveToFolder).*?:([^\s,]+)", 2, Params)
| project
TimeGenerated,
UserId,
ClientIP,
Operation,
HasForward,
HasHide,
HasBypass,
ExternalAddress,
FolderTarget,
Params
| sort by TimeGenerated desc
David Alonso · Week 11 · Mar 9-16 ·
AzureActivity
Inventories 90 days of successful GPU and high-compute VM deployments from AzureActivity, the baseline you need before cryptojacking detections mean anything.
// Hunt : Hunt - GPU and High-Compute VM Deployment History (90d)
// Tactics : Impact
// MITRE : T1496
// Purpose : Inventory all successful GPU/high-compute VM deployments over the past 90 days. Use to baseline legitimate deployers before tuning Rule-01, and to investigate potential cryptojacking campaigns.
//==========================================================================================
let GPUSKUs = dynamic(["Standard_NC", "Standard_NV", "Standard_ND", "Standard_NP", "Standard_HB", "Standard_HC"]);
AzureActivity
| where TimeGenerated > ago(90d)
| where OperationNameValue has_any ("VIRTUALMACHINES/WRITE", "VIRTUALMACHINESCALESETS/WRITE")
| where ActivityStatusValue =~ "Success"
| where Properties has_any (GPUSKUs)
| project TimeGenerated, Caller, CallerIpAddress, SubscriptionId, ResourceGroup, ResourceId, Properties
| extend SKUHint = extract(@'Standard_(?:NC|NV|ND|NP|HB|HC)[^"]*', 0, Properties)
| order by TimeGenerated desc
Alex Verboon · Week 11 · Mar 9-16 ·
CloudAppEvents
Tracks changes to the outbound spam policy AutoForwardingMode setting, the tenant-level switch that decides whether external auto-forwarding is possible at all.
CloudAppEvents
| where ObjectName == "Set-HostedOutboundSpamFilterPolicy"
| mv-expand parse_json(ActivityObjects)
| where ActivityObjects.Name == 'AutoForwardingMode'
| extend Setting = tostring(ActivityObjects.Name)
| extend Configuration = tostring(ActivityObjects.Value)
| extend Description = case(
Configuration == "Automatic", "System-controlled: Default value. Same as Off — forwarding is disabled.",
Configuration == "On", "Forwarding is enabled: Automatic external forwarding is allowed and not restricted.",
Configuration == "Off", "Forwarding is disabled: Automatic external forwarding is blocked and results in an NDR to the sender.",
"Unknown"
)
| project TimeGenerated, Setting,Configuration,Description
Bert-Jan Pals · Week 10 · Mar 2-9 ·
DeviceNetworkEvents
Scores outbound connections for beaconing using device count, connection count and global prevalence thresholds you can tune to your own noise floor.
let DeviceThreshold = 5;
let ConnectionThreshold = 25;
let GlobalPrevalanceThreshold = 250;
DeviceNetworkEvents
| where TimeGenerated > ago(7d)
| where not(ipv4_is_private(RemoteIP))
| where ActionType == 'ConnectionSuccessAggregatedReport'
| extend Connections = toint(parse_json(AdditionalFields).uniqueEventsAggregated)
| summarize Total = count(), Devices = dcount(DeviceId), Domains = make_set(RemoteUrl), AvgConnections = avg(Connections) by RemoteIP, bin(TimeGenerated, 1d)
| where AvgConnections >= ConnectionThreshold and Devices <= DeviceThreshold
| join kind=inner (DeviceNetworkEvents
| where ActionType == 'ConnectionSuccess'
| distinct RemoteIP, InitiatingProcessSHA256) on RemoteIP
| invoke FileProfile(InitiatingProcessSHA256)
| where GlobalPrevalence <= GlobalPrevalanceThreshold
Bert-Jan Pals · Week 9 · Feb 23-Mar 2 ·
EntraIdSignInEvents
Parses the sign-in user agent into an operating system breakdown, useful for finding stale or unmanaged client platforms before you tighten Conditional Access.
EntraIdSignInEvents
| where isnotempty(UserAgent)
// Filter for successful sign ins only
| where ErrorCode == 0
| extend ParsedAgent = parse_json(parse_user_agent(UserAgent, "os"))
| extend OperatingSystem = strcat(tostring(ParsedAgent.OperatingSystem.Family), " ", tostring(ParsedAgent.OperatingSystem.MajorVersion))
| summarize Total = count() by OperatingSystem
| sort by Total
Benjamin Zulliger · Week 9 · Feb 23-Mar 2 ·
DeviceProcessEvents
Detects systemctl and service commands aimed at syslog and rsyslog on Linux endpoints, an anti-forensics step that usually precedes something worse.
// Attempt to Disable Syslog Service
DeviceProcessEvents
| where ProcessCommandLine has_any ("syslog", "rsyslog", "syslog-ng", "syslog.service", "rsyslog.service", "syslog-ng.service")
| where FileName in~ ("systemctl", "service", "chkconfig", "update-rc.d")
| where (
(FileName =~ "systemctl" and ProcessCommandLine has_any ("disable", "stop", "kill", "mask")) or
(FileName =~ "service" and ProcessCommandLine has "stop") or
(FileName =~ "chkconfig" and ProcessCommandLine has "off") or
(FileName =~ "update-rc.d" and ProcessCommandLine has_any ("remove", "disable"))
)
// Exclude known log rotation or HUP signals
| where InitiatingProcessFileName !~ "rsyslog-rotate"
| where ProcessCommandLine !has "HUP"
Alex Verboon · Week 8 · Feb 16-23 ·
DeviceEvents
Pulls the LOLDrivers catalogue at query time and checks it against ASR vulnerable-driver block events, showing which known-bad drivers actually got blocked in your estate.
let LOLDrivers = externaldata (Category:string, KnownVulnerableSamples:dynamic, Verified:string ) [h@"https://www.loldrivers.io/api/drivers.json"]
with (format=multijson, ingestionMapping='[{"Column":"Category","Properties":{"Path":"$.Category"}},{"Column":"KnownVulnerableSamples","Properties":{"Path":"$.KnownVulnerableSamples"}},{"Column":"Verified","Properties":{"Path":"$.Verified"}}]')
| mv-expand KnownVulnerableSamples
| extend SHA1 = tostring(KnownVulnerableSamples.SHA1), SHA256 = tostring(KnownVulnerableSamples.SHA256)
;
DeviceEvents
| where ActionType == @"AsrVulnerableSignedDriverBlocked"
| project Timestamp, DeviceName, FileName, SHA256,SHA1, FolderPath
| join kind=inner (LOLDrivers | where isnotempty(SHA256)) on SHA256
| union (
DeviceEvents
| where ActionType == @"AsrVulnerableSignedDriverBlocked"
| join kind=inner (LOLDrivers | where isnotempty(SHA1)) on SHA1
)
Benjamin Zulliger · Week 8 · Feb 16-23 ·
DeviceProcessEvents
Targets the ClickFix living-off-the-land chain, wscript and cscript running SyncAppvPublishingServer.vbs and its siblings, from process telemetry.
//THX to Maurice Fielenbach https://www.linkedin.com/posts/mauricefielenbach_threathunting-dfir-cybersecurity-activity-7429953431588659200-c0zE/
DeviceProcessEvents
| where Timestamp >= ago(7d)
| where (
(FileName in~ ("wscript.exe", "cscript.exe") and ProcessCommandLine has "SyncAppvPublishingServer.vbs")
or (FileName =~ "wmic.exe" and ProcessCommandLine has_any ("process", "call", "create"))
or (FileName =~ "ssh.exe" and ProcessCommandLine has "ProxyCommand")
)
| where ProcessCommandLine has_any (
"gal", "i*x",
"gcm", "*stM*",
"jsdelivr.net",
"github",
"powershell"
)
C.J. May · Week 7 · Feb 9-16 ·
DeviceNetworkEvents
Reconstructs the IP history of a single device from network telemetry, the lookup you want when an alert gives you an address and a timestamp but no hostname.
let QueryDevice = "devicename";
DeviceNetworkEvents
| where DeviceName startswith QueryDevice
| where LocalIP !in ("", "::", "::1", "127.0.0.1", "0.0.0.0")
| where LocalIP !startswith "::ffff:"
| where LocalIP != RemoteIP
| where ActionType !in ("ListeningConnectionCreated", "ConnectionFailed") // only successful peer connections are evidence
| where ActionType !endswith "Inspected" // bad data -- Zeek flips RemoteIP and LocalIP sometimes
| extend AccurateDirection = tostring(parse_json(AdditionalFields).direction)
| extend EstimatedDirection = iff(LocalPort > RemotePort, "Out", "In") // rarely wrong; needed when sensor isn't logging directionality
| extend Direction = iff(AccurateDirection != "", AccurateDirection, EstimatedDirection)
| summarize
Start=min(Timestamp),
End=max(Timestamp),
ConnectionCount=count(),
InboundConnections=countif(Direction=="In"),
OutboundConnections=countif(Direction!="In"),
Peers=make_set(RemoteIP)
by LocalIP
| where OutboundConnections > 0 // wrong directionality captured by EstimatedDirection
| sort by Start desc
Nathan Hutchinson · Week 7 · Feb 9-16 ·
DeviceTvmInfoGathering
Lists recently onboarded devices that never ran the initial full antivirus scan Microsoft recommends after MDE onboarding.
// All credit goes to Felix Brand - Taken from his post (https://www.linkedin.com/posts/felix-brand_defenderxdr-kql-microsoft-activity-7228749401849040896-0sNk)
// Microsoft now recommends (https://lnkd.in/ewBryvhF) to have one initial full scan when you onboard your system into MDE. But MDE does not trigger a full scan after onboarding, this KQL query combined with a custom detection rule can trigger a scan.
// Once a scan is completed on an endpoint, it will not appear in this query again.
let AvModeDescription = dynamic({"0":"Normal", "1":"Passive", "4":"EDR Block"});
let TimeRange = ago(1d);
DeviceTvmInfoGathering
| where Timestamp > TimeRange
| extend AdditionalFields = parse_json(AdditionalFields)
| extend AvMode = tostring(AvModeDescription[tostring(AdditionalFields.["AvMode"])])
| extend FullScanStatus = coalesce(extractjson("$.Full.ScanStatus", tostring(AdditionalFields.AvScanResults)),"Not available")
| where isnotempty( AvMode ) and AvMode has "Normal"
| where FullScanStatus !has "Completed"
| join DeviceEvents on DeviceName
| summarize arg_max(Timestamp, *) by DeviceName, DeviceId
| project DeviceName, DeviceId, FullScanStatus, ReportId, Timestamp
| take 50
Jay Kerai · Week 6 · Feb 2-9 ·
DeviceFileEvents
Matches browser extension files written to disk against a public list of malicious extension IDs, covering an install path that rarely gets monitored.
//Credit https://github.com/toborrm9/malicious_extension_sentry
let MaliciousExtensions = externaldata (ExtensionID: string) [@'https://raw.githubusercontent.com/toborrm9/malicious_extension_sentry/refs/heads/main/Malicious-Extensions.csv'] with (format=txt, ignoreFirstRecord = true)
| extend ExtensionID = split(ExtensionID,",")
| mv-expand ExtensionID
| extend ExtensionID = tostring(ExtensionID);
DeviceFileEvents
| where TimeGenerated > ago(90d)
| where ActionType == "FileCreated"
| where FileName endswith ".crx"
//| where InitiatingProcessFileName == "chrome.exe" //if you need to filter down to chrome vs edge
| where FolderPath contains "Webstore Downloads"
| extend ExtensionID = trim_end(@"_\d{2,6}.crx", FileName)
| extend ExtensionURL = strcat("https://chrome.google.com/webstore/detail/",ExtensionID)
| extend EdgeExtensionURL = strcat("https://microsoftedge.microsoft.com/addons/detail/",ExtensionID)
| summarize count() by ExtensionID,ExtensionURL, EdgeExtensionURL
| join kind=leftouter MaliciousExtensions on ExtensionID //if name is present in the risky list present it
Benjamin Zulliger · Week 6 · Feb 2-9 ·
DeviceRegistryEvents
Watches Image File Execution Options and SilentProcessExit registry keys, a debugger-hijack persistence technique that also doubles as a way to kill security tooling.
let ExludedInitiatingProcessFileNames = ("ExludedProcessFilesName.exe");
DeviceRegistryEvents
| where InitiatingProcessFileName !in (ExludedInitiatingProcessFileNames)
| where RegistryKey has_any (
@"Microsoft\Windows NT\CurrentVersion\Image File Execution Options",
@"Microsoft\Windows NT\CurrentVersion\SilentProcessExit"
)
| where RegistryValueName has_any ("Debugger", "MonitorProcess", "ReportingMode", "GlobalFlag")
| extend TargetProcess = tostring(split(RegistryKey, @"\")[8])
Nathan Hutchinson · Week 5 · Jan 26-Feb 2 ·
AuditLogs, Operation
Catches the Azure RBAC elevate-access toggle, the one-click path from Global Administrator to User Access Administrator over every subscription in the tenant.
AuditLogs
| where Category == "AzureRBACRoleManagementElevateAccess"
| where ActivityDisplayName == "User has elevated their access to User Access Administrator for their Azure Resources"
| extend Actor = tostring(InitiatedBy.user.displayName)
| extend ActorUPN = tostring(InitiatedBy.user.userPrincipalName)
| extend ActorId = tostring(InitiatedBy.user.id)
| extend Operation = tostring(OperationName)
| extend IPAddress = tostring(InitiatedBy.user.ipAddress)
| extend AppId = tostring(InitiatedBy.app.appId)
| project TimeGenerated, ActivityDisplayName, Category, Operation, Actor, ActorUPN, ActorId, IPAddress, AppId, Result, ResultReason, CorrelationId
| order by TimeGenerated desc
Robbe Van den Daele · Week 5 · Jan 26-Feb 2 ·
ExposureGraphNodes, IdentityInfo
Joins IdentityInfo against the exposure graph to list accounts flagged with leaked credentials, sorted so critical identities surface first.
IdentityInfo
| summarize arg_max(TimeGenerated, AccountUpn, AccountDisplayName, AccountDomain, CriticalityLevel, DistinguishedName) by AccountObjectId
| join kind=inner (
ExposureGraphNodes
// Get accounts with Leaked Credentials
| where NodeProperties.rawData.hasAdLeakedCredentials == "true" or NodeProperties.rawData.hasLeakedCredentials == "true"
// Get the AAD Object ID
| mv-expand EntityIds
| where EntityIds.type == "AadObjectId"
| extend AccountObjectId = extract('objectid=(.*)', 1, tostring(EntityIds.id))
| extend HasAdLeakedCredentials = tostring(NodeProperties.rawData.hasAdLeakedCredentials),
HasLeakedCredentials = tostring(NodeProperties.rawData.hasLeakedCredentials)
| distinct NodeLabel, AccountObjectId, HasAdLeakedCredentials, HasLeakedCredentials
) on AccountObjectId
Daniel Card · Week 4 · Jan 19-26 ·
AuditLogs, EntraIdSignInEvents
Baselines the countries each user normally signs in from over 90 days, then surfaces authenticator enrollments that happen outside that baseline.
let Lookback=90d;
let CountryBaseline=EntraIdSignInEvents
| where Timestamp > ago(Lookback)
| where isnotempty(AccountUpn)
| where isnotempty(IPAddress)
| extend Geo=geo_info_from_ip_address(IPAddress)
| extend CountryName=tostring(Geo["country"])
| where isnotempty(CountryName)
| summarize BaselineCountries=make_set(CountryName,256),BaselineCountryCount=dcount(CountryName) by AccountUpn;
AuditLogs
| where TimeGenerated > ago(Lookback)
| extend Details=tostring(AdditionalDetails)
| where Details has_any ("Microsoft Authenticator","PhoneAppNotification","PhoneAppOTP","Authenticator")
| extend Timestamp=TimeGenerated,
AccountUpn=tostring(TargetResources[0].userPrincipalName),
InitiatorUpn=coalesce(tostring(InitiatedBy.user.userPrincipalName),tostring(InitiatedBy.app.displayName)),
InitiatorIP=coalesce(tostring(InitiatedBy.user.ipAddress),tostring(InitiatedBy.app.ipAddress))
| extend Geo=iff(isempty(InitiatorIP),dynamic(null),geo_info_from_ip_address(InitiatorIP))
| extend Country=tostring(Geo["country"]),
Latitude=todouble(Geo["latitude"]),
Longitude=todouble(Geo["longitude"])
| join kind=leftouter (CountryBaseline) on $left.AccountUpn==$right.AccountUpn
| extend BaselineCountries=coalesce(BaselineCountries,dynamic([]))
| extend HasBaseline=array_length(BaselineCountries)>0
| extend HasGeo=isnotempty(Country)
| extend IsNewCountry=iff(HasGeo==false,bool(null),not(set_has_element(BaselineCountries,Country)))
| extend RiskLevelAggregated=case(Result !~ "success",100,HasGeo==false,50,HasBaseline==false,50,IsNewCountry==true,50,0)
| extend RiskLevel=case(RiskLevelAggregated==100,"High",RiskLevelAggregated==50,"Medium",RiskLevelAggregated==10,"Low","None/Unknown")
| extend RiskReason=case(Result !~ "success","Audit operation not success",HasGeo==false,"No geo data for initiator IP",HasBaseline==false,"No 90d country baseline for user",IsNewCountry==true,"New country vs 90d sign-in baseline","Within baseline")
| project Timestamp,AccountUpn,InitiatorUpn,IPAddress=InitiatorIP,Country,Latitude,Longitude,BaselineCountryCount,BaselineCountries,IsNewCountry,RiskLevelAggregated,RiskLevel,RiskReason,OperationName,Result,CorrelationId,AdditionalDetails
| sort by Timestamp desc
Jay Kerai · Week 3 · Jan 12-19 ·
DeviceImageLoadEvents
Summarises loaded DLLs by certificate signer so unsigned or oddly signed modules stand out against the normal signer set in your estate.
DeviceImageLoadEvents
| where TimeGenerated > ago(90d) //change to 30d if using advanced hunting with no sentinel
| where FileName contains ".dll"
| join kind=leftouter DeviceFileCertificateInfo on $left.SHA1 == $right.SHA1
| where FileName contains ".dll"
| summarize make_set(FileName) by Signer
Bert-Jan Pals · Week 3 · Jan 12-19 ·
DeviceNetworkEvents
Pulls an external C2 domain feed with externaldata and matches it against DeviceNetworkEvents, a cheap way to add threat intel without a connector.
// Collect Remote data
let C2IntelFeeds = externaldata(Domain: string, ioc:string, path:string, IP:string)[@"https://raw.githubusercontent.com/drb-ra/C2IntelFeeds/master/feeds/domainC2swithURLwithIP.csv"] with (format="csv", ignoreFirstRecord=True);
// Generate list that can be used to filter DeviceNetworkEvents
let DomainList = C2IntelFeeds
| distinct Domain;
DeviceNetworkEvents
// Filter only on C2 Domains
| extend ToLowerUrl = tolower(RemoteUrl)
| where RemoteUrl has_any (DomainList)
// Join the C2IntelFeed information for enrichment
| join kind=leftouter C2IntelFeeds on $left.RemoteIP == $right.IP
| extend GeoIPInfo = geo_info_from_ip_address(RemoteIP)
| extend country = tostring(parse_json(GeoIPInfo).country), state = tostring(parse_json(GeoIPInfo).state), city = tostring(parse_json(GeoIPInfo).city), latitude = tostring(parse_json(GeoIPInfo).latitude), longitude = tostring(parse_json(GeoIPInfo).longitude)
| project-reorder TimeGenerated, DeviceName, RemoteIP, RemotePort, RemoteUrl
Robbe Van den Daele · Week 2 · Jan 5-12 ·
DeviceNetworkEvents, DeviceProcessEvents
Joins network connections back to their initiating process to catch msiexec spawning a child that then calls out, the classic MSI-delivered loader pattern.
DeviceNetworkEvents
| where TimeGenerated > ago(1h)
| where InitiatingProcessParentFileName =~ "msiexec.exe"
| join kind=inner (
DeviceProcessEvents
| where Timestamp > ago(3d)
| where InitiatingProcessFileName =~ "msiexec.exe"
) on DeviceId,
$left.InitiatingProcessParentId == $right.InitiatingProcessId,
$left.InitiatingProcessParentCreationTime == $right.InitiatingProcessCreationTime
| where InitiatingProcessCommandLine1 has_any ("/y", "-y", "/z", "-z")
Bert-Jan Pals · Week 2 · Jan 5-12 ·
DeviceRegistryEvents
Reads the RunMRU registry key for encoded PowerShell, curl and mshta invocations, which is where ClickFix-style paste-and-run lures leave their fingerprint.
let Parameters = dynamic(['http', 'https', 'Encoded', 'EncodedCommand', '-e', '-eC', '-enc', "-w", '-i', '/i','/e', '/eC', '/enc', "/w", 'wind', 'nop', 'DownloadString', 'FromBase64String', 'iwr', '$env']);
let Executables = dynamic(["cmd", "powershell", "curl", "mshta", "msiexec", 'SyncAppvPublishingServer']);
DeviceRegistryEvents
| where ActionType == "RegistryValueSet"
| where RegistryKey has "RunMRU"
| where RegistryValueData has_any (Parameters) and RegistryValueData has_any (Executables)
| project-reorder TimeGenerated, DeviceId, DeviceName, RegistryValueData, RegistryKey
Benjamin Zulliger · Week 1 · Dec 29-Jan 5 ·
DeviceFileEvents
Watches DeviceFileEvents for writes into the macOS SecurityAgentPlugins and LoginHook paths, a persistence spot that survives reboots and rarely changes on a managed Mac fleet.
let ExcludedBinaries = dynamic(["acrappyAPP"]);
DeviceFileEvents
| where Timestamp > ago(24h)
| where FolderPath has_any (
"/Library/Security/SecurityAgentPlugins/",
"/System/Library/CoreServices/SecurityAgentPlugins/",
"/Library/Security/SecurityAgentPlugins.bundle"
)
or (FolderPath contains "/loginwindow" and ActionType in ("FileCreated", "FileModified"))
| where InitiatingProcessFileName !in ("installer", "softwareupdated", "installd")
| invoke FileProfile(SHA1)
| where GlobalPrevalence < 10000
Thomas Kurth · Week 1 · Dec 29-Jan 5 ·
IntuneAuditLogs
Flags an operator wiping or retiring more than five Intune devices in an hour, the shape a compromised endpoint admin account leaves behind.
let WipeThreashold = 5; // A normal engineer is not wiping more than 5 devices per hour. Can be adjusted for the environment.
IntuneAuditLogs
| where OperationName in('wipe ManagedDevice','retire ManagedDevice')
| where ResultType == 'Success'
| where isnotempty(Properties)
| extend Targets = extract_json("$.Targets", Properties, typeof(dynamic))
| extend TargetCount= array_length(Targets)
| extend Actor = extract_json("$.Actor", Properties, typeof(string))
| extend AccountObjectId = extract_json("$.ObjectId", Actor, typeof(guid))
| summarize TotalWipeOrRetireTargetCount=sum(TargetCount), TimeGenerated=min(TimeGenerated), Identity=min(Identity) by AccountObjectId
| where TotalWipeOrRetireTargetCount > WipeThreashold
No query matches that filter.