Detection rules › Kusto

Suspicious Network Beacons - Microsoft Defender(MDE/M365D)

Group by
InitiatingProcessFileName, RemoteIP, RemotePort
Author
Cyb3rMonk
Source
github.com/Cyb3r-Monk/Threat-Hunting-and-Detection

Below query detects suspicious beaconing activity by analyzing DeviceNetworkEvents data.

MITRE ATT&CK coverage

TacticTechniques
Command & ControlNo specific technique

Event coverage

Rule body kusto

// Author: Cyb3rMonk(https://twitter.com/Cyb3rMonk, https://mergene.medium.com)
// Link to original post:
// Part-1: https://mergene.medium.com/enterprise-scale-threat-hunting-network-beacon-detection-with-unsupervised-machine-learning-and-277c4c30304f
// Part-2: https://mergene.medium.com/enterprise-scale-threat-hunting-network-beacon-detection-with-unsupervised-ml-and-kql-part-2-bff46cfc1e7e
//
// Read the blog to understand how this query works and how to analyze the results.
// This query may not be able to detect beacons that have large sleep values like 6h-1d. Refactoring and additional analysis are required. 
// WARNING!: Since MDE doesn't log every single network connection, there is a chance of FALSE NEGATIVES. 
//
// Query parameters:
let starttime = 1d;
let endtime = 1s;
// Set the minimum beacon sleep. Increase it to get less results. the format is (hour,minute,second.milisecond).
// Be careful when changing the value. run " print ['timespan'] = make_timespan(0, x, y) " to verify you have the correct value set. 
let TimeDeltaThresholdMin = make_timespan(0,0,0.001);  
let TotalEventsThresholdMin = 15;
let TotalEventsThresholdMax=toint(((totimespan(starttime) - totimespan(endtime))/TimeDeltaThresholdMin));
let JitterThreshold = 50; // jitter in percentage. Set to filter out false positives: small threshold means tighter filtering/fewer results.
// Outlier thresholds. 1.5 means the value is a normal outlier, 3 means the value is far far out.
let OutlierThresholdMax = 2; //increase or decrease this value to get more or less results
// Time delta data set can have some outliers. Define how many outliers are acceptable for a beacon. Values between 1 to 3 should be fine.
let OutlierCountMax = 3; // increasing the value provides more results.
// Define how many devices can have the same beacon. 
let CompromisedDeviceCountMax = 10; // increasing the value provides more results. 
// Get all beacon candidates just by jitter filtering.
let BeaconCandidates = materialize (
    DeviceNetworkEvents
    | where Timestamp between (ago(starttime)..ago(endtime))
    | where RemoteIPType !in ("Reserved", "Private", "LinkLocal", "Loopback")
    | where isnotempty(RemoteIP) and RemoteIP !in ("0.0.0.0") 
    | where not (ipv4_is_private(RemoteIP))
    | where ActionType in ("ConnectionSuccess", "CsonnectionRequest", "CsonnectionFailed") // Fix the typos if you want to inlcude connreq. and connfail. 
    | summarize hint.strategy=shuffle make_set(Timestamp) by DeviceId, DeviceName,InitiatingProcessAccountName, InitiatingProcessAccountDomain, InitiatingProcessFileName, RemoteIP, RemotePort
    | where array_length(set_Timestamp) > TotalEventsThresholdMin and array_length(set_Timestamp) < TotalEventsThresholdMax
    | project DeviceId, DeviceName,InitiatingProcessAccountName, InitiatingProcessAccountDomain, InitiatingProcessFileName, RemoteIP, RemotePort, Timestamp=array_sort_asc(set_Timestamp)
    | mv-apply Timestamp to typeof(datetime) on 
    (     
        extend nextTimestamp = next(Timestamp, 1), nextInitiatingProcessAccountName = next(InitiatingProcessAccountName, 1), nextDeviceId = next(DeviceId, 1), nextDeviceName = next(DeviceName, 1), nextRemoteIP = next(RemoteIP, 1), nextRemotePort = next(RemotePort, 1), nextInitiatingProcessFileName = next(InitiatingProcessFileName, 1)
        | extend TimeDeltaInSeconds = datetime_diff('second',nextTimestamp,Timestamp)
        | where nextInitiatingProcessAccountName == InitiatingProcessAccountName and nextDeviceId == DeviceId and nextDeviceName == DeviceName and nextInitiatingProcessFileName == InitiatingProcessFileName and nextRemoteIP == RemoteIP and nextRemotePort == RemotePort
        | project Timestamp, TimeDeltaInSeconds, DeviceId, DeviceName, InitiatingProcessAccountName, InitiatingProcessFileName, RemoteIP, RemotePort
        // compute statistical values including standard deviation
        | summarize count(), min(Timestamp), max(Timestamp), Duration=datetime_diff("second", max(Timestamp), min(Timestamp)), 
            percentiles(TimeDeltaInSeconds, 5, 25, 50, 75, 95), 
            TimeDeltaList=make_list(TimeDeltaInSeconds) by DeviceId, DeviceName, InitiatingProcessAccountName, InitiatingProcessFileName, RemoteIP, RemotePort
        | extend (TimeDeltaInSeconds_min,TimeDeltaInSeconds_min_index,TimeDeltaInSeconds_max,TimeDeltaInSeconds_max_index,TimeDeltaInSeconds_avg,TimeDeltaInSeconds_stdev,TimeDeltaInSeconds_variance)=series_stats(TimeDeltaList)
        | extend Jitter=(TimeDeltaInSeconds_stdev/TimeDeltaInSeconds_avg)*100,
                 BeaconSleepMin=TimeDeltaInSeconds_avg - TimeDeltaInSeconds_stdev,
                 BeaconSleepMax=TimeDeltaInSeconds_avg + TimeDeltaInSeconds_stdev
        // Filter out impossible beacons based on jitter threshold defined.
        | where Jitter < JitterThreshold
    )
    // Try to enricht IP with the hostname
    | join kind=leftouter
        (
        DeviceNetworkEvents
        | where Timestamp > ago(starttime+1d)
        // Extract domain from the RemoteUrl
        | extend Host=tostring(parse_url(iif(RemoteUrl !startswith "http", strcat(@'http://',RemoteUrl),RemoteUrl)).Host)
        | extend domain = reverse(replace(@'([A-z0-9-]+\.[A-z0-9-]+\.[A-z0-9-]+)\..*',@'\1',reverse(Host)))
        | project domain, RemoteIP
        | summarize Domains = make_set(domain) by RemoteIP
        ) on RemoteIP
        | project-reorder DeviceId, DeviceName, InitiatingProcessAccountName, InitiatingProcessAccountDomain
)
;
// Get potentially suspicious beacons based on CompromisedDeviceCountMax
let PotentialBeacons = materialize (
    BeaconCandidates
    | summarize dcount(DeviceId) by InitiatingProcessFileName, RemoteIP, RemotePort
    // Filter out beacon destinations if many devices are connecting to it (like windows update)
    | where dcount_DeviceId <= CompromisedDeviceCountMax
    | join kind=inner BeaconCandidates on InitiatingProcessFileName, RemoteIP, RemotePort
    | project-away *1
    )
    ;
