Home / Blog / Threat Hunting
How to Hunt for C2 Beaconing in Proxy and EDR Logs
Find regular callback traffic in proxy and EDR logs with interval analysis in KQL and SPL, then separate real command and control from noisy legitimate software.
Implants on a compromised host usually check in with their controller on a timer, and that timer leaves a pattern in your network logs even when the traffic is encrypted and the domain has no bad reputation. This guide helps analysts who have proxy or EDR data turn that pattern into a repeatable hunt, then decide quickly which results deserve escalation.
Scenario: No alert has fired, but threat intelligence reports describe HTTPS callbacks to newly registered domains in your sector. You want to know whether anything in your estate is checking in on a timer. Your first pass shows WS-114 contacting cdn-update.example roughly every five minutes, day and night, from a process that has no obvious reason to do so.
What does beaconing look like in logs?
A beacon is a repeated, usually small, outbound connection from an implant to its controller. The implant asks whether there is any work to do, gets a short answer, then sleeps. Because the content is often hidden inside TLS, you hunt the behaviour instead of the payload.
Four properties separate a beacon from ordinary browsing:
- Regular timing: the gaps between connections cluster around one value.
- Persistence: the pattern continues outside working hours, as long as the host is awake.
- Consistent size: most requests and responses are nearly the same length, with occasional larger exchanges when tasks run.
- Low prevalence: the destination is contacted by very few hosts in your organisation.
Operators know this, so most frameworks add jitter, a random variation in the sleep time. Jitter blurs the pattern but rarely destroys it, so you measure how tightly the gaps cluster instead of looking for identical gaps.
Note: Beaconing itself is a behaviour, not a single ATT&CK technique. Map what you find to the channel used, such as T1071.001 Web Protocols or T1573 Encrypted Channel, both under Command and Control. If you see regular large uploads, also consider T1029 Scheduled Transfer, which sits under Exfiltration.
Which logs should you hunt in?
You can run this hunt on any log with a timestamp, a source and a destination. What changes is how much you can conclude from a result.
Firewall or flow logs
Good coverage, but you often see only IP addresses, and shared CDN addresses add a lot of noise.
Source IP, destination IP, bytesWeb proxy logs
Adds the hostname, user agent and byte counts. Size consistency checks become possible, but you cannot see the process.
CommonSecurityLog, CIM WebEDR network events
Ties each connection to a device, process and account, which makes triage much faster. Pair it with proxy data for byte counts.
DeviceNetworkEventsTip: If you run Zeek, look at RITA from Active Countermeasures. It is an open source framework that ingests Zeek logs and includes beaconing, long connection and DNS tunnelling detection, so it can give you a second opinion on what your SIEM queries find.
Measure the intervals between connections
The core technique has three steps. Group connections by host and destination, calculate the seconds between each connection and the previous one, then score how consistent those gaps are.
The score used here is the coefficient of variation (CV): the standard deviation divided by the mean. A perfect timer scores 0. Human browsing tends to score much higher. It also gives you a way to reason about jitter: if sleep times vary evenly by plus or minus 30 per cent, the CV works out at about 0.17, and at plus or minus 50 per cent it is about 0.29.
Microsoft Defender XDR or Sentinel
This version keeps the process name in the grouping, so a browser and an implant talking to the same domain do not blur together.
DeviceNetworkEvents
| where Timestamp > ago(24h)
| where ActionType == "ConnectionSuccess"
| where RemoteIPType == "Public"
| extend Destination = iff(isnotempty(RemoteUrl), RemoteUrl, RemoteIP)
| project Timestamp, DeviceName, InitiatingProcessFileName, Destination
| sort by DeviceName asc, InitiatingProcessFileName asc, Destination asc, Timestamp asc
| extend Delta = iff(
DeviceName == prev(DeviceName)
and InitiatingProcessFileName == prev(InitiatingProcessFileName)
and Destination == prev(Destination),
datetime_diff('second', Timestamp, prev(Timestamp)),
long(null))
| where Delta > 5
| summarize Connections = count(),
AvgDelta = avg(Delta),
StdevDelta = stdev(Delta),
MedianDelta = percentile(Delta, 50)
by DeviceName, InitiatingProcessFileName, Destination
| where Connections >= 20 and AvgDelta >= 30
| extend CV = round(StdevDelta / AvgDelta, 3)
| where CV < 0.2
| sort by CV asc
The Delta > 5 filter removes bursts, because one page or one task can open several connections within a second or two. Null deltas, which mark the first row of each group, drop out at the same step.
Splunk
This version assumes proxy data with CIM Web style field names. Swap in the index and fields your environment uses.
index=proxy earliest=-24h
| fields _time src dest bytes_out http_user_agent
| sort 0 src dest _time
| streamstats current=f last(_time) AS prev_time BY src dest
| eval delta=_time-prev_time
| where delta>5
| stats count AS connections avg(delta) AS avg_delta stdev(delta) AS stdev_delta
median(delta) AS median_delta avg(bytes_out) AS avg_bytes_out
values(http_user_agent) AS user_agents BY src dest
| where connections>=20 AND avg_delta>=30
| eval cv=round(stdev_delta/avg_delta,3)
| where cv<0.2
| sort cv
Watch out: These thresholds are starting points, not rules. Requiring 20 connections in 24 hours misses anything sleeping for longer than about an hour, so run a second pass over 7 days with a higher minimum average delta. Raise the CV limit to about 0.35 if you want to catch heavier jitter, and expect more noise when you do. EDR network telemetry may also not record every connection, so a gap in the results does not prove an absence of traffic.
Reduce the noise without hiding the threat
Your first run will be dominated by software that really does phone home on a timer: update agents, security tools, collaboration clients, telemetry services. Handle this carefully, because a careless allowlist is exactly where an attacker would like to hide.
Known agent traffic
MedA signed updater or security agent contacting the vendor's own hostname from many devices.
Exclude the exact process and exact hostname together. Record who approved it and why.
Browser tab polling
MedA browser process contacting a web app on a steady interval, mostly during working hours.
Check the hourly pattern and the prevalence. Lots of users and a working hours shape point to an ordinary web app.
Trusted platform abuse
HighAn unusual process with steady traffic to a cloud service or CDN that you would normally ignore.
Never allowlist a whole platform. T1102 Web Service and T1090.004 Domain Fronting both rely on defenders trusting big names.
Rare destination, odd process
HighOne or two hosts, a recently seen domain and a process running from a user-writable path.
Move straight to host triage. This is the combination the hunt exists to find.
Rank the remainder by prevalence. A destination contacted by 2 devices deserves attention well before one contacted by 2,000. Also look at the shape of the domain names: long random-looking labels may point to T1568.002 Domain Generation Algorithms.
Validate a candidate beacon
Pick one result and test it against the four properties from the start of this guide. Each query below takes about a minute to adapt.
Is the timing really clustered?
A real beacon produces one strong peak in the gap histogram, or a tight band if jitter is in use. A few much larger gaps are normal, as they often match a laptop going to sleep overnight.
DeviceNetworkEvents
| where Timestamp > ago(7d)
| where DeviceName startswith "WS-114"
| where RemoteUrl == "cdn-update.example"
| sort by Timestamp asc
| extend Delta = datetime_diff('second', Timestamp, prev(Timestamp))
| where isnotnull(Delta)
| summarize Gaps = count() by Bucket = bin(Delta, 30)
| sort by Gaps desc
Who else talks to this destination?
DeviceNetworkEvents
| where Timestamp > ago(30d)
| where RemoteUrl == "cdn-update.example"
| summarize Devices = dcount(DeviceName),
FirstSeen = min(Timestamp),
LastSeen = max(Timestamp),
Processes = make_set(InitiatingProcessFileName, 10)
by RemoteUrl
Do the sizes and hours fit?
Proxy data shows whether request sizes stay flat and whether traffic continues overnight. Occasional spikes in sent bytes can mark tasking or data leaving the network.
CommonSecurityLog
| where TimeGenerated > ago(7d)
| where DestinationHostName =~ "cdn-update.example"
| summarize Requests = count(),
AvgSent = avg(SentBytes),
MaxSent = max(SentBytes),
AvgReceived = avg(ReceivedBytes),
Sources = dcount(SourceIP)
by bin(TimeGenerated, 1h)
| sort by TimeGenerated asc
What is running on the host?
This is the step that usually settles the question. Find the process making the connections, then see how it started.
DeviceProcessEvents
| where Timestamp > ago(7d)
| where DeviceName startswith "WS-114"
| where FileName =~ "updatehelper.exe"
| project Timestamp, AccountName, FolderPath, ProcessCommandLine, SHA1,
InitiatingProcessFileName, InitiatingProcessCommandLine
| sort by Timestamp asc
Then work through these checks and note the answer to each in your hunt record:
- Does the process belong on this host, and does it run from its expected path?
- Is the binary signed by the vendor you would expect, and is the hash known in your threat intelligence sources?
- Is the parent process sensible? An Office application or script host starting the process is a warning sign.
- Is there persistence behind it, such as a scheduled task, service or run key created around the first connection?
- How old is the domain, and do its registration and TLS certificate details fit the service it claims to be?
- Does the user agent match the process? A browser string from a non-browser process deserves a closer look.
Decide whether to escalate
Hunts often end without a clear verdict, so agree the outcome levels in advance. That keeps your conclusions consistent between analysts and shifts.
- L0ExplainedKnown software, expected vendor, wide prevalence. Add a narrow exclusion and record it.Close
- L1UnexplainedRegular timing and low prevalence, but the process looks plausible. Ask the asset owner and keep watching.Monitor
- L2Likely C2Timing fits, and the process, path, parent or domain looks wrong. Open an incident and preserve evidence.Escalate
- L3ConfirmedMalicious binary or observed tasking. Follow your incident process, and scope every host contacting the destination.Respond
Watch out: Avoid blocking a domain or killing a process the moment you get suspicious, unless your playbook says to. Doing so can alert the operator and lose you the chance to scope the intrusion. Agree containment timing with your incident lead.
Whatever the outcome, feed the result back. A confirmed beacon gives you new indicators to sweep for. A clean hunt gives you a tuned exclusion list and a query you can schedule, with its thresholds tested on your own data.
Key takeaways
- Beacons are found by behaviour: regular timing, persistence, consistent size and low prevalence.
- Use the coefficient of variation to score timing, so that jitter narrows the signal instead of hiding it.
- Run short and long time windows, because slow beacons will not appear in a 24 hour hunt.
- Scope exclusions to an exact process and hostname, and never to a whole cloud platform.
- Timing only gives you a lead. Process, parent, path and persistence on the host give you the verdict.
Start small: run the first query for one day of data, look at the top ten results, and write down why each one is or is not explained. After a few rounds you will have an exclusion list that fits your estate, and anything new at the top of that list will be worth your time.