Detection rules › Kusto

Suspicious Network Beacons - Sysmon

Group by
Computer, DestinationIp, DestinationPort, Image, UserName
Author
Cyb3rMonk
Source
github.com/Cyb3r-Monk/Threat-Hunting-and-Detection

Below query detects suspicious beaconing activity by analyzing Sysmon network connection events.

MITRE ATT&CK coverage

TacticTechniques
Command & ControlNo specific technique

Event coverage

ProviderEventTitle
SysmonEvent ID 3Network connection

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. 
//
// 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 = 2; // increasing the value provides more results.
// Define how many devices can have the same beacon. 
let CompromisedDeviceCountMax = 10; // increasing the value provides more results. 
// Function for parsing Sysmon network connection events.
let parse_sysmon_3 = (T:(TimeGenerated:datetime,EventID:int, Source:string,RenderedDescription:string, EventData:string))
{
T 
| where TimeGenerated between (ago(starttime)..ago(endtime))
| where Source == "Microsoft-Windows-Sysmon" and EventID == 3
| extend RenderedDescription = tostring(split(RenderedDescription, ":")[0])
| extend EventData = parse_xml(EventData).DataItem.EventData.Data
| mv-expand bagexpansion=array EventData
| evaluate bag_unpack(EventData)
| extend Key=tostring(['@Name']), Value=['#text']
| evaluate pivot(Key, any(Value), TimeGenerated, Source, EventLog, Computer, EventLevel, EventLevelName, EventID, UserName, RenderedDescription, MG, ManagementGroupName, Type, _ResourceId)
| where not (ipv4_is_private(tostring(DestinationIp)))
| project TimeGenerated, Computer=tostring(Computer), UserName=tostring(UserName), Image=tostring(Image), DestinationIp=tostring(DestinationIp), DestinationPort=tostring(DestinationPort)
};
// Get all beacon candidates just by jitter filtering.
let BeaconCandidates = materialize (
    Event
    | invoke parse_sysmon_3()
    | summarize hint.strategy=shuffle make_set(TimeGenerated) by Computer, UserName, Image, DestinationIp, DestinationPort
    | where array_length(set_TimeGenerated) > TotalEventsThresholdMin and array_length(set_TimeGenerated) < TotalEventsThresholdMax
    | project Computer, UserName, Image, DestinationIp, DestinationPort, TimeGenerated=array_sort_asc(set_TimeGenerated)
    | mv-apply TimeGenerated to typeof(datetime) on 
    (     
        extend nextTimeGenerated = next(TimeGenerated, 1), nextUserName = next(UserName, 1), nextComputer = next(Computer, 1), nextDestinationIp = next(DestinationIp, 1), nextDestinationPort = next(DestinationPort, 1), nextImage = next(Image, 1)
        | extend TimeDeltaInSeconds = datetime_diff('second',nextTimeGenerated,TimeGenerated)
        | where nextUserName == UserName and nextComputer == Computer and nextDestinationIp == DestinationIp and nextDestinationPort == DestinationPort and nextImage == Image
        | project TimeGenerated, TimeDeltaInSeconds, Computer, UserName, Image, DestinationIp, DestinationPort
        | summarize count(), min(TimeGenerated), max(TimeGenerated),
            Duration=datetime_diff("second", max(TimeGenerated), min(TimeGenerated)), 
            percentiles(TimeDeltaInSeconds, 5, 25, 50, 75, 95),
            TimeDeltaList=make_list(TimeDeltaInSeconds) by Computer, UserName, DestinationIp, DestinationPort
        | extend (TimeDeltaInSeconds_min,TimeDeltaInSeconds_min_index,TimeDeltaInSeconds_max,TimeDeltaInSeconds_max_index,TimeDeltaInSeconds_avg,TimeDeltaInSeconds_stdev,TimeDeltaInSeconds_variance)=series_stats(TimeDeltaList)
        | extend JitterPercentage = (TimeDeltaInSeconds_stdev/TimeDeltaInSeconds_avg)*100
        // Filter out impossible beacons based on jitter threshold defined.
        | where JitterPercentage < JitterThreshold
    )
)
;
// Get potentially suspicious beacons based on CompromisedDeviceCountMax
let PotentialBeacons = materialize 
    (
    BeaconCandidates
    | summarize dcount(Computer) by Image, DestinationIp, DestinationPort
    // Filter out beacon destinations if many devices are connecting to the same destination (like windows update)
    | where dcount_Computer <= CompromisedDeviceCountMax
    | join kind=inner BeaconCandidates on Image, DestinationIp, DestinationPort
    | 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 Computer, UserName, Image, DestinationIp, DestinationPort
    | where count_ > OutlierCountMax
    )
    ;