// Get candidates that can't be beacons based on outlier analysis on the time delta
let ImpossibleBeaconsByTimeDelta = materialize 
    (
    PotentialBeacons
    | extend outliers = series_outliers(TimeDeltaList)
    | mv-expand TimeDeltaList, outliers to typeof(double)
    | where outliers > OutlierThresholdMax or outliers < (-1 * OutlierCountMax) // outlier can be negative or positive.
    | summarize count(), make_set(outliers) by DeviceId, DeviceName,InitiatingProcessAccountName, InitiatingProcessAccountDomain, InitiatingProcessFileName, RemoteIP, RemotePort
    | where count_ > OutlierCountMax
    )
    ;
// Remove ImpossibleBeaconsByTimeDelta from potentially suspicious beacons. 
PotentialBeacons
| join kind=leftantisemi ImpossibleBeaconsByTimeDelta on DeviceId, DeviceName,InitiatingProcessAccountName, InitiatingProcessAccountDomain, InitiatingProcessFileName, RemoteIP, RemotePort
| extend Timestamp = min_Timestamp // just to make it easy to jump to the device timeline etc. 
// if the logs have extra information, they can be used for filtering the nonmalicious destinations
| order by Jitter asc, TimeDeltaInSeconds_avg asc

Stages and Predicates

Parameters

let starttime = 1d;
let endtime = 1s;
let TimeDeltaThresholdMin = make_timespan(0,0,0.001);
let TotalEventsThresholdMin = 15;
let TotalEventsThresholdMax = toint(((totimespan(starttime) - totimespan(endtime))/TimeDeltaThresholdMin));
let JitterThreshold = 50;
let OutlierThresholdMax = 2;
let OutlierCountMax = 3;
let CompromisedDeviceCountMax = 10;

Let binding: ImpossibleBeaconsByTimeDelta

let ImpossibleBeaconsByTimeDelta = materialize 
    (
    PotentialBeacons
    | extend outliers = series_outliers(TimeDeltaList)
    | mv-expand TimeDeltaList, outliers to typeof(double)
    | where outliers > OutlierThresholdMax or outliers < (-1 * OutlierCountMax)
    | summarize count(), make_set(outliers) by DeviceId, DeviceName,InitiatingProcessAccountName, InitiatingProcessAccountDomain, InitiatingProcessFileName, RemoteIP, RemotePort
    | where count_ > OutlierCountMax
    );

Derived from OutlierThresholdMax, OutlierCountMax, PotentialBeacons.

Stage 1: source

BeaconCandidates

Stage 2: summarize

summarize by InitiatingProcessFileName, RemoteIP, RemotePort

Stage 3: where

where dcount_DeviceId <= 10

Stage 4: join

join kind=inner (...)

Stage 5: project-away

project-away *1

Stage 6: join (negated)

join kind=leftantisemi (...)

Stage 7: extend

extend Timestamp

Stage 8: sort

sort by Jitter, TimeDeltaInSeconds_avg

Exclusions

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

FieldKindExcluded values
RemoteIPcidr_match10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 127.0.0.0/8
RemoteIPeq0.0.0.0
RemoteIPTypeinLinkLocal, Loopback, Private, Reserved
outliersgt2
outlierslt-3
count_gt3

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
ActionTypein
  • ConnectionSuccess transforms: cased corpus 9 (kusto 9)
  • CsonnectionFailed transforms: cased
  • CsonnectionRequest transforms: cased
Jitterlt
  • JitterThreshold transforms: cased
RemoteIPis_not_null
  • (no value, null check)
dcount_DeviceIdle
  • 10 transforms: cased
nextDeviceIdeq
  • DeviceId transforms: cased
nextDeviceNameeq
  • DeviceName transforms: cased
nextInitiatingProcessAccountNameeq
  • InitiatingProcessAccountName transforms: cased
nextInitiatingProcessFileNameeq
  • InitiatingProcessFileName transforms: cased
nextRemoteIPeq
  • RemoteIP transforms: cased
nextRemotePorteq
  • RemotePort transforms: cased
set_Timestampcross_field_compare
  • TotalEventsThresholdMax transforms: op:lt, lhs:array_length
set_Timestampgt
  • 15 transforms: array_length

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
InitiatingProcessFileNamesummarize
RemoteIPsummarize
RemotePortsummarize
Timestampextend