Detection rules › Kusto

Suspicious Network Beacons - Microsoft Defender(MDE/M365D)

Group by
DeviceId, DeviceName, InitiatingProcessAccountDomain, InitiatingProcessAccountName, 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

Telemetry coverage

Rule body

// 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 BeaconCandidates and let PotentialBeacons are inlined into the numbered stages below.

Let binding: ImpossibleBeaconsByTimeDelta used in Stage 19

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
    );

Stage 1: source

let BeaconCandidates

Stage 2: source

let PotentialBeacons

Stage 3: source

DeviceNetworkEvents

Stage 4: where

where Timestamp between (ago(86400s) .. ago(1s))

Stage 5: where

where not (RemoteIPType in~ ("LinkLocal", "Loopback", "Private", "Reserved"))

Stage 6: where

where not (RemoteIP =~ "0.0.0.0") and isnotempty(RemoteIP)

Stage 7: where

where not ((ipv4_is_in_range(RemoteIP, "10.0.0.0/8") or ipv4_is_in_range(RemoteIP, "172.16.0.0/12") or ipv4_is_in_range(RemoteIP, "192.168.0.0/16") or ipv4_is_in_range(RemoteIP, "169.254.0.0/16") or ipv4_is_in_range(RemoteIP, "127.0.0.0/8")))

Stage 8: where

where ActionType in~ ("ConnectionSuccess", "CsonnectionFailed", "CsonnectionRequest")

Stage 9: summarize

summarize by DeviceId, DeviceName, InitiatingProcessAccountName, InitiatingProcessAccountDomain, InitiatingProcessFileName, RemoteIP, RemotePort

Stage 10: where

where <unknown:array_length(, set_Timestamp)> < TotalEventsThresholdMax and set_Timestamp > 15

Stage 11: project

project DeviceId, DeviceName, InitiatingProcessAccountDomain, InitiatingProcessAccountName, InitiatingProcessFileName, RemoteIP, RemotePort, Timestamp

Stage 12: kusto:mv-apply

kusto:mv-apply Jitter < JitterThreshold and nextDeviceId =~ DeviceId and nextDeviceName =~ DeviceName and nextInitiatingProcessAccountName =~ InitiatingProcessAccountName and nextInitiatingProcessFileName =~ InitiatingProcessFileName and nextRemoteIP =~ RemoteIP and nextRemotePort =~ RemotePort

Stage 13: join

join kind=leftouter (DeviceNetworkEvents) on RemoteIP

Stage 14: project-reorder

project-reorder

Stage 15: summarize

summarize by InitiatingProcessFileName, RemoteIP, RemotePort

Stage 16: where

where dcount_DeviceId <= 10

Stage 17: join

join kind=inner (BeaconCandidates) on InitiatingProcessFileName, RemoteIP, RemotePort

Stage 18: project-away

project-away *1

Stage 19: join (negated)

join kind=leftantisemi (ImpossibleBeaconsByTimeDelta) on DeviceId, DeviceName, InitiatingProcessAccountName, InitiatingProcessAccountDomain, InitiatingProcessFileName, RemoteIP, RemotePort

Stage 20: extend

extend Timestamp

Stage 21: sort

sort by Jitter, TimeDeltaInSeconds_avg

Exclusions

The rule actively suppresses these predicates.

