Detection rules › Kusto
Suspicious Network Beacons - Microsoft Defender for Endpoint Aggregated Reports
Below query detects suspicious beaconing activity by analyzing DeviceNetworkEvents Aggregated Reports telemetry. Use it as a starting point and refine further as it may generate too many results.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Command & Control | No specific technique |
Event coverage
| Provider | Event/ActionType | Title |
|---|---|---|
| Sysmon | Event ID 3 | Network connection |
| Security-Auditing | Event ID 5156 | The Windows Filtering Platform has permitted a connection. |
| Defender-DeviceNetworkEvents | any | Network activity (any) |
Rule body kusto
// Author: Cyb3rMonk(https://twitter.com/Cyb3rMonk, https://mergene.medium.com)
// Link to original post: https://academy.bluraven.io/blog/beaconing-detection-using-mde-aggregated-report-telemetry
//
// Query parameters:
let lookback = 3d;
let min_uniform_count = 4 * (lookback / 1d); // (4 uniform distribution per lookback)
DeviceNetworkEvents
| where Timestamp > ago(lookback)
| where ActionType endswith "AggregatedReport"
| where ipv4_is_private(RemoteIP) == false
| extend ConnectionCount = toint(parse_json(AdditionalFields).uniqueEventsAggregated)
| project Timestamp = bin(Timestamp, 1h), DeviceName, InitiatingProcessFileName, RemoteIP, ConnectionCount
| sort by Timestamp asc
| summarize Timestamp = make_list(Timestamp), ConnCounts = make_list(ConnectionCount) by DeviceName, InitiatingProcessFileName, RemoteIP
| extend count_of_hours = array_length(Timestamp)
| extend anomalies_decomposed = series_decompose_anomalies(ConnCounts, 1.5, -1),
series_stats(ConnCounts)
| mv-apply anomaly = anomalies_decomposed to typeof(int) on (
summarize inliner_count = countif(anomaly !in (-1, 1)), outlier_count = countif(anomaly in (-1, 1))
)
| where inliner_count >= min_uniform_count and series_stats_ConnCounts_avg > 45 // avg=45 is roughly 1 min sleep with some jitter
| sort by series_stats_ConnCounts_avg desc
| extend FirstConnection = Timestamp[0], LastConnection=Timestamp[-1], avg_conn_count=toint(series_stats_ConnCounts_avg)
| project-reorder FirstConnection, LastConnection, DeviceName, InitiatingProcessFileName, RemoteIP, count_of_hours, inliner_count, outlier_count, avg_conn_count
Stages and Predicates
Parameters
let lookback = 3d;
let min_uniform_count = 4 * (lookback / 1d);
Stage 1: source
DeviceNetworkEvents
Stage 2: where
| where Timestamp > ago(lookback)
Stage 3: where
| where ActionType endswith "AggregatedReport"
Stage 4: where
| where ipv4_is_private(RemoteIP) == false
Stage 5: extend
| extend ConnectionCount = toint(parse_json(AdditionalFields).uniqueEventsAggregated)
Stage 6: project
| project Timestamp = bin(Timestamp, 1h), DeviceName, InitiatingProcessFileName, RemoteIP, ConnectionCount
Stage 7: sort
| sort by Timestamp asc
Stage 8: summarize
| summarize Timestamp = make_list(Timestamp), ConnCounts = make_list(ConnectionCount) by DeviceName, InitiatingProcessFileName, RemoteIP
Stage 9: extend
| extend count_of_hours = array_length(Timestamp)
The stages below score time-series anomalies (make-series, series_decompose_anomalies).
Stage 10: extend
| extend anomalies_decomposed = series_decompose_anomalies(ConnCounts, 1.5, -1),
series_stats(ConnCounts)
Stage 11: kusto:mv-apply
| mv-apply anomaly = anomalies_decomposed to typeof(int) on (
summarize inliner_count = countif(anomaly !in (-1, 1)), outlier_count = countif(anomaly in (-1, 1))
)
Stage 12: where
| where inliner_count >= min_uniform_count and series_stats_ConnCounts_avg > 45
Stage 13: sort
| sort by series_stats_ConnCounts_avg desc
Stage 14: extend
| extend FirstConnection = Timestamp[0], LastConnection=Timestamp[-1], avg_conn_count=toint(series_stats_ConnCounts_avg)
Stage 15: project-reorder
| project-reorder FirstConnection, LastConnection, DeviceName, InitiatingProcessFileName, RemoteIP, count_of_hours, inliner_count, outlier_count, avg_conn_count
Exclusions
Top-level NOT(...) conjuncts: predicates this rule actively suppresses.
| Field | Kind | Excluded values |
|---|---|---|
RemoteIP | cidr_match | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 127.0.0.0/8 |
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.
| Field | Kind | Values |
|---|---|---|
ActionType | ends_with |
|
inliner_count | ge |
|
series_stats_ConnCounts_avg | gt |
|
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.
| Field | Source |
|---|---|
ConnCounts | summarize |
DeviceName | summarize |
InitiatingProcessFileName | summarize |
RemoteIP | summarize |
Timestamp | summarize |
count_of_hours | extend |
anomalies_decomposed | extend |
FirstConnection | extend |
LastConnection | extend |
avg_conn_count | extend |