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 |
Telemetry coverage
Rule body
// 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)
);
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
);
Let binding: servers
let servers = materialize (
DeviceInfo
| where Timestamp > ago(baseline_window)
| where DeviceType == "Server"
| summarize make_set(DeviceName)
);
Stage 1: source
let servers
Stage 2: source
DeviceLogonEvents
Stage 3: where
where Timestamp > ago(86400s)
Stage 4: where
where ActionType =~ "LogonSuccess"
Stage 5: where
where DeviceName =~ "servers"
Stage 6: where
where LogonType =~ "Network"
Stage 7: where
where IsLocalAdmin == 1
Stage 8: project
project AccountName, DeviceIdX, DeviceName, IsLocalAdmin, TimestampX
Stage 9: join
join kind=inner (DeviceLogonEvents) on DeviceIdX, DeviceId, AccountName
Stage 10: where
where /* macro: (abs(datetime_diff('second', Timestamp, TimestampX)) < 15) */
Stage 11: summarize
summarize by DeviceId, LogonId
Stage 12: join (negated)
join kind=leftanti (baseline) on DeviceName, RemoteDeviceName, AccountName, RemoteIP
Stage 13: join (negated)
join kind=leftanti (DeviceNetworkInfo) on RemoteDeviceName, DeviceName, RemoteIP, DvcIP
Stage 14: summarize
summarize by DeviceId, AccountName, RemoteDeviceName, RemoteIP
Stage 15: where
where all_devices =~ RemoteDeviceName
Stage 16: evaluate
evaluate
Stage 17: where
where isempty(subnet)
Stage 18: extend
extend Origin, RelayingDeviceIP, Target
Stage 19: project-away
project-away AccountName1, DeviceIdX, DeviceName1, TimestampX
Stage 20: project-reorder
project-reorder
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
AdditionalFields | match | {"IsLocalLogon":true} | excludes:AdditionalFields |
AccountDomain | in | PUT YOUR AD DOMAINS HERE!, contoso, contoso.local | excludes:AccountDomain field:"AccountDomain" value:"PUT YOUR AD DOMAINS HERE!" field:"AccountDomain" value:"contoso" field:"AccountDomain" value:"contoso.local" |
AccountName | cross_field_compare | RemoteDeviceName | excludes:AccountName field:"AccountName" value:"RemoteDeviceName" |
ActionType | eq | LogonSuccess | excludes:ActionType field:"ActionType" value:"LogonSuccess" |
LogonType | eq | Network | excludes:LogonType field:"LogonType" value:"Network" |
Protocol | eq | NTLM | excludes:Protocol field:"Protocol" value:"NTLM" |
RemoteDeviceName | is_not_null | excludes:RemoteDeviceName | |
RemoteIP | is_not_null | excludes:RemoteIP | |
RemoteIPType | ne | Loopback | excludes:RemoteIPType field:"RemoteIPType" value:"Loopback" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
AccountDomain | in |
| field:"TargetDomainName" kind:in |
AccountName | cross_field_compare |
| field:"user" kind:cross_field_compare value:"RemoteDeviceName" |
ActionType | eq |
| field:"ActionType" kind:eq value:"LogonSuccess" |
AdditionalFields | match |
| field:"AdditionalFields" kind:match |
DeviceName | in |
| field:"Computer" kind:in value:"servers" |
IsLocalAdmin | eq |
| field:"IsLocalAdmin" kind:eq value:"1" |
LogonType | eq |
| field:"LogonType" kind:eq value:"Network" |
Protocol | eq |
| field:"Protocol" kind:eq value:"NTLM" |
RemoteDeviceName | is_not_null | field:"RemoteDeviceName" kind:is_not_null | |
RemoteIP | is_not_null | field:"dest_ip" kind:is_not_null | |
RemoteIPType | ne |
| field:"RemoteIPType" kind:ne value:"Loopback" |
all_devices | cross_field_compare |
| field:"all_devices" kind:cross_field_compare value:"RemoteDeviceName" |
subnet | is_null | field:"subnet" kind:is_null |
Output fields
These fields are emitted when the rule matches.
| Field | Source |
|---|---|
AccountName | summarize |
DeviceId | summarize |
RemoteDeviceName | summarize |
RemoteIP | summarize |
Origin | extend |
RelayingDeviceIP | extend |
Target | extend |