Detection rules › Kusto
IP with multiple failed Microsoft Entra ID logins successfully logs in to Palo Alto VPN
This query creates a list of IP addresses with the number of failed login attempts to Entra ID above a set threshold ( default of 5 ). It then looks for any successful Palo Alto VPN logins from any of these IPs within the same timeframe.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | T1078 Valid Accounts |
| Credential Access | T1110 Brute Force |
Rule body kusto
id: ba144bf8-75b8-406f-9420-ed74397f9479
name: IP with multiple failed Microsoft Entra ID logins successfully logs in to Palo Alto VPN
description: |
This query creates a list of IP addresses with the number of failed login attempts to Entra ID
above a set threshold ( default of 5 ). It then looks for any successful Palo Alto VPN logins from any of these IPs within the same timeframe.
severity: Medium
requiredDataConnectors:
- connectorId: AzureActiveDirectory
dataTypes:
- SigninLogs
- connectorId: AzureActiveDirectory
dataTypes:
- AADNonInteractiveUserSignInLogs
- connectorId: PaloAltoNetworks
dataTypes:
- CommonSecurityLog
queryFrequency: 1d
queryPeriod: 1d
triggerOperator: gt
triggerThreshold: 0
tactics:
- InitialAccess
- CredentialAccess
relevantTechniques:
- T1078
- T1110
query: |
//Set a threshold of failed AAD signins from an IP address within 1 day above which we want to deem those logins suspicious.
let signin_threshold = 5;
//Make a list of IPs with AAD signin failures above our threshold.
let aadFunc = (tableName:string){
let suspicious_signins =
table(tableName)
//Looking for logon failure results
| where ResultType !in ("0", "50125", "50140")
//Exclude localhost addresses to reduce the chance of FPs
| where IPAddress !in ("127.0.0.1", "::1")
| summarize count() by IPAddress
| where count_ > signin_threshold
| summarize make_set(IPAddress);
suspicious_signins
};
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
let suspicious_signins =
union isfuzzy=true aadSignin, aadNonInt
| summarize make_set(set_IPAddress);
//See if any of those IPs have sucessfully logged into PA VPNs during the same timeperiod
CommonSecurityLog
//Select only PA VPN sucessful logons
| where DeviceVendor == "Palo Alto Networks" and DeviceEventClassID == "globalprotect"
| where Message has "GlobalProtect gateway user authentication succeeded"
//Parse out the logon source IP from the Message field to match on
| extend SourceIP = extract("Login from: ([^,]+)", 1, Message)
| where SourceIP in (suspicious_signins)
| extend Reason = "Multiple failed AAD logins from SourceIP"
//Parse out other useful information from Message field
| extend User = extract('User name: ([^,]+)', 1, Message)
| extend ClientOS = extract('Client OS version: ([^,\"]+)', 1, Message)
| extend Location = extract('Source region: ([^,]{2})',1, Message)
| project TimeGenerated, Reason, SourceIP, User, ClientOS, Location, Message, DeviceName, ReceiptTime, DeviceVendor, DeviceEventClassID, Computer, FileName
| extend timestamp = TimeGenerated
entityMappings:
- entityType: Account
fieldMappings:
- identifier: Name
columnName: User
- entityType: Host
fieldMappings:
- identifier: HostName
columnName: DeviceName
- entityType: IP
fieldMappings:
- identifier: Address
columnName: SourceIP
version: 1.0.4
kind: Scheduled
metadata:
source:
kind: Community
author:
name: Microsoft Security Research
support:
tier: Community
categories:
domains: [ "Security - Others", "Identity" ]
Stages and Predicates
Parameters
let signin_threshold = 5;
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
Let binding: aadFunc
let aadFunc = (tableName:string){
let suspicious_signins =
table(tableName)
| where ResultType !in ("0", "50125", "50140")
| where IPAddress !in ("127.0.0.1", "::1")
| summarize count() by IPAddress
| where count_ > signin_threshold
| summarize make_set(IPAddress);
Derived from signin_threshold, suspicious_signins.
union isfuzzy=true (2 sources)
Each leg below queries one source; the rule matches if any leg does. Sources: aadSignin, aadNonInt
Leg 1: aadSignin
Leg 2: aadNonInt
Applied to the combined result
| summarize make_set(set_IPAddress) | where DeviceVendor == "Palo Alto Networks" and DeviceEventClassID == "globalprotect" | where Message has "GlobalProtect gateway user authentication succeeded" | extend SourceIP = extract("Login from: ([^,]+)", 1, Message) | where SourceIP in (suspicious_signins) | extend Reason = "Multiple failed AAD logins from SourceIP" | extend User = extract('User name: ([^,]+)', 1, Message) | extend ClientOS = extract('Client OS version: ([^,\"]+)', 1, Message) | extend Location = extract('Source region: ([^,]{2})',1, Message) | project TimeGenerated, Reason, SourceIP, User, ClientOS, Location, Message, DeviceName, ReceiptTime, DeviceVendor, DeviceEventClassID, Computer, FileName | extend timestamp = TimeGenerated
Indicators
Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.
| Field | Kind | Values |
|---|---|---|
DeviceEventClassID | eq |
|
DeviceVendor | eq |
|
Message | match |
|
SourceIP | in |
|
Output fields
Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.
| Field | Source |
|---|---|
ClientOS | project |
Computer | project |
DeviceEventClassID | project |
DeviceName | project |
DeviceVendor | project |
FileName | project |
Location | project |
Message | project |
Reason | project |
ReceiptTime | project |
SourceIP | project |
TimeGenerated | project |
User | project |
timestamp | extend |