Detection rules › Kusto
Potentially Relayed NTLM Authentication - Microsoft Defender for Endpoint
The below query detects NTLM logons where Network Address in the logon event doesn't match the Workstation Name's IP. This indicates potentially relayed NTLM authentication. It analyzes only the logons with domain accounts having admin privileges.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access | No specific technique |
Event coverage
| Provider | Event/ActionType | Title |
|---|---|---|
| Security-Auditing | Event ID 4624 | An account was successfully logged on. |
| Defender-DeviceLogonEvents | LogonSuccess | Logon succeeded |
Rule body kusto
// Author : Cyb3rMonk(https://twitter.com/Cyb3rMonk, https://mergene.medium.com)
//
// Link to original post:
// https://posts.bluraven.io/detecting-ntlm-relay-attacks-d92e99e68fb9
//
// Description: This query detects NTLM logons where RemoteIP in the logon event doesn't match the RemoteDevice's IP.
// This indicates potentially relayed NTLM authentication. The query analyzes only the logons with domain accounts having admin privileges.
//
// Query parameters:
//
let lookup_window = 24h;
let baseline_window = 7d;
// Specify domains in NETBIOS name and full domain format
let domains = dynamic(["PUT YOUR AD DOMAINS HERE!", "contoso","contoso.local"]);
// Exclude authentications coming from device performing SNAT.
let SNAT_Subnets = datatable (subnet:string)
[
"1.0.0.0/26", "1.1.1.1/32"
];
// Generate list of all known(enrolled) Devices
let all_devices = toscalar (
DeviceInfo
| where Timestamp > ago(baseline_window)
| summarize make_set(DeviceName)
);
// Create a baseline for known NTLM authentication events.
// This will be used for removing the potential false positives.
let baseline = materialize (
DeviceLogonEvents
| where Timestamp > ago(baseline_window) and Timestamp < ago(lookup_window)
| where ActionType == "LogonSuccess"
| where LogonType == "Network"
| where Protocol=="NTLM"
| where isnotempty(RemoteDeviceName) and isnotempty(RemoteIP)
| where RemoteIPType <> "Loopback"
| where AdditionalFields !has '{"IsLocalLogon":true}' // exclude local(interactive) logon
| where AccountName !has RemoteDeviceName // exclude computer account logon
| where AccountDomain in~ (domains) // get only the logons with domain accounts
| distinct DeviceName, RemoteDeviceName, AccountName, RemoteIP
);
// Generate list of servers (assuming NTLM relay is performed towards servers)
let servers = materialize (
DeviceInfo
| where Timestamp > ago(baseline_window)
| where DeviceType == "Server"
| summarize make_set(DeviceName)
);
// Get logons to servers with LocalAdmin rights
DeviceLogonEvents
| where Timestamp > ago(lookup_window)
| where ActionType == "LogonSuccess"
| where DeviceName in (servers)
| where LogonType == "Network"
| where IsLocalAdmin == 1
| project TimestampX=Timestamp, DeviceIdX=DeviceId, DeviceName,AccountName,IsLocalAdmin
// Join LocalAdmin logons with NTLM logons. LocalAdmin logon events don't have logonID, Protocol, etc.,
// use time window join.
| join kind=inner
(
DeviceLogonEvents
| where Timestamp > ago(lookup_window)
| where ActionType == "LogonSuccess"
| where LogonType == "Network"
| where Protocol=="NTLM"
| where isnotempty(RemoteDeviceName) and isnotempty(RemoteIP)
| where RemoteIPType <> "Loopback"
| where AdditionalFields !has '{"IsLocalLogon":true}' // exclude local(interactive) logon
| where AccountName !has RemoteDeviceName // exclude computer account logon
| where AccountDomain in~ (domains) // get only the logons with domain accounts
)
on $left.DeviceIdX==$right.DeviceId, AccountName
| where abs(datetime_diff('second', Timestamp, TimestampX)) < 15 // time window condition
| summarize arg_max(Timestamp,*) by DeviceId, LogonId // get last event for each logonID
// Filter logons that are not in the baseline(unknown/new logons)
| join kind=leftanti baseline on DeviceName, RemoteDeviceName, AccountName, RemoteIP
// Filter events where there is no corresponding IP address for the RemoteDeviceName
| join kind=leftanti
(
DeviceNetworkInfo
| where Timestamp > ago(lookup_window)
| mv-expand todynamic(IPAddresses)
| extend DvcIP = tostring(IPAddresses.IPAddress)
| summarize arg_max(Timestamp,*) by DeviceId, DvcIP // get last report event for each IP
| project DeviceId, DeviceName=replace(@'([A-z0-9-]+)\.?.*',@'\1',DeviceName), ReportTimestamp = Timestamp, DvcIP, IPAddresses
)
on $left.RemoteDeviceName==$right.DeviceName, $left.RemoteIP==$right.DvcIP // filter condition
// Get last logon event (remove duplication)
| summarize arg_max(Timestamp,*), count() by DeviceId, AccountName, RemoteDeviceName, RemoteIP
// Get only the logons originated from a known(enrolled) device.
| where all_devices has RemoteDeviceName
// Exclude SNAT subnets
// ipv4 lookup doesn't have notmatch condition.
| evaluate ipv4_lookup(SNAT_Subnets, RemoteIP, subnet, return_unmatched = true)
| where isempty(subnet) // remove results that matched a SNAT subnet.
| extend Origin = RemoteDeviceName, RelayingDeviceIP = RemoteIP, Target = DeviceName
| project-away TimestampX, DeviceIdX, AccountName1, DeviceName1
| project-reorder Timestamp, Origin, RelayingDeviceIP, Target, AccountName
Stages and Predicates
Parameters
let lookup_window = 24h;
let baseline_window = 7d;
let domains = dynamic(["PUT YOUR AD DOMAINS HERE!", "contoso","contoso.local"]);
Let binding: SNAT_Subnets
let SNAT_Subnets = datatable (subnet:string)
[
"1.0.0.0/26", "1.1.1.1/32"
];
Let binding: all_devices
let all_devices = toscalar (
DeviceInfo
| where Timestamp > ago(baseline_window)
| summarize make_set(DeviceName)
);
Derived from baseline_window.
Let binding: baseline
let baseline = materialize (
DeviceLogonEvents
| where Timestamp > ago(baseline_window) and Timestamp < ago(lookup_window)
| where ActionType == "LogonSuccess"
| where LogonType == "Network"
| where Protocol=="NTLM"
| where isnotempty(RemoteDeviceName) and isnotempty(RemoteIP)
| where RemoteIPType <> "Loopback"
| where AdditionalFields !has '{"IsLocalLogon":true}'
| where AccountName !has RemoteDeviceName
| where AccountDomain in~ (domains)
| distinct DeviceName, RemoteDeviceName, AccountName, RemoteIP
);
Derived from lookup_window, baseline_window, domains.
Let binding: servers
let servers = materialize (
DeviceInfo
| where Timestamp > ago(baseline_window)
| where DeviceType == "Server"
| summarize make_set(DeviceName)
);
Derived from baseline_window.
Stage 1: source
DeviceLogonEvents
Stage 2: where
| where Timestamp > ago(lookup_window)
Stage 3: where
| where ActionType == "LogonSuccess"
Stage 4: where
| where DeviceName in (servers)
References servers (defined above).
Stage 5: where
| where LogonType == "Network"
Stage 6: where
| where IsLocalAdmin == 1
Stage 7: project
| project TimestampX=Timestamp, DeviceIdX=DeviceId, DeviceName,AccountName,IsLocalAdmin
Stage 8: join
| join kind=inner
(
DeviceLogonEvents
| where Timestamp > ago(lookup_window)
| where ActionType == "LogonSuccess"
| where LogonType == "Network"
| where Protocol=="NTLM"
| where isnotempty(RemoteDeviceName) and isnotempty(RemoteIP)
| where RemoteIPType <> "Loopback"
| where AdditionalFields !has '{"IsLocalLogon":true}'
| where AccountName !has RemoteDeviceName
| where AccountDomain in~ (domains)
)
on $left.DeviceIdX==$right.DeviceId, AccountName
Stage 9: where
| where abs(datetime_diff('second', Timestamp, TimestampX)) < 15
Stage 10: summarize
| summarize arg_max(Timestamp,*) by DeviceId, LogonId
Stage 11: join (negated)
| join kind=leftanti baseline on DeviceName, RemoteDeviceName, AccountName, RemoteIP
Stage 12: join (negated)
| join kind=leftanti
(
DeviceNetworkInfo
| where Timestamp > ago(lookup_window)
| mv-expand todynamic(IPAddresses)
| extend DvcIP = tostring(IPAddresses.IPAddress)
| summarize arg_max(Timestamp,*) by DeviceId, DvcIP
| project DeviceId, DeviceName=replace(@'([A-z0-9-]+)\.?.*',@'\1',DeviceName), ReportTimestamp = Timestamp, DvcIP, IPAddresses
)
on $left.RemoteDeviceName==$right.DeviceName, $left.RemoteIP==$right.DvcIP
Stage 13: summarize
| summarize arg_max(Timestamp,*), count() by DeviceId, AccountName, RemoteDeviceName, RemoteIP
Stage 14: where
| where all_devices has RemoteDeviceName
References all_devices (defined above).
Stage 15: evaluate
| evaluate ipv4_lookup(SNAT_Subnets, RemoteIP, subnet, return_unmatched = true)
Stage 16: where
| where isempty(subnet)
Stage 17: extend
| extend Origin = RemoteDeviceName, RelayingDeviceIP = RemoteIP, Target = DeviceName
Stage 18: project-away
| project-away TimestampX, DeviceIdX, AccountName1, DeviceName1
Stage 19: project-reorder
| project-reorder Timestamp, Origin, RelayingDeviceIP, Target, AccountName
Exclusions
Top-level NOT(...) conjuncts: predicates this rule actively suppresses.
| Field | Kind | Excluded values |
|---|---|---|
AccountName | match | RemoteDeviceName |
AdditionalFields | match | {"IsLocalLogon":true} |
AccountDomain | in | PUT YOUR AD DOMAINS HERE!, contoso, contoso.local |
ActionType | eq | LogonSuccess |
LogonType | eq | Network |
Protocol | eq | NTLM |
RemoteDeviceName | is_not_null | |
RemoteIP | is_not_null | |
RemoteIPType | ne | Loopback |
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 |
|---|---|---|
AccountDomain | in |
|
AccountName | match |
|
ActionType | eq |
|
AdditionalFields | match |
|
DeviceName | in |
|
IsLocalAdmin | eq |
|
LogonType | eq |
|
Protocol | eq |
|
RemoteDeviceName | is_not_null | |
RemoteIP | is_not_null | |
RemoteIPType | ne |
|
all_devices | match |
|
subnet | is_null |
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 |
|---|---|
AccountName | summarize |
DeviceId | summarize |
RemoteDeviceName | summarize |
RemoteIP | summarize |
Origin | extend |
RelayingDeviceIP | extend |
Target | extend |