LOW AND SLOW
CUMULATIVE
PERIODIC
RATE LIMITED
DetectionBlue TeamSOCOctober 12, 2026•9 min read

Detecting Low-and-Slow Exfiltration over Time

An operator who stays under every daily threshold still has to send the data eventually. Sum it over weeks, not hours.

Scroll

When attackers know a volume alert exists, they cap the rate, send on a schedule and spread the theft across weeks. Per-hour and per-day thresholds do not see that pattern. Cumulative volume per destination and the regularity of small uploads do, which is why you need long retention and a few well-chosen aggregates.

Signals at a Glance

SignalWhereATT&CK
30-day cumulative bytes-out to one external destination from one hostFirewall, proxy, ZeekT1030
Uploads of near-constant size at a near-constant intervalProxy, Zeek conn.logT1029
Egress only inside a narrow scheduled window each dayFlow dataT1029
Long-lived session with steady low-rate outbound bytesZeek conn.logT1030
Scripted, recurring upload from a scheduled task or cron jobProcess creation, task logsT1020

Cumulative Volume per Destination

Aggregate over a window long enough to expose the total. A destination that no one else in the organisation talks to, and that has received a few hundred megabytes from one host across a month, is more interesting than a one-off 1 GB upload to a CDN:

// Sentinel (KQL) - 30-day cumulative bytes to rare external destinations
// tune/validate: needs 30+ days of retention in the flow table
let rare = CommonSecurityLog
| where TimeGenerated > ago(30d) and DeviceAction == "allow" and ipv4_is_private(DestinationIP) == false
| summarize hosts = dcount(SourceIP) by DestinationIP
| where hosts <= 2;
CommonSecurityLog
| where TimeGenerated > ago(30d) and DeviceAction == "allow"
| where DestinationIP in ((rare | project DestinationIP))
| summarize totalMB = round(sum(SentBytes) / 1048576.0, 1), activeDays = dcount(bin(TimeGenerated, 1d)),
            sessions = count() by SourceIP, DestinationIP
| where totalMB > 200 and activeDays >= 10
| order by totalMB desc

Regular Small Uploads

The timing signal is the same as for beaconing, but the focus is outbound payload. Group by source and destination, then look for low variance in both the interval between sessions and the bytes sent:

index=zeek sourcetype="bro:conn:json" local_orig=true local_resp=false earliest=-14d
| rename "id.orig_h" as src, "id.resp_h" as dest
| sort 0 src, dest, _time
| streamstats current=f last(_time) as prev_time by src, dest
| eval gap = _time - prev_time
| stats count as sessions avg(gap) as gap_avg stdev(gap) as gap_sd avg(orig_bytes) as b_avg stdev(orig_bytes) as b_sd by src, dest
| where sessions > 30 AND gap_avg > 60 AND (gap_sd / gap_avg) < 0.25 AND (b_sd / b_avg) < 0.5
| eval total_mb = round(sessions * b_avg / 1048576, 1)
| sort - total_mb
``` tune/validate: exclude telemetry, NTP and update endpoints through a lookup ```
Vetted sources

MITRE CAR analytics on scheduled and periodic communication, RITA over Zeek conn.log for interval scoring, Elastic and Splunk ESCU content for beaconing and data-transfer-size analytics, and Zeek conn.log semantics. Baseline your own environment — telemetry agents, monitoring and software updates are low-and-slow by design.

Build Detections That Fire

Mjolnir Security helps blue teams turn threat intelligence into high-fidelity detections across endpoint, network, identity and cloud telemetry.

Detection EngineeringManaged Detection & ResponseThreat HuntingIncident ResponsePurple Teaming
  • Detection Engineering We build and tune detections mapped to ATT&CK from your own telemetry.
  • Threat Hunting as a Service Hypothesis-driven hunts for the behaviours on this page.
  • 24/7 Incident Response Containment when a detection fires. Call +1 833 403 5875.
Written by: Mjolnir Threat Research (MTAC)  |  Published: October 12, 2026  |  Last reviewed: October 12, 2026