Detection rules › Kusto

NTLM Relay Attack

Group by
RemoteDeviceName, RemoteIP, ShortDeviceName
Author
FalconForce
Source
github.com/FalconForceTeam/FalconFriday

This query searches for successful NTLM network logins where the device name contained in the NTLM authentication message contains a device that is known to MDE, but the source IP address is different from the known source IP address for that specific device. This could indicate an attacker is relaying the NTLM authentication information. To remove false positives, this query also searches for an outgoing network connection from the initiator to the attacker.

MITRE ATT&CK coverage

References

Event coverage

Rule body kusto

let timeframe = 2*1d;
// Extract a list of known local IPs per device. Note that the DeviceNetworkEvents table is used for this, since this is faster than the
// DeviceNetworkInfo table where IP addresses are stored inside a JSON structure that requires additional parsing.
let DeviceIPs=(
    DeviceNetworkEvents
    | where ingestion_time() >= ago(timeframe)
    | where ActionType == "ConnectionAttempt" or ActionType == "ConnectionSuccess"
    | distinct DeviceName, LocalIP
    | extend DeviceName=tolower(split(DeviceName,".")[0])
);
// Find potential NTLM relay attack by looking for NTLM logins from devices that are known in MDE, but are from a source IP that does not match any known IP addresses for the device.
let PotentialNTLMRelayLogins=materialize (
    DeviceLogonEvents
    | where ingestion_time() >= ago(timeframe)
    | where ActionType == "LogonSuccess"
    | where LogonType == "Network"
    | where Protocol=="NTLM"
    | where isnotempty(RemoteDeviceName) and isnotempty(RemoteIP)
    | where RemoteIPType <> "Loopback"
    | extend RemoteDeviceName=tolower(RemoteDeviceName)
    | where RemoteDeviceName in ((DeviceIPs | project DeviceName)) // The remote device is known in MDE.
    | join kind=leftanti DeviceIPs on $left.RemoteIP == $right.LocalIP, $left.RemoteDeviceName == $right.DeviceName // The Remote IP does not match any known IP for the device.
    | project-reorder Timestamp, RemoteIP, RemoteDeviceName, AccountDomain, AccountName
);
// Filter the potential NTLM relay events by checking there was an outgoing SMB connection from the source device to the relay IP address.
DeviceNetworkEvents
| where ingestion_time() >= ago(timeframe)
| where RemotePort in (445, 80, 9389)
| where RemoteIP in ((PotentialNTLMRelayLogins | project RemoteIP))
| extend ShortDeviceName=tolower(split(DeviceName,".")[0])
| where ShortDeviceName in ((PotentialNTLMRelayLogins | project RemoteDeviceName))
| lookup kind=inner PotentialNTLMRelayLogins on $left.ShortDeviceName == $right.RemoteDeviceName, $left.RemoteIP == $right.RemoteIP
| extend HostName=tostring(split(DeviceName,".")[0]),DnsDomain=iif(DeviceName contains ".", substring(DeviceName, indexof(DeviceName, ".") + 1, strlen(DeviceName)),"")
// Begin environment-specific filter.
// End environment-specific filter.

Stages and Predicates

Parameters

let timeframe = 2*1d;

Let binding: DeviceIPs

let DeviceIPs = (
    DeviceNetworkEvents
    | where ingestion_time() >= ago(timeframe)
    | where ActionType == "ConnectionAttempt" or ActionType == "ConnectionSuccess"
    | distinct DeviceName, LocalIP
    | extend DeviceName=tolower(split(DeviceName,".")[0])
);

Derived from timeframe.

Let binding: PotentialNTLMRelayLogins

let PotentialNTLMRelayLogins = materialize (
    DeviceLogonEvents
    | where ingestion_time() >= ago(timeframe)
    | where ActionType == "LogonSuccess"
    | where LogonType == "Network"
    | where Protocol=="NTLM"
    | where isnotempty(RemoteDeviceName) and isnotempty(RemoteIP)
    | where RemoteIPType <> "Loopback"
    | extend RemoteDeviceName=tolower(RemoteDeviceName)
    | where RemoteDeviceName in ((DeviceIPs | project DeviceName))
    | join kind=leftanti DeviceIPs on $left.RemoteIP == $right.LocalIP, $left.RemoteDeviceName == $right.DeviceName
    | project-reorder Timestamp, RemoteIP, RemoteDeviceName, AccountDomain, AccountName
);

Derived from timeframe, DeviceIPs.

Stage 1: source

DeviceNetworkEvents

Stage 2: where

| where ingestion_time() >= ago(timeframe)

Stage 3: where

| where RemotePort in (445, 80, 9389)

Stage 4: where

| where RemoteIP in ((PotentialNTLMRelayLogins | project RemoteIP))

Stage 5: extend

| extend ShortDeviceName=tolower(split(DeviceName,".")[0])

Stage 6: where

| where ShortDeviceName in ((PotentialNTLMRelayLogins | project RemoteDeviceName))

Stage 7: kusto:lookup

| lookup kind=inner PotentialNTLMRelayLogins on $left.ShortDeviceName == $right.RemoteDeviceName, $left.RemoteIP == $right.RemoteIP

Stage 8: extend

| extend HostName=tostring(split(DeviceName,".")[0]),DnsDomain=iif(DeviceName contains ".", substring(DeviceName, indexof(DeviceName, ".") + 1, strlen(DeviceName)),"")

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
RemotePortin
  • 445 transforms: cased corpus 8 (elastic 5, splunk 2, sigma 1)
  • 80 transforms: cased corpus 10 (sigma 6, elastic 2, kusto 2)
  • 9389 transforms: cased corpus 5 (kusto 2, sigma 1, elastic 1, splunk 1)

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
ShortDeviceNameextend
DnsDomainextend
HostNameextend