// Remove ImpossibleBeaconsByTimeDelta from potentially suspicious beacons. 
let SuspiciousBeacons = materialize (
    PotentialBeacons
    | join kind=leftantisemi ImpossibleBeaconsByTimeDelta on Computer, UserName, Image, DestinationIp, DestinationPort
    // if the logs have extra information, they can be used for filtering the nonmalicious destinations
    | order by JitterPercentage asc, TimeDeltaInSeconds_avg asc
    );
// get prevalence data for the destinations(last14d)
let DestinationList = 
    SuspiciousBeacons
    | summarize make_set(DestinationIp)
    ;
let PrevalanceData = 
    Event
    | where TimeGenerated between (ago(14d) .. ago(endtime)) // analyze the duration before the last beacon connection
    | invoke parse_sysmon_3()
    | where DestinationIp in (DestinationList)
    | summarize hint.strategy=shuffle DestinationPrevalence = dcount(Computer) by DestinationIp
    ;
// Enrich suspicious beacons with the historical prevalence data for prioritization
SuspiciousBeacons
| join kind=leftouter PrevalanceData on DestinationIp
| sort by DestinationPrevalence asc
| project-reorder DestinationPrevalence
// It's best to enrich IP information with other logs like proxy/firewall to get more info on the IP and apply filtering.

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 = 2;
let CompromisedDeviceCountMax = 10;

Let binding: parse_sysmon_3

let parse_sysmon_3 = (T:(TimeGenerated:datetime,EventID:int, Source:string,RenderedDescription:string, EventData:string))
{
T 
| where TimeGenerated between (ago(starttime)..ago(endtime))
| where Source == "Microsoft-Windows-Sysmon" and EventID == 3
| extend RenderedDescription = tostring(split(RenderedDescription, ":")[0])
| extend EventData = parse_xml(EventData).DataItem.EventData.Data
| mv-expand bagexpansion=array EventData
| evaluate bag_unpack(EventData)
| extend Key=tostring(['@Name']), Value=['#text']
| evaluate pivot(Key, any(Value), TimeGenerated, Source, EventLog, Computer, EventLevel, EventLevelName, EventID, UserName, RenderedDescription, MG, ManagementGroupName, Type, _ResourceId)
| where not (ipv4_is_private(tostring(DestinationIp)))
| project TimeGenerated, Computer=tostring(Computer), UserName=tostring(UserName), Image=tostring(Image), DestinationIp=tostring(DestinationIp), DestinationPort=tostring(DestinationPort)
};

Derived from starttime, endtime.

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 Computer, UserName, Image, DestinationIp, DestinationPort
    | where count_ > OutlierCountMax
    );

Derived from OutlierThresholdMax, OutlierCountMax, PotentialBeacons.

Let binding: DestinationList

let DestinationList = SuspiciousBeacons
    | summarize make_set(DestinationIp);

Derived from SuspiciousBeacons.

Let binding: PrevalanceData

let PrevalanceData = Event
    | where TimeGenerated between (ago(14d) .. ago(endtime))
    | invoke parse_sysmon_3()
    | where DestinationIp in (DestinationList)
    | summarize hint.strategy=shuffle DestinationPrevalence = dcount(Computer) by DestinationIp;

Derived from endtime, parse_sysmon_3, DestinationList.

Stage 1: source

PotentialBeacons

Stage 2: join (negated)

join kind=leftantisemi (...)

Stage 3: sort

sort by JitterPercentage, TimeDeltaInSeconds_avg

Stage 4: join

join kind=leftouter (...)

Stage 5: sort

sort by DestinationPrevalence

Stage 6: project-reorder

project-reorder

Stage 7: summarize

summarize by Computer, UserName, Image, DestinationIp, DestinationPort

Stage 8: summarize

summarize by Computer, UserName, Image, DestinationIp, DestinationPort

Stage 9: summarize

summarize by Computer, UserName, DestinationIp, DestinationPort

Stage 10: summarize

summarize by DestinationIp

Exclusions

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

FieldKindExcluded values
outliersgt2
outlierslt-2
count_gt2

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
DestinationIpin
  • DestinationList transforms: cased

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
DestinationIpsummarize