FieldKindExcluded valuesSearch
RemoteIPTypeinLinkLocal, Loopback, Private, Reservedexcludes:RemoteIPType
RemoteIPeq0.0.0.0excludes:RemoteIP field:"RemoteIP" value:"0.0.0.0"
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/8excludes:RemoteIP
outliersgt2excludes:outliers field:"outliers" value:"2"
outlierslt-3excludes:outliers field:"outliers" value:"-3"
ActionTypeinConnectionSuccess, CsonnectionFailed, CsonnectionRequestexcludes:ActionType field:"ActionType" value:"ConnectionSuccess" field:"ActionType" value:"CsonnectionFailed" field:"ActionType" value:"CsonnectionRequest"
Jittercross_field_compareJitterThresholdexcludes:Jitter field:"Jitter" value:"JitterThreshold"
RemoteIPis_not_null(no value, null check)excludes:RemoteIP
count_gt3excludes:count_ field:"count_" value:"3"
dcount_DeviceIdle10excludes:dcount_DeviceId field:"dcount_DeviceId" value:"10"
nextDeviceIdcross_field_compareDeviceIdexcludes:nextDeviceId field:"nextDeviceId" value:"DeviceId"
nextDeviceNamecross_field_compareDeviceNameexcludes:nextDeviceName field:"nextDeviceName" value:"DeviceName"
nextInitiatingProcessAccountNamecross_field_compareInitiatingProcessAccountNameexcludes:nextInitiatingProcessAccountName field:"nextInitiatingProcessAccountName" value:"InitiatingProcessAccountName"
nextInitiatingProcessFileNamecross_field_compareInitiatingProcessFileNameexcludes:nextInitiatingProcessFileName field:"nextInitiatingProcessFileName" value:"InitiatingProcessFileName"
nextRemoteIPcross_field_compareRemoteIPexcludes:nextRemoteIP field:"nextRemoteIP" value:"RemoteIP"
nextRemotePortcross_field_compareRemotePortexcludes:nextRemotePort field:"nextRemotePort" value:"RemotePort"
set_Timestampcross_field_compareTotalEventsThresholdMaxexcludes:set_Timestamp field:"set_Timestamp" value:"TotalEventsThresholdMax"
set_Timestampgt15excludes:set_Timestamp field:"set_Timestamp" value:"15"

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
ActionTypein
  • ConnectionSuccess
  • CsonnectionFailed
  • CsonnectionRequest
field:"ActionType" kind:in
Jittercross_field_compare
  • JitterThreshold transforms: op:lt
field:"Jitter" kind:cross_field_compare value:"JitterThreshold"
RemoteIPcidr_match
  • 10.0.0.0/8
  • 127.0.0.0/8
  • 169.254.0.0/16
  • 172.16.0.0/12
  • 192.168.0.0/16
field:"DestinationIp" kind:cidr_match
RemoteIPin
  • 0.0.0.0
field:"DestinationIp" kind:in value:"0.0.0.0"
RemoteIPis_not_null
  • (no value, null check)
field:"DestinationIp" kind:is_not_null
RemoteIPTypein
  • LinkLocal
  • Loopback
  • Private
  • Reserved
field:"RemoteIPType" kind:in
dcount_DeviceIdle
  • 10
field:"dcount_DeviceId" kind:le value:"10"
nextDeviceIdcross_field_compare
  • DeviceId transforms: op:eq
field:"nextDeviceId" kind:cross_field_compare value:"DeviceId"
nextDeviceNamecross_field_compare
  • DeviceName transforms: op:eq
field:"nextDeviceName" kind:cross_field_compare value:"DeviceName"
nextInitiatingProcessAccountNamecross_field_compare
  • InitiatingProcessAccountName transforms: op:eq
field:"nextInitiatingProcessAccountName" kind:cross_field_compare value:"InitiatingProcessAccountName"
nextInitiatingProcessFileNamecross_field_compare
  • InitiatingProcessFileName transforms: op:eq
field:"nextInitiatingProcessFileName" kind:cross_field_compare value:"InitiatingProcessFileName"
nextRemoteIPcross_field_compare
  • RemoteIP transforms: op:eq
field:"nextRemoteIP" kind:cross_field_compare value:"RemoteIP"
nextRemotePortcross_field_compare
  • RemotePort transforms: op:eq
field:"nextRemotePort" kind:cross_field_compare value:"RemotePort"
set_Timestampcross_field_compare
  • TotalEventsThresholdMax transforms: op:lt, lhs:array_length
field:"set_Timestamp" kind:cross_field_compare value:"TotalEventsThresholdMax"

Output fields

These fields are emitted when the rule matches.

FieldSource
InitiatingProcessFileNamesummarize
RemoteIPsummarize
RemotePortsummarize
Timestampextend