Detection rules › Kusto

Potentially Relayed NTLM Authentication - Microsoft Sentinel

Group by
Account, Computer, IpAddress, SubjectLogonId, TargetLogonId, TargetUserName, WorkstationName
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 Network Address in the NTLM logon event doesn't match the Workstation Name's IP. 
//				      This indicates potentially relayed NTLM authentication. The query analyzes only the logons with domain accounts having admin privileges. 
//
// Query parameters:
//
let ingestion_delay = 2h;
let rule_frequency = 2h;
let lookback = 1d;
// 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.
// Exclude devices that always perform NTLM authentication
let SNAT_Subnets = datatable (subnet: string) [
    "1.0.0.0/26", "1.1.1.1/32"
];
// Get NTLM relay candidates
let NTLMRelayCandidates = materialize ( 
    SecurityEvent
    | where TimeGenerated > ago(rule_frequency + ingestion_delay)
    | where EventID == 4624
    | where AccountType == "User"
    | where AuthenticationPackageName == "NTLM"
    | where LogonType == 3
    | where TargetDomainName in~ (domains)
    | where isnotempty(IpAddress) and IpAddress !in ('-', '::1', '127.0.0.1')
    | where isnotempty(WorkstationName) and WorkstationName <> '-'
    | where IpPort <> 0 and Computer !has WorkstationName
    | where ElevatedToken <> '%%1843'// exclude non-admin logon sessions
    | extend delay = ingestion_time() - TimeGenerated
    | summarize hint.strategy=shuffle arg_max(TimeGenerated, *) by Computer, Account, IpAddress, WorkstationName
    // Machine logon events have the IP address of the machine, exclude results where the IPAddress in the NTLM logon matches the IPAddress in Machine logon event
    | join hint.strategy=shuffle kind=leftanti 
        (
        SecurityEvent
        | where TimeGenerated > ago(lookback)
        | where EventID == 4624
        | where AccountType == "Machine"
        | where isnotempty(IpAddress) and IpAddress !in ('-', '::1', '127.0.0.1')
        | distinct TargetUserName, IpAddress
        | extend TargetUserName = toupper(replace(@'([A-z0-9-]+)\.?.*', @'\1', TargetUserName))
        )
        on $left.WorkstationName == $right.TargetUserName, IpAddress // filter condition
        // Filter out excluded IP subnets.
        | evaluate ipv4_lookup(SNAT_Subnets, IpAddress, subnet, return_unmatched = true)
        | where isempty(subnet) // remove results that matched a SNAT subnet.
    )
;
// Windows 2012 doesn't have elevated token info in NTLM logon events.
// Filter relayed authentications where the session has admin privileges
let Computers=
    NTLMRelayCandidates| summarize make_set(Computer);
//
let Accounts = 
    NTLMRelayCandidates| summarize make_set(TargetUserName);
// There must be a 4672 event for an admin logon with the same logon id
NTLMRelayCandidates
| join hint.strategy=shuffle kind=inner 
    (
    SecurityEvent
    | where TimeGenerated > ago(rule_frequency + ingestion_delay) 
    | where Computer in (Computers)
    | where SubjectUserName in (Accounts)
    | where EventID == 4672
    | where AccountType == "User"
    | project Computer, Account, SubjectLogonId, PrivilegeList
    )
    on $left.TargetLogonId==$right.SubjectLogonId, Account, Computer
| extend Origin = WorkstationName, RelayingDeviceIP = IpAddress, Target = Computer
| project-reorder TimeGenerated, Computer, Origin, RelayingDeviceIP, Target, Account, PrivilegeList1
// more filtering can be done based on the privilege list, specific computers or accounts.

Stages and Predicates

Parameters

let ingestion_delay = 2h;
let rule_frequency = 2h;
let lookback = 1d;
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: Computers

let Computers = NTLMRelayCandidates| summarize make_set(Computer);

Derived from NTLMRelayCandidates.

Let binding: Accounts

let Accounts = NTLMRelayCandidates| summarize make_set(TargetUserName);

Derived from NTLMRelayCandidates.

