Detection rules › Kusto

Potentially Relayed NTLM Authentication - Microsoft Defender for Endpoint

Group by
AccountName, DeviceId, DeviceIdX, DeviceName, DvcIP, LogonId, RemoteDeviceName, RemoteIP
Author
Cyb3rMonk
Source
github.com/Cyb3r-Monk/Threat-Hunting-and-Detection

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

TacticTechniques
Credential AccessNo 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 used in Stage 15

let all_devices = toscalar (
    DeviceInfo
    | where Timestamp > ago(baseline_window)
    | summarize make_set(DeviceName)
    );

Let binding: baseline used in Stage 12

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 used in Stages 1, 5

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.

FieldKindExcluded valuesSearch
AdditionalFieldsmatch{"IsLocalLogon":true}excludes:AdditionalFields
AccountDomaininPUT YOUR AD DOMAINS HERE!, contoso, contoso.localexcludes:AccountDomain field:"AccountDomain" value:"PUT YOUR AD DOMAINS HERE!" field:"AccountDomain" value:"contoso" field:"AccountDomain" value:"contoso.local"
AccountNamecross_field_compareRemoteDeviceNameexcludes:AccountName field:"AccountName" value:"RemoteDeviceName"
ActionTypeeqLogonSuccessexcludes:ActionType field:"ActionType" value:"LogonSuccess"
LogonTypeeqNetworkexcludes:LogonType field:"LogonType" value:"Network"
ProtocoleqNTLMexcludes:Protocol field:"Protocol" value:"NTLM"
RemoteDeviceNameis_not_null(no value, null check)excludes:RemoteDeviceName
RemoteIPis_not_null(no value, null check)excludes:RemoteIP
RemoteIPTypeneLoopbackexcludes:RemoteIPType field:"RemoteIPType" value:"Loopback"

Indicators

These rows show field, operator, and value matches.

Output fields

These fields are emitted when the rule matches.

FieldSource
AccountNamesummarize
DeviceIdsummarize
RemoteDeviceNamesummarize
RemoteIPsummarize
Originextend
RelayingDeviceIPextend
Targetextend