Detection rules › Kusto

Potentially Relayed NTLM Authentication - Microsoft Sentinel

Group by
Computer, IpAddress, ServiceName, TargetUserName
Author
Cyb3rMonk
Source
github.com/Cyb3r-Monk/Threat-Hunting-and-Detection

The below query detects Kerberos logons of computer accounts where there isn't any ticket request in the last 12h (10h is the default ticket expiration) coming from the same IpAddress with the same TargetUserName. The query can be enriched further if needed.

Event coverage

Rule body kusto

// Author       : Cyb3rMonk(https://twitter.com/Cyb3rMonk, https://mergene.medium.com)
//
// Link to original post:
// https://posts.bluraven.io/detecting-kerberos-relaying-e6be66fa647c
//
// Description: This query detects Kerberos logons of computer accounts where there isn't any ticket request in the last 12h (10h is the default ticket expiration) coming from the same IpAddress with the same TargetUserName. The query can be enriched further if needed. 
//
// Query parameters:
//
let Ticket_Requests = materialize ( 
SecurityEvent
| where TimeGenerated > ago(12h)
| where EventID == 4769
| where EventData has '<Data Name="Status">0x0</Data>'
| where EventData !has'<Data Name="IpAddress">::1</Data>'
| parse EventData with * 'TargetUserName">' TargetUserName '</Data' * 'TargetDomainName">' TargetDomainName '</Data' * 'ServiceName">' ServiceName '<' * 'IpAddress">::ffff:' IpAddress '<' * 'Status">' Status '<' *
| where TargetUserName !has ServiceName
| where TargetUserName contains "$"
| where ServiceName has "$"
| project TimeGenerated, TargetUserName=tolower(TargetUserName), TargetDomainName, ServiceName=tolower(replace_string(ServiceName, '$', '')), IpAddress, Status
)
;
let Suspicious_Logons = 
    Ticket_Requests
    | join kind=rightanti (
        SecurityEvent
        | where TimeGenerated > ago(1h)
        | where EventID == 4624
        | where AuthenticationPackageName == "Kerberos"
        | where IpAddress !in ('-', '::1', '127.0.0.1')
        | where IpAddress !startswith "169.254."
        | where Account endswith_cs "$"
        | project TimeGenerated, Computer = tolower(replace_regex(Computer, @'(\w+)\..*', @'\1')), Account, TargetUserName=tolower(TargetUserName), IpAddress
        | where TargetUserName !has Computer
        ) on IpAddress, $left.ServiceName==$right.Computer
        ;
Suspicious_Logons
| join kind=leftouter  (
    Ticket_Requests
    | extend TargetUserName = replace_regex(TargetUserName, @'(\w+\$)@.*', @'\1')
    ) on IpAddress, TargetUserName
| summarize FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated), count(), dcount(ServiceName) by TargetUserName, IpAddress
// Filter results
// we don't expect a successful ticket request coming from the rogue(attacker) device befor the relaying attack.
// If there is at least one ticket request coming from the suspicious IP with the same TargetUserName, assume it's a legitimate activity.
| where isempty(dcount_ServiceName)

Stages and Predicates

Stage 0: let

let Ticket_Requests = materialize(<inlined as stages below>);
let Suspicious_Logons = Ticket_Requests <inlined as stages below>;

Stage 1: source

let Ticket_Requests

Stage 2: source

let Suspicious_Logons

Stage 3: source

SecurityEvent

Stage 4: where time_window=43200s

| where TimeGenerated > ago(12h)

Stage 5: where

| where EventID == 4769

Stage 6: where

| where EventData has '<Data Name="Status">0x0</Data>'

Stage 7: where

| where EventData !has'<Data Name="IpAddress">::1</Data>'

Stage 8: parse

| parse EventData with * 'TargetUserName">' TargetUserName '</Data' * 'TargetDomainName">' TargetDomainName '</Data' * 'ServiceName">' ServiceName '<' * 'IpAddress">::ffff:' IpAddress '<' * 'Status">' Status '<' *

Stage 9: where

| where TargetUserName !has ServiceName

Stage 10: where

| where TargetUserName contains "$"

Stage 11: where

| where ServiceName has "$"

Stage 12: project

| project TimeGenerated, TargetUserName=tolower(TargetUserName), TargetDomainName, ServiceName=tolower(replace_string(ServiceName, '$', '')), IpAddress, Status

Stage 13: join

| join kind=rightanti (
        SecurityEvent
        | where TimeGenerated > ago(1h)
        | where EventID == 4624
        | where AuthenticationPackageName == "Kerberos"
        | where IpAddress !in ('-', '::1', '127.0.0.1')
        | where IpAddress !startswith "169.254."
        | where Account endswith_cs "$"
        | project TimeGenerated, Computer = tolower(replace_regex(Computer, @'(\w+)\..*', @'\1')), Account, TargetUserName=tolower(TargetUserName), IpAddress
        | where TargetUserName !has Computer
        ) on IpAddress, $left.ServiceName==$right.Computer

Stage 14: join

| join kind=leftouter  (
    Ticket_Requests
    | extend TargetUserName = replace_regex(TargetUserName, @'(\w+\$)@.*', @'\1')
    ) on IpAddress, TargetUserName

Stage 15: summarize

| summarize FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated), count(), dcount(ServiceName) by TargetUserName, IpAddress

Stage 16: where

| where isempty(dcount_ServiceName)

Exclusions

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

StageFieldKindExcluded values
7EventDatamatch<Data Name="IpAddress">::1</Data>
9TargetUserNamematchServiceName
13IpAddressin-, 127.0.0.1, ::1
13IpAddressstarts_with169.254.
13TargetUserNamematchComputer
14EventDatamatch<Data Name="IpAddress">::1</Data>
14TargetUserNamematchServiceName

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
Accountends_with
  • $ transforms: cased
AuthenticationPackageNameeq
  • Kerberos transforms: cased corpus 6 (sigma 2, elastic 2, splunk 1, kusto 1)
EventDatamatch
  • <Data Name="Status">0x0</Data>
EventIDeq
  • 4624 transforms: cased corpus 26 (splunk 13, kusto 9, chronicle 4)
  • 4769 transforms: cased corpus 10 (splunk 6, kusto 4)
ServiceNamematch
  • $
TargetUserNamecontains
  • $
dcount_ServiceNameis_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
FirstSeensummarize
IpAddresssummarize
LastSeensummarize
TargetUserNamesummarize