Detects command-and-control (C2) beaconing by identifying outbound network connections that repeat on a regular, machine-like schedule. For each host and external destination it measures the interval between consecutive connections and the coefficient of variation (jitter relative to the mean); automated beacons hold a fixed cadence and stand out with a very low coefficient of variation, unlike bursty human-driven traffic. Destinations served by many anycast or CDN edge IPs (Fastly, Cloudflare, cloud providers) are consolidated by owning organization and cadence, so a single beacon is reported once rather than as many near-duplicate rows. The detection is purely behavioral and needs no IOC list or threat feed, making it effective against novel or custom C2 infrastructure.
#event_simpleName=NetworkConnectIP4
// Keep only egress to routable / external destinations
| !cidr(RemoteAddressIP4, subnet=[
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"127.0.0.0/8", "169.254.0.0/16", "224.0.0.0/4",
"0.0.0.0/8", "100.64.0.0/10"
])
// Drop high-volume benign services that create artificial regularity
| RemotePort != 53
| RemotePort != 123
| RemotePort != 137
| RemotePort != 138
// ===== STAGE 1: detect beacons per destination IP =====
// Channel = host -> (remote IP + port). Keeping the IP here means two distinct
// beacons to the same provider are never merged before their cadence is measured.
| ConnKey := format(format="%s:%s", field=[RemoteAddressIP4, RemotePort])
| ts := @timestamp
| sort(field=[aid, ConnKey, ts], order=[asc, asc, asc], limit=max)
| neighbor(include=[ts, aid, ConnKey], prefix=prev, direction=preceding)
| test(aid == prev.aid)
| test(ConnKey == prev.ConnKey)
| Delta := (ts - prev.ts) / 1000
| Delta >= 1
| groupBy([aid, ComputerName, RemoteAddressIP4, RemotePort], function=[
count(as=Beacons),
avg(Delta, as=AvgInterval),
stdDev(field=Delta, as=JitterStdDev)
], limit=max)
| CoV := JitterStdDev / AvgInterval
// Beaconing profile (applied per IP so each real channel is judged on its own)
| Beacons >= 8
| AvgInterval >= 10
| AvgInterval <= 86400
| CoV < 0.10
// ===== STAGE 2: de-duplicate anycast edges by (org + cadence) =====
| asn(RemoteAddressIP4)
| Org := coalesce([RemoteAddressIP4.org, RemoteAddressIP4])
// --- OPTIONAL ALLOWLIST ------------------------------------------------
// Populate with orgs already attributed to benign scheduled software.
// Do NOT blanket-trust Fastly / Cloudflare / Google - they are common C2
// fronting providers; allowlist only AFTER confirming the process.
// | !in(field=Org, values=["EXAMPLE VENDOR ORG", "ANOTHER TRUSTED ORG"])
// -----------------------------------------------------------------------
// Bucket the interval to the nearest minute so identical-cadence siblings merge,
// but channels with genuinely different intervals remain distinct rows.
| CadenceBucket := AvgInterval / 60
| CadenceBucket := round(CadenceBucket)
| groupBy([aid, ComputerName, Org, RemotePort, CadenceBucket], function=[
count(as=EdgeIPs),
collect([RemoteAddressIP4], limit=25),
avg(Beacons, as=Beacons),
avg(AvgInterval, as=AvgInterval),
avg(JitterStdDev, as=JitterStdDev),
avg(CoV, as=CoV)
], limit=max)
| Beacons := round(Beacons)
| AvgInterval := round(AvgInterval)
| JitterStdDev := round(JitterStdDev)
| sort(field=CoV, order=asc, limit=20000)
| format(format="%.4f", field=CoV, as=CoV)
| table([ComputerName, aid, Org, RemotePort, EdgeIPs, RemoteAddressIP4, Beacons, AvgInterval, JitterStdDev, CoV], limit=20000)