EXPLORE
← Back to Explore
crowdstrike_cqlHunting

C2 Beaconing Detection

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. ## Output | Field | Meaning | | :--- | :--- | | `Org` | Owning organization from `asn()`, or the raw IP if unresolved | | `EdgeIPs` | Distinct IPs merged into this finding (high = anycast/CDN) | | `RemoteAddressIP4` | The member edge IPs | | `Beacons` / `AvgInterval` / `JitterStdDev` / `CoV` | Regularity metrics (averaged across merged edges, which are near-identical by construction) | ## The allowlist is deliberately empty Domain fronting and CDN-hosted redirectors (T1090.004) mean adversaries actively use Fastly / Cloudflare / Google for C2. Allowlist an org only after attributing the traffic to a specific, expected process on the specific host - never on provider reputation alone. ## Recommendation Pivot each surviving finding to the responsible binary via `ContextProcessId`: ``` #event_simpleName=NetworkConnectIP4 | aid="<AID>" | RemoteAddressIP4=/<one of the edge IPs>/ | groupBy([aid, ContextProcessId], function=count(as=Conns)) | join({#event_simpleName=ProcessRollup2}, field=[aid, ContextProcessId], key=[aid, TargetProcessId], include=[FileName, ImageFileName, CommandLine, ParentBaseFileName, UserName], mode=left) | table([UserName, ParentBaseFileName, FileName, CommandLine, ImageFileName, Conns]) ```

MITRE ATT&CK

command-and-controlexfiltration

Detection Query

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

Author

ByteRay GmbH

Data Sources

Endpoint

Platforms

windowslinux

Tags

Huntingcs_module:Insight
Raw Content
# --- Query Metadata ---
# Human-readable name for the query. Will be displayed as the title.
name: C2 Beaconing Detection

# MITRE ATT&CK technique IDs
mitre_ids:
  - T1071
  - T1571
  - T1041
  - T1090.004

# Description of what the query does and its purpose.
description: |
  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.

# The author or team that created the query.
author: ByteRay GmbH

# The required log sources to run this query successfully in Next-Gen SIEM.
log_sources:
  - Endpoint

# The CrowdStrike modules required to run this query.
cs_required_modules:
  - Insight

# Tags for filtering and categorization.
tags:
  - Hunting

# --- Query Content ---
# The actual CrowdStrike Query Language (CQL) code.
# Using the YAML block scalar `|` allows for multi-line strings.
cql: |
  #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)

# Explanation of the query.
# Using the YAML block scalar `|` allows for multi-line strings.
# Uses markdown for formatting on the webpage.
explanation: |
  ## Output
   
    | Field | Meaning |
    | :--- | :--- |
    | `Org` | Owning organization from `asn()`, or the raw IP if unresolved |
    | `EdgeIPs` | Distinct IPs merged into this finding (high = anycast/CDN) |
    | `RemoteAddressIP4` | The member edge IPs |
    | `Beacons` / `AvgInterval` / `JitterStdDev` / `CoV` | Regularity metrics (averaged across merged edges, which are near-identical by construction) |
   
    ## The allowlist is deliberately empty
   
    Domain fronting and CDN-hosted redirectors (T1090.004) mean adversaries actively use Fastly / Cloudflare / Google for C2. Allowlist an org only after attributing the traffic to a specific, expected process on the specific host - never on provider reputation alone.
   
    ## Recommendation
   
    Pivot each surviving finding to the responsible binary via `ContextProcessId`:
   
    ```
    #event_simpleName=NetworkConnectIP4
    | aid="<AID>"
    | RemoteAddressIP4=/<one of the edge IPs>/
    | groupBy([aid, ContextProcessId], function=count(as=Conns))
    | join({#event_simpleName=ProcessRollup2}, field=[aid, ContextProcessId],
           key=[aid, TargetProcessId],
           include=[FileName, ImageFileName, CommandLine, ParentBaseFileName, UserName],
           mode=left)
    | table([UserName, ParentBaseFileName, FileName, CommandLine, ImageFileName, Conns])
    ```