The stages below define let NTLMRelayCandidates (the rule's main pipeline source).

Stage 1: source

let NTLMRelayCandidates

Stage 2: source

let Computers

Stage 3: source

let Accounts

Stage 4: source

SecurityEvent

Stage 5: where

| where TimeGenerated > ago(rule_frequency + ingestion_delay)

Stage 6: where

| where EventID == 4624

Stage 7: where

| where AccountType == "User"

Stage 8: where

| where AuthenticationPackageName == "NTLM"

Stage 9: where

| where LogonType == 3

Stage 10: where

| where TargetDomainName in~ (domains)

Stage 11: where

| where isnotempty(IpAddress) and IpAddress !in ('-', '::1', '127.0.0.1')

Stage 12: where

| where isnotempty(WorkstationName) and WorkstationName <> '-'

Stage 13: where

| where IpPort <> 0 and Computer !has WorkstationName

Stage 14: where

| where ElevatedToken <> '%%1843'

Stage 15: extend

| extend delay = ingestion_time() - TimeGenerated

Stage 16: summarize

| summarize hint.strategy=shuffle arg_max(TimeGenerated, *) by Computer, Account, IpAddress, WorkstationName

Stage 17: join (negated)

| join hint.strategy=shuffle kind=leftanti 
        (
        SecurityEvent
        | where TimeGenerated > ago(lookback)
        | where EventID == 4624
        | where AccountType == "Machine"
        | where isnotempty(IpAddress) and IpAddress !in ('-', '::1', '127.0.0.1')
        | distinct TargetUserName, IpAddress
        | extend TargetUserName = toupper(replace(@'([A-z0-9-]+)\.?.*', @'\1', TargetUserName))
        )
        on $left.WorkstationName == $right.TargetUserName, IpAddress

Stage 18: evaluate

| evaluate ipv4_lookup(SNAT_Subnets, IpAddress, subnet, return_unmatched = true)

Stage 19: where

| where isempty(subnet)

The stages below run on NTLMRelayCandidates (the outer pipeline).

Stage 20: join

NTLMRelayCandidates
| join hint.strategy=shuffle kind=inner 
    (
    SecurityEvent
    | where TimeGenerated > ago(rule_frequency + ingestion_delay) 
    | where Computer in (Computers)
    | where SubjectUserName in (Accounts)
    | where EventID == 4672
    | where AccountType == "User"
    | project Computer, Account, SubjectLogonId, PrivilegeList
    )
    on $left.TargetLogonId==$right.SubjectLogonId, Account, Computer

Stage 21: extend

| extend Origin = WorkstationName, RelayingDeviceIP = IpAddress, Target = Computer

Stage 22: project-reorder

| project-reorder TimeGenerated, Computer, Origin, RelayingDeviceIP, Target, Account, PrivilegeList1

Exclusions

Top-level NOT(...) conjuncts: predicates this rule actively suppresses.

FieldKindExcluded values
IpAddressin-, 127.0.0.1, ::1
ComputermatchWorkstationName
AccountTypeeqMachine
EventIDeq4624
IpAddressis_not_null(no value, null check)

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
AccountTypeeq
  • User transforms: cased corpus 9 (kusto 9)
AuthenticationPackageNameeq
  • NTLM transforms: cased corpus 9 (sigma 5, elastic 2, splunk 1, kusto 1)
Computerin
  • Computers transforms: cased
ElevatedTokenne
  • %%1843 transforms: cased
EventIDeq
  • 4624 transforms: cased corpus 25 (splunk 13, kusto 8, chronicle 4)
  • 4672 transforms: cased corpus 2 (splunk 1, kusto 1)
IpAddressin
  • - transforms: cased
  • 127.0.0.1 transforms: cased
  • ::1 transforms: cased
IpAddressis_not_null
  • (no value, null check)
IpPortne
  • 0 transforms: cased
LogonTypeeq
  • 3 transforms: cased corpus 40 (splunk 13, sigma 12, elastic 9, kusto 6)
SubjectUserNamein
  • Accounts transforms: cased
TargetDomainNamein
  • PUT YOUR AD DOMAINS HERE!
  • contoso
  • contoso.local
WorkstationNameis_not_null
  • (no value, null check)
WorkstationNamene
  • - transforms: cased
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
Accountsummarize
Computersummarize
IpAddresssummarize
WorkstationNamesummarize
Originextend
RelayingDeviceIPextend
Targetextend