Home › The Stash

Find C2 beaconing with Zeek: a hands-on threat hunting lab

LabNovember 6, 2026

IntermediateTestedTime ~40 minTool ZeekNeeds Lab 00Cost freeExam CySA+ 1

0 / 12 steps

The goal

Build a pretend office laptop and a pretend internet inside your own Linux box, then let two things run on the laptop for three minutes: a “person” browsing a website at random, and a harmless script that checks in with a server about every five seconds, the way malware calls home to its command-and-control (C2) server. You will record the traffic, turn it into logs with Zeek, and find the check-ins by their rhythm alone, using a short awk command you can reuse. Then you will map what you found to MITRE ATT&CK, scope it and block it.

Everything stays inside your own machine. The laptop and the “internet” are network namespaces in your own Ubuntu VM or WSL, on the private range 10.88.0.0/24 and the made-up domain sglab.test, which exist only inside that box. The “beacon” is a tiny shell loop that fetches a file containing the word “ok”. No real malware or C2 framework is used, and nothing leaves your computer except the package downloads in step 1. Step 12 removes everything.

Why this lab. Malware that has already gotten in usually needs to ask its operator for orders. It does that by “beaconing”: a small, regular check-in, often dressed up as ordinary web traffic. Signatures miss a lot of it, because the content looks harmless. The rhythm gives it away, because people don’t click on a link every five seconds for hours. CySA+ Domain 1 (Security Operations) expects you to read network logs, spot indicators like beaconing and pick the right response. This is that skill, on a scale small enough to see every number.

Steven’s “malware” for this lab is a while loop with curl in it. Terrifying. Meanwhile your real network is full of honest beacons: update checkers, smart TVs, printers, all calling home on a timer. Rhythm tells you where to look, not what you found. — Nyx

Words you’ll meet

Term Meaning
C2 Command and control. The server an attacker uses to send orders to malware on a victim’s computer and collect what it sends back.
Beacon A regular check-in from infected machine to C2 server: “I’m still here, anything for me?”
Jitter Random variation an attacker adds to the beacon timer (say 5 seconds give or take 1) so the pattern is less obvious.
Zeek A free network monitor. It doesn’t alert by default; it turns traffic into tidy logs: one for every connection, every web request, every DNS lookup.
conn.log Zeek’s log with one line per connection: when, who to whom, which port, how long, how many bytes.
Inter-arrival time The gap between one connection and the next between the same two machines.
CV Coefficient of variation: standard deviation divided by the mean. Near 0 means “like clockwork”; 0.5 or more means “all over the place”.
IOC Indicator of compromise. A clue you can search for elsewhere, such as a domain, IP address or user agent.

Before you start. Use the Ubuntu VM from Lab 00 or Ubuntu 24.04 in WSL2 on Windows. On a Mac, use the Lab 00 VM; this lab needs Linux networking. Open a terminal and get a root shell with sudo -i (the prompt changes to #). Do every step in that same terminal, because step 1 sets the PATH and later steps change folder. You need about 250 MB of disk and very little RAM. Step 5 waits three minutes while the traffic runs; that’s a good time to read ahead.

Steps

01Install Zeek and the helpers

Ubuntu’s own Zeek package is old, so add the Zeek project’s official package repository (hosted on the openSUSE Build Service) and install zeek-core: the engine and zeek-cut, nothing else. tcpdump records traffic, dnsmasq-base is a tiny DNS server for the pretend internet.

# apt update 2>&1 | tail -1
# DEBIAN_FRONTEND=noninteractive apt install -y curl gpg tcpdump dnsmasq-base 2>&1 | tail -1
# install -d /etc/apt/keyrings
# curl -fsSL https://download.opensuse.org/repositories/security:zeek/xUbuntu_24.04/Release.key | gpg --dearmor --yes -o /etc/apt/keyrings/zeek.gpg
# echo 'deb [signed-by=/etc/apt/keyrings/zeek.gpg] http://download.opensuse.org/repositories/security:/zeek/xUbuntu_24.04/ /' > /etc/apt/sources.list.d/zeek.list
# apt update 2>&1 | tail -1
# apt install -y zeek-core 2>&1 | tail -1
# export PATH=/opt/zeek/bin:$PATH
# zeek --version
# which zeek-cut
...
Setting up zeek-core (9.0.1-0) ...
zeek version 9.0.1
/opt/zeek/bin/zeek-cut

Why zeek-core and not zeek? The full zeek package also pulls in zeekctl (for running Zeek as a permanent sensor), developer files, the zkg package manager and even a mail server (postfix), about 25 packages in all. To read a capture file you only need the engine. Zeek installs under /opt/zeek, which is why the export PATH line is there. Your version may be newer than 9.0.1; the logs used here have looked the same for years.

02Build a laptop and a pretend internet

Two network namespaces joined by one virtual cable called sg0. office is the laptop, 10.88.0.10. outside plays the internet with three addresses: a DNS server (.53), a normal website (.80) and the “C2” server (.66). The last line but one tells the laptop which DNS server to use.

# ip netns add office; ip netns add outside
# ip link add sg0 netns office type veth peer name sg0 netns outside
# ip -n office addr add 10.88.0.10/24 dev sg0
# ip -n outside addr add 10.88.0.53/24 dev sg0; ip -n outside addr add 10.88.0.66/24 dev sg0; ip -n outside addr add 10.88.0.80/24 dev sg0
# ip -n office link set lo up; ip -n office link set sg0 up
# ip -n outside link set lo up; ip -n outside link set sg0 up
# mkdir -p /etc/netns/office && echo 'nameserver 10.88.0.53' > /etc/netns/office/resolv.conf
# ip netns exec office ping -c 1 -W 1 10.88.0.66 | grep received
1 packets transmitted, 1 received, 0% packet loss, time 0ms

Why /etc/netns? A network namespace is a private copy of Linux networking, so it behaves like a separate computer. Programs started with ip netns exec office see the file /etc/netns/office/resolv.conf in place of the normal /etc/resolv.conf, so only the laptop asks 10.88.0.53 for names. Your own system’s DNS is untouched.

03Start DNS and two web servers

Make three web pages of different sizes (a title, 60 headlines, 15 lines) and a file called sync that just says ok. Python’s built-in web server serves them on the website (port 80) and on the C2 address (port 8080). dnsmasq answers two names: www.sglab.test and cdn-sync.sglab.test, a name chosen to look boring, as real C2 names often are.

# mkdir -p /tmp/zeeklab/www && cd /tmp/zeeklab
# echo '<h1>Intranet</h1>' > www/index.html; seq -f '<p>Headline %g</p>' 60 > www/news.html; seq -f '<p>About us, part %g</p>' 15 > www/about.html; echo ok > www/sync
# ip netns exec outside python3 -m http.server 80 --bind 10.88.0.80 --directory /tmp/zeeklab/www >/dev/null 2>&1 &
# ip netns exec outside python3 -m http.server 8080 --bind 10.88.0.66 --directory /tmp/zeeklab/www >/dev/null 2>&1 &
# ip netns exec outside dnsmasq --listen-address=10.88.0.53 --bind-interfaces --no-resolv --no-hosts --address=/www.sglab.test/10.88.0.80 --address=/cdn-sync.sglab.test/10.88.0.66 --pid-file=/tmp/zeeklab/dnsmasq.pid
# sleep 2; ip netns exec office curl -s http://www.sglab.test/
# ip netns exec office curl -s 'http://cdn-sync.sglab.test:8080/sync?id=lab42'
<h1>Intranet</h1>
ok

What the dnsmasq switches do. --listen-address and --bind-interfaces keep it on 10.88.0.53 only. --no-resolv stops it forwarding anything to the real internet. Each --address=/name/ip is a fixed answer. If both lines came back, names resolve and both servers work. If the first curl prints nothing, check that dnsmasq started (no error after the long line) and that step 2’s resolv.conf line ran.

04Write the person and the beacon

Two small scripts. noise.sh is a person reading the website: a random page, then a random pause of 1 to 8 whole seconds. beacon.sh is the “implant”: fetch /sync from the C2 name, then sleep 4.0 to 5.9 seconds. Copy the whole block.

# cat > noise.sh <<'EOF'
#!/bin/bash
# a person browsing: random page, random pause of 1-8 seconds
pages=(index news about)
while true; do
  curl -s -o /dev/null -A 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0' "http://www.sglab.test/${pages[RANDOM % 3]}.html"
  sleep $((1 + RANDOM % 8))
done
EOF
# cat > beacon.sh <<'EOF'
#!/bin/bash
# a harmless "implant": check in about every 5 seconds, with up to a second of jitter
while true; do
  curl -s -o /dev/null -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' 'http://cdn-sync.sglab.test:8080/sync?id=lab42'
  sleep $((4 + RANDOM % 2)).$((RANDOM % 10))
done
EOF
# wc -l noise.sh beacon.sh
  7 noise.sh
  6 beacon.sh
 13 total

Notice the disguise. Both scripts claim to be a web browser in their user agent (the -A text), and the beacon’s domain looks like a content delivery network. Real C2 traffic does the same, which is why you will not find it by reading names. $((4 + RANDOM % 2)).$((RANDOM % 10)) builds a random number from 4.0 to 5.9: that is the jitter. Real implants call it something like “sleep 5, jitter 20%”.

05Record three minutes of traffic

Start tcpdump on the “internet” side of the cable, then start the person and the beacon on the laptop. timeout stops each one by itself (the scripts after 180 seconds, tcpdump after 190), so the last line just waits a little over three minutes and then shows the capture file.

# ip netns exec outside timeout 190 tcpdump -i sg0 -w /tmp/zeeklab/lab.pcap 2>/dev/null &
# sleep 2
# ip netns exec office timeout 180 bash noise.sh &
# ip netns exec office timeout 180 bash beacon.sh &
# sleep 195; ls -l lab.pcap
# tcpdump -nr lab.pcap 2>/dev/null | wc -l
-rw-r--r-- 1 tcpdump tcpdump 155922 Nov  5 20:06 lab.pcap
1271

Where a sensor sits. In a real network this capture point is a SPAN port or network tap at the edge, where everything going to the internet passes. Here it is the outside end of the cable, which sees the same thing. While you wait: the person makes a request every 1 to 8 seconds, so over three minutes it should make about as many requests as the beacon. That is on purpose. You can’t pick out the beacon by counting. Your shell will also print job lines such as [3] 4021 when things start and Exit 124 when they stop; 124 is simply timeout saying “stopped on schedule”. Your packet count will differ from the one shown.

06Let Zeek read the capture

Make a folder for the logs and point Zeek at the capture file. -r reads a file instead of a live interface. -C tells Zeek to ignore bad checksums, which are normal on virtual cables because the network card is supposed to fill them in.

# mkdir -p zeek && cd zeek
# zeek -C -r ../lab.pcap
# ls
# head -8 conn.log | cut -c1-120
conn.log
dns.log
files.log
http.log
packet_filter.log
#separator \x09
...
#path	conn
#open	2026-11-05-20-06-42
#fields	ts	uid	id.orig_h	id.orig_p	id.resp_h	id.resp_p	proto	service	duration	orig_bytes	resp_bytes	conn_state	local_ori
#types	time	string	addr	port	addr	port	enum	string	interval	count	count	string	bool	bool	count	string	count	count	count	

One capture, five logs. Zeek recognized the web and DNS traffic and wrote a log for each, plus conn.log for every connection of any kind. The header lines starting with # describe the tab-separated columns: #fields gives their names, #types their types. You won’t read these files raw for long; zeek-cut picks columns by name. Leave out -C and Zeek prints a warning about checksum offloading and throws away the TCP packets it thinks are damaged: conn.log still appears, but http.log doesn’t.

07Who talked to whom, and how often

The first line shows a few connections with readable times (-d). The second is the classic first question of any network hunt: group by source, destination and port, then count.

# zeek-cut -d ts id.orig_h id.resp_h id.resp_p proto service < conn.log | head -6
# zeek-cut id.orig_h id.resp_h id.resp_p proto service < conn.log | sort | uniq -c | sort -rn
2026-11-05T20:03:27-0500	10.88.0.10	10.88.0.66	8080	tcp	http
2026-11-05T20:03:27-0500	10.88.0.10	10.88.0.80	80	tcp	http
2026-11-05T20:03:31-0500	10.88.0.10	10.88.0.80	80	tcp	http
2026-11-05T20:03:31-0500	10.88.0.10	10.88.0.66	8080	tcp	http
2026-11-05T20:03:27-0500	10.88.0.10	10.88.0.53	53	udp	dns
2026-11-05T20:03:27-0500	10.88.0.10	10.88.0.53	53	udp	dns
     78 10.88.0.10	10.88.0.53	53	udp	dns
     41 10.88.0.10	10.88.0.80	80	tcp	http
     37 10.88.0.10	10.88.0.66	8080	tcp	http
      2 fe80::d456:7dff:fe91:2060	ff02::2	134	icmp	-
      2 fe80::b0d0:6bff:fe25:3b5b	ff02::2	134	icmp	-
      1 fe80::d456:7dff:fe91:2060	ff02::16	0	icmp	-
      1 fe80::b0d0:6bff:fe25:3b5b	ff02::16	0	icmp	-

Reading it. Three conversations, all from the laptop (10.88.0.10): 78 DNS lookups, 41 connections to the website and 37 to 10.88.0.66 on port 8080. The fe80:: lines are IPv6 housekeeping the virtual network cards send by themselves when they come up (looking for a router, joining multicast groups); ignore them. Counting alone doesn’t point at the beacon: the website got about as many connections. That is the trap with “top talkers” lists. Notice too that the lines are not in strict time order. Zeek writes a connection to conn.log when it ends, not when it starts.

08What was asked for: http.log and dns.log

Now the content. http.log has one line per web request; dns.log one per DNS question.

# zeek-cut host uri user_agent < http.log | sort | uniq -c | sort -rn
# zeek-cut query qtype_name answers < dns.log | sort | uniq -c | sort -rn
     37 cdn-sync.sglab.test:8080	/sync?id=lab42	Mozilla/5.0 (Windows NT 10.0; Win64; x64)
     17 www.sglab.test	/about.html	Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
     12 www.sglab.test	/news.html	Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
     12 www.sglab.test	/index.html	Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
     41 www.sglab.test	AAAA	-
     41 www.sglab.test	A	10.88.0.80
     37 cdn-sync.sglab.test	AAAA	-
     37 cdn-sync.sglab.test	A	10.88.0.66

Three small clues. The same URL, with the same id=, requested over and over, while the person moves between pages. A “Windows” browser on a laptop whose other traffic says Linux and Firefox: one machine, two user agents. And the beacon looks its server up in DNS before every single check-in (one A and one AAAA question each time), so dns.log alone would show you the pattern even if the traffic were encrypted. None of these proves anything on its own. Plenty of honest software repeats one URL and sets an odd user agent.

09Measure the rhythm

This is the hunt. For every source → destination:port pair, the awk program takes the time of each connection, subtracts the time of the one before, and keeps a running count, sum and sum of squares of those gaps. At the end it prints the mean gap, the standard deviation and the CV (standard deviation ÷ mean), lowest CV first. Pairs with fewer than 10 gaps are skipped. It is one long line; copy it whole.

# zeek-cut ts id.orig_h id.resp_h id.resp_p < conn.log | sort -n | awk '{k=$2" -> "$3":"$4} k in last {g=$1-last[k]; n[k]++; s[k]+=g; q[k]+=g*g} {last[k]=$1} END {for (k in n) if (n[k] >= 10) {m=s[k]/n[k]; sd=sqrt(q[k]/n[k]-m*m); printf "%-28s gaps=%-3d mean=%5.2fs sd=%5.2fs cv=%.2f\n", k, n[k], m, sd, sd/m}}' | sort -t= -k5,5n
10.88.0.10 -> 10.88.0.66:8080 gaps=36  mean= 4.95s sd= 0.56s cv=0.11
10.88.0.10 -> 10.88.0.80:80  gaps=40  mean= 4.43s sd= 2.13s cv=0.48
10.88.0.10 -> 10.88.0.53:53  gaps=77  mean= 2.31s sd= 1.50s cv=0.65

There it is. The connections to 10.88.0.66:8080 come every 4.95 seconds on average, give or take 0.56: a CV of 0.11. The person browsing the website scores 0.48, and DNS, which both scripts trigger, 0.65. Nothing a human does by hand comes back at the same interval 36 times in a row. A CV under about 0.2 over many connections is worth a look; the exact cut-off is a judgment call, and an attacker with a lot of jitter can push it up. Why skip pairs with few gaps? Two connections an hour apart have a CV of exactly 0, which looks perfect and means nothing. Real tools add more tests on top of this one. RITA, a free tool from Active Countermeasures, reads Zeek logs and scores every pair on timing, data sizes and how long the connections have gone on, across hours or days instead of three minutes.

10See the jitter, and the same size every time

A histogram of the gaps, rounded to the nearest half second: first for the suspect, then for the website. The last line counts connections by bytes sent and received.

# zeek-cut ts id.resp_h < conn.log | awk '$2=="10.88.0.66"' | sort -n | awk 'NR>1 {printf "%.1f\n", int(($1-p)*2+0.5)/2} {p=$1}' | sort -n | uniq -c
# zeek-cut ts id.resp_h < conn.log | awk '$2=="10.88.0.80"' | sort -n | awk 'NR>1 {printf "%.1f\n", int(($1-p)*2+0.5)/2} {p=$1}' | sort -n | uniq -c | head -12
# zeek-cut id.resp_h id.resp_p orig_bytes resp_bytes < conn.log | awk '$2==80 || $2==8080' | sort | uniq -c | sort -rn
      5 4.0
      8 4.5
     11 5.0
      9 5.5
      3 6.0
      4 1.0
      5 2.0
      5 3.0
      8 4.0
      4 5.0
      6 6.0
      4 7.0
      4 8.0
     37 10.88.0.66	8080	131	202
     17 10.88.0.80	80	147	552
     12 10.88.0.80	80	147	203
     12 10.88.0.80	80	146	1318

What jitter looks like. The beacon’s gaps all sit between 4.0 and 6.0 seconds, bunched around 5: the timer plus jitter. The website’s gaps spread evenly from 1 to 8 seconds, because a person’s pauses have no center. Jitter makes a beacon less obvious, but it still leaves a clear center. The byte counts tell the same story: the beacon sent 131 bytes and got 202 back all 37 times, while the person’s requests come back in different sizes depending on the page. Real data is messier (pages change, beacons sometimes carry orders), which is why RITA looks at how spread out the sizes are rather than requiring an exact match.

11Name it, scope it, contain it

First, name it in MITRE ATT&CK terms. C2 over ordinary web requests is T1071.001 Application Layer Protocol: Web Protocols. Then scope it: which machines looked up the C2 name? Then contain it: block the C2 address and check that normal browsing still works.

# zeek-cut id.orig_h query < dns.log | grep cdn-sync | sort | uniq -c
# ip netns exec office nft add table inet contain
# ip netns exec office nft add chain inet contain out '{ type filter hook output priority 0; }'
# ip netns exec office nft add rule inet contain out ip daddr 10.88.0.66 counter drop
# ip netns exec office curl -s -m 3 -o /dev/null -w '%{http_code}\n' 'http://cdn-sync.sglab.test:8080/sync?id=lab42'; echo "exit $?"
# ip netns exec office curl -s -m 3 -o /dev/null -w '%{http_code}\n' http://www.sglab.test/
# ip netns exec office nft list chain inet contain out | grep counter
     74 10.88.0.10	cdn-sync.sglab.test
000
exit 28
200
		ip daddr 10.88.0.66 counter packets 3 bytes 180 drop

Only one laptop, and it’s blocked. Every one of the 74 lookups of cdn-sync.sglab.test (37 A plus 37 AAAA) came from 10.88.0.10, so one machine is affected. After the block, the beacon’s request times out (000, curl exit code 28 means “timed out”) while the website still answers 200, and the rule’s counter shows the dropped packets. In a real network the block goes on the edge firewall, web proxy or DNS (a “sinkhole” that answers the C2 name with a dead address), so every machine is covered; here the laptop’s own firewall stands in for it. Your IOCs to search for everywhere else come in two kinds. Atomic ones are single values: the domain, the IP and port, the URL with its id=, the user agent. The attacker can change any of them in a minute. The behavioral one is the five-second rhythm with a same-size check-in, which still matches after the domain changes; that is why behavior sits higher on the Pyramid of Pain. Blocking also tells the attacker you’ve noticed, so in a real incident you decide that with the incident response lead, and you still have to find and remove whatever is running on the laptop.

What we didn’t map. T1573 Encrypted Channel doesn’t apply: this beacon is plain HTTP, which is why you could read its URL. Over HTTPS, http.log would be empty, but conn.log and dns.log would still show every check-in, so step 9 would work unchanged. T1571 Non-Standard Port is a maybe at most: 8080 is a common alternative web port. Map what the evidence shows, not everything that sounds close.

12Clean up

Stop the servers, delete both namespaces (which also deletes the cable and the laptop’s firewall rule) and remove the lab files.

# pkill -f 'http.server 80 --bind 10.88.0.80'; pkill -f 'http.server 8080 --bind 10.88.0.66'
# kill $(cat /tmp/zeeklab/dnsmasq.pid)
# ip netns del office; ip netns del outside
# cd / && rm -rf /tmp/zeeklab /etc/netns/office

There is nothing to see except perhaps a few Terminated job messages for the two web servers, which is what you want. Zeek stays installed for the next lab. To remove it too: apt remove zeek-core, then delete /etc/apt/sources.list.d/zeek.list and /etc/apt/keyrings/zeek.gpg.

What you learned

  • Beaconing — malware checking in with its C2 server on a timer, usually dressed up as normal web or DNS traffic
  • Zeek — turns a capture into conn.log, http.log and dns.log; zeek-cut picks columns by name
  • Count, then time — top talkers don’t find a beacon; the regularity of the gaps does (low CV)
  • Jitter — widens the spread but leaves a tight cluster around the timer, still far tighter than a person
  • Supporting clues — same URL and size every time, a user agent that doesn’t fit, a DNS lookup before each check-in
  • ATT&CK — T1071.001 for C2 over web requests; only map what the evidence shows
  • Response — scope with the logs, contain at the edge, collect IOCs, then fix the host

Tried it? Tell us

Nova built this lab from Steven’s ideas, and we ran every step once before publishing. If a step broke, behaved differently for you, or you know a better way, tell us. Ideas for the next lab are welcome too — no promises on timing.

NextMISP threat intel: turn your IOCs into shared intelligence→

Go deeper

Original walkthrough. Every command was run end to end, exactly as shown, on Ubuntu 24.04 (WSL2, kernel 6.6.87.2, Zeek 9.0.1 from the official openSUSE Build Service repository, tcpdump 4.99.4, dnsmasq 2.91) on 5 Nov 2026; outputs are from that run, trimmed where marked with .... Step 1’s output is from the first full run, when Zeek was installed fresh; the final run found it already installed. The step 6 claim about leaving out -C was checked separately on a similar capture. Your counts, gaps and timestamps will differ a little, because the scripts are random. StashGrid isn’t affiliated with CompTIA, MITRE, the Zeek project, Corelight or Active Countermeasures. If something doesn’t match what your machine does, tell me and I will fix it.