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.

Event coverage

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

Stage 0: let

let lookup_window = 24h;
let baseline_window = 7d;
let domains = dynamic(["PUT YOUR AD DOMAINS HERE!", "contoso","contoso.local"]);
let SNAT_Subnets = datatable (subnet:string)
[
"1.0.0.0/26", "1.1.1.1/32"
];
let all_devices = toscalar (
    DeviceInfo
    | where Timestamp > ago(baseline_window)
    | summarize make_set(DeviceName)
    );
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 servers = materialize (
    DeviceInfo
    | where Timestamp > ago(baseline_window)
    | where DeviceType == "Server"
    | summarize make_set(DeviceName)
    );

Stage 1: source

DeviceLogonEvents

Stage 2: where time_window=86400s

| where Timestamp > ago(lookup_window)

Stage 3: where

| where ActionType == "LogonSuccess"

Stage 4: where

| where DeviceName in (servers)

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

| join kind=leftanti baseline on DeviceName, RemoteDeviceName, AccountName, RemoteIP

Stage 12: join

| 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

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.

StageFieldKindExcluded values
8AccountNamematchRemoteDeviceName
8AdditionalFieldsmatch{"IsLocalLogon":true}
11AccountNamematchRemoteDeviceName
11AdditionalFieldsmatch{"IsLocalLogon":true}

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.

FieldKindValues
AccountDomainin
  • PUT YOUR AD DOMAINS HERE!
  • contoso
  • contoso.local
ActionTypeeq
  • LogonSuccess transforms: cased corpus 3 (kusto 3)
DeviceNamein
  • servers
IsLocalAdmineq
  • 1 transforms: cased
LogonTypeeq
  • Network transforms: cased corpus 39 (splunk 13, sigma 12, elastic 9, kusto 5)
Protocoleq
  • NTLM transforms: cased corpus 2 (kusto 2)
RemoteDeviceNameis_not_null
  • (no value, null check)
RemoteIPis_not_null
  • (no value, null check)
RemoteIPTypene
  • Loopback transforms: cased corpus 2 (kusto 2)
all_devicesmatch
  • RemoteDeviceName
subnetis_null
  • (no value, null check)

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.

FieldSource
AccountNamesummarize
DeviceIdsummarize
RemoteDeviceNamesummarize
RemoteIPsummarize
Originextend
RelayingDeviceIPextend
Targetextend