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

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

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.

FieldKindExcluded values
AccountNamematchRemoteDeviceName
AdditionalFieldsmatch{"IsLocalLogon":true}
AccountDomaininPUT YOUR AD DOMAINS HERE!, contoso, contoso.local
ActionTypeeqLogonSuccess
LogonTypeeqNetwork
ProtocoleqNTLM
RemoteDeviceNameis_not_null(no value, null check)
RemoteIPis_not_null(no value, null check)
RemoteIPTypeneLoopback

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
AccountNamematch
  • RemoteDeviceName transforms: term
ActionTypeeq
  • LogonSuccess transforms: cased corpus 5 (kusto 5)
AdditionalFieldsmatch
  • {"IsLocalLogon":true} transforms: term
DeviceNamein
  • servers transforms: cased
IsLocalAdmineq
  • 1 transforms: cased
LogonTypeeq
  • Network transforms: cased corpus 40 (splunk 13, sigma 12, elastic 9, kusto 6)
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 transforms: term
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