Going Deeper — Beacons, a Second Host, and What the Data Confessed
- Jul 10
- 10 min read
"Part 1 told me something was wrong. Part 2 is where I learned to tell a real signal from a convincing one."
In Part 1, I loaded a Zeek conn.log into SQLite and identified a Mirai botnet infection: mass scanning, the telnet-family port signature, and a success-rate comparison that made the compromise undeniable. That felt like a complete story.
Then I kept querying. I want to be honest with you about what Part 2 actually became — because it turned into a lesson about the difference between a finding that looks right and one that reproduces. Some of what I first thought I'd found didn't survive a second, stricter look. The story below is the one the data actually supports, checked query by query against the raw logs.
💡 A note on method
Every figure in this post came off my own screen, from a query I can re-run. Where an earlier pass over-counted or mislabeled something, I've said so plainly and shown the corrected result. In threat hunting, an anomaly that dissolves under a tighter filter was noise. One that survives is a finding. Learning to tell them apart is the whole job.
Query 1: The Heartbeat — C2 Beaconing (and a false start)
The most important question after confirming an infection is whether any host is still taking orders — reaching out to a command-and-control server on a schedule. A beacon is a bot raising its hand at fixed intervals to ask "what next?" The instinct is the same one I used reading atmospheric data: a signal you can trust isn't the loudest reading, it's the one that keeps time.
My first attempt looked for destinations whose average gap between connections fell into a beacon-like window. That was a trap. Averaging is blind to burstiness — a host that fires ten connections in half a second and then goes quiet for a minute has the same average gap as one that connects steadily every six seconds. One is scan spray; the other is a beacon. When I pulled the candidates, the giveaway was in the spread: minimum gaps near zero, maximum gaps in the tens of seconds. Those weren't heartbeats. They were scan bursts that happened to average into range.
The fix was to filter on regularity, not average — the coefficient of variation (standard deviation ÷ mean). A true beacon clusters tightly around its period; bursty traffic scatters. I also constrained direction: beacons are outbound, internal host to external server. Without that, inbound scan traffic hitting internal machines gets counted as beaconing.
WITH ordered AS (
SELECT orig_h, resp_h, CAST(ts AS REAL) AS t,
LAG(CAST(ts AS REAL)) OVER (
PARTITION BY orig_h, resp_h ORDER BY CAST(ts AS REAL)) AS prev_t
FROM conn_logs
WHERE orig_h LIKE '192.168.%'
AND resp_h NOT LIKE '192.168.%'),
gaps AS (SELECT orig_h, resp_h, (t - prev_t) AS gap
FROM ordered WHERE prev_t IS NOT NULL)
SELECT orig_h, resp_h, COUNT(*) AS conns,
ROUND(AVG(gap),1) AS avg_gap
FROM gaps GROUP BY orig_h, resp_h
HAVING conns >= 50 AND cv < 0.35
ORDER BY cv ASC;Under that filter, the inflated list collapsed to a single host with genuine periodic behavior:
Infected Host | C2 Server | Connections | Avg Interval | CV |
192.168.60.22 | 69.43.168.214 | 216 | 757.8s | 0.06 |
192.168.60.22 | 109.74.9.119 | 217 | 754.5s | 0.09 |
192.168.60.22 | 192.188.58.163 | 217 | 754.3s | 0.09 |
192.168.60.22 | 203.153.165.21 | 217 | 754.2s | 0.09 |
192.168.60.22 beacons to four external C2 servers on a steady ~755-second cadence — roughly every 12.6 minutes — held so consistently that the variation stays under 10% across more than 200 callbacks to each. That regularity is the signature. This host wasn't scanning; it was checking in, on schedule, with a fixed set of controllers.
💡 Why regularity beats average
Legitimate traffic is irregular — humans and applications connect when they need to. A scheduler firing on a timer produces the opposite: the same interval, over and over, regardless of what the network is doing. The coefficient of variation captures exactly that. A low CV means "this repeats like a metronome," and metronomes in network logs are almost always automation, not people.
Query 2: A Second Infected Host — Internal Reconnaissance
When I profiled connection-success rates across every internal host, one machine stood out for the opposite reason .43 had: 192.168.10.50 completed barely 0.5% of its connections across a quarter-million attempts. Something on this host was knocking constantly and almost never getting an answer.
SELECT
CASE WHEN resp_h LIKE '192.168.%' THEN 'internal'
ELSE 'external' END AS target_type,
COUNT(*) AS conns,
COUNT(DISTINCT resp_h) AS distinct_targets
FROM conn_logs
WHERE orig_h = '192.168.10.50'
GROUP BY target_type;Where .43 fired outward at 338,000 external IPs, .50 pointed inward: of its 251,368 connections, all but three targeted internal 192.168.x addresses — reaching 771 distinct internal hosts, effectively every machine on the network. And the shape of the probing told a two-phase story:
Activity | Connections | Targets | Meaning |
443 (HTTPS) | 51,686 | 768 | Broad sweep — probing HTTPS on nearly every host |
ICMP (type 14) | 18,573 | 763 | Timestamp sweep — host discovery |
ICMP (type 0) | 18,060 | 769 | Echo sweep — "who's alive?" |
445 (SMB) | 3,439 | 8 | Narrow — targeted at 8 specific hosts |
21 (FTP) / 135 (RPC) / 139 (NetBIOS) | ~1,360 combined | 7 each | Targeted service enumeration |
Two phases stand out. First a broad discovery sweep — HTTPS and ICMP probes fanning across ~770 internal hosts, the network equivalent of walking every hallway to see who answers. Then a narrow, targeted probe — SMB, RPC, NetBIOS, and FTP aimed at only 7–8 specific hosts, presumably the ones that responded. Wide reconnaissance, then selective enumeration. That selection is what makes it read like recon rather than noise.
The connection states confirm it: S0 (no response, 111,382) and REJ (actively refused, 101,972) together account for 85% of .50's traffic. That isn't a broken host — it's exactly what enumeration looks like from the log side. Most probes hit machines that don't offer the service, so they time out or get refused. The 0.5% success rate is the fingerprint of a host systematically testing every door in the building, most of them locked.
🔎 One honest limit
The logs show what .50 is doing — sweeping the network — not definitively why. A legitimate vulnerability scanner produces similar traffic. In a real environment the next step is to check whether .50 is a known, authorized asset. Given the surrounding compromise, malicious internal reconnaissance is the strongest hypothesis — but distinguishing it from a sanctioned scanner is exactly the kind of question a good analyst keeps open.
So the infection wasn't one host doing one thing. It was two, with different jobs:
192.168.10.43 | 192.168.10.50 | |
Direction | Outward (external) | Inward (internal) |
Targets | 338,181 internet IPs | 771 internal hosts |
Ports | Telnet family (23/2323/2222) | HTTPS + ICMP sweep, then SMB/RPC/FTP |
Role | Mirai propagation | Internal reconnaissance |
Success rate | 17.7% | 0.5% |
Query 3: External Attackers — SSH Brute Force from Outside
While the internal hosts were scanned, external IPs were hammering the network from outside. This query surfaced completed SSH sessions and their average bytes sent — small, repetitive transfers being the signature of automated credential attempts:
SELECT orig_h, COUNT(*) AS attempts,
AVG(CAST(orig_bytes AS REAL)) AS avg_bytes_sent
FROM conn_logs
WHERE resp_p = '22'
AND conn_state = 'SF'
GROUP BY orig_h
ORDER BY attempts DESC
LIMIT 15;Source IP | SSH Sessions | Avg Bytes | Assessment |
18.191.216.176 | 4,168 | 225 | Automated credential stuffing |
218.65.30.30 | 2,875 | 1,659 | Higher-byte brute force tool |
58.218.199.133 | 1,519 | 1,370 | Sequential IP pair — coordinated |
182.100.67.4 | 1,377 | 1,663 | External brute force |
192.168.10.43 | 690 | 1,021 | Infected internal host also brute-forcing SSH |
58.218.199.134 | 84 | 1,290 | Sequential to .133 — same attacker |
5.45.85.158 | 62 | 20,114 | ⚠ Massive avg bytes — possible successful intrusion |
5.45.86.133 | 20 | 738,330 | ⚠ Extreme transfer — flag for investigation |
Three things stand out. First, 58.218.199.133 and .134 are sequential IPs running the same attack — one operator across a small block of machines, not one person at one keyboard. Second, 192.168.10.43 appears here too: the infected internal host wasn't only scanning for telnet victims, it was attempting SSH logins where it found the port open. Third, and most alarming: 5.45.85.158 logged only 62 completed sessions but averaged over 20,000 bytes each — every other brute-forcer averaged under 2,000. And an adjacent IP, 5.45.86.133, averaged over 700,000 bytes across 20 sessions. Those outliers demanded their own look.
Query 4: The Intruder Inside — 5.45.85.158
Brute-force attempts are noise until one succeeds. To separate a failed attacker from a successful one, I pulled everything 5.45.85.158 did — session count, average and total bytes, and the longest single session:
SELECT orig_h, resp_h,
COUNT(*) AS sessions,
AVG(CAST(orig_bytes AS REAL)) AS avg_bytes,
MAX(CAST(duration AS REAL)) AS longest_session,
SUM(CAST(orig_bytes AS REAL)) AS total_bytes
FROM conn_logs
WHERE orig_h = '5.45.85.158'
GROUP BY orig_h, resp_h;Metric | Value | What it means |
Target | 192.168.10.43 | Single victim — the same host Mirai infected |
Sessions | 85 | Sustained access, not a one-off |
Avg bytes/session | 47,034 | Interactive activity, not credential-guessing |
Longest session | 11,888s (3h 18m) | Hands-on-keyboard presence inside the network |
Total transferred | ~4.0 MB | Real data movement into the host |
⚠ Confirmed intrusion
An external IP holding a 3-hour-18-minute session with an internal host, moving roughly 4 MB across 85 sessions, is not a brute-force attempt that failed. It's an attacker who got in and stayed. And the victim, 192.168.10.43, is the same host running the Mirai scanner — the scanning begins just 29 minutes after this intrusion, which points to it as the trigger for .43's compromise. (Notably, the timestamps show two other hosts were already active more than a day earlier — see the timeline below — so this intrusion explains .43, but the network was already compromised before it.)
The Timeline — What Order Did This Actually Happen In?
I assumed the SSH intrusion was patient zero — the first event, from which everything else cascaded. The timestamps said otherwise. Pulling the first-seen time for each activity in the capture reordered the whole story:
Time (UTC) | Event | Host |
May 1, 12:34 | Internal recon begins | 192.168.10.50 — already active at capture start |
May 1, 17:38 | C2 beaconing begins | 192.168.60.22 → 4 external servers |
May 2, 16:36 | SSH intrusion begins (~28h later) | 5.45.85.158 → 192.168.10.43 |
May 2, 17:05 | Mirai scanning begins (+29 min) | 192.168.10.43 → 338,181 external IPs |
⚠ The assumption that didn't survive
Internal recon and C2 beaconing were running more than a day before the SSH intrusion I could see. The intrusion cleanly explains one host — .43 starts scanning just 29 minutes after the attacker logs in — but it does not explain .50 or .60.22, which were already active. The network was compromised before the intrusion in the logs. (One caveat: these are first-seen-in-capture times, not necessarily true patient-zero — an earlier compromise could predate the logged window entirely.)
Query 5: The Network Looked Perfectly Normal
The most unsettling query was the simplest. While all of this was happening, what did the "healthy" traffic look like?
SELECT service, COUNT(*) AS total
FROM conn_logs
WHERE service != '-'
AND conn_state = 'SF'
GROUP BY service
ORDER BY total DESC
LIMIT 15;Service | Completed Sessions | Note |
http | 74,614 | Normal web browsing — users had no idea |
dns | 33,230 | Name resolution — the next log to cross-reference |
ssl | 30,355 | Encrypted traffic — C2 can hide here |
smtp | 9,614 | Email — Mirai variants weaponize this for spam |
ssh | 7,786 | Includes the attacker's active sessions |
ntp | 234 | Time synchronization — normal |
Seventy-four thousand HTTP sessions completed normally. DNS kept resolving. Email kept flowing. From the outside, nothing looked wrong — while two internal machines were compromised, an attacker held multi-hour sessions inside the network, and a third host beaconed to external controllers on a timer.
That gap — between what the traffic looked like and what was actually happening — is exactly why threat hunting exists. Alerts tell you something fired. Hunting tells you what was already there before anything fired at all.
The Complete Investigation — Both Parts
Part | Query | Finding |
Part 1 | Connection state breakdown | Mass S0 scanning confirmed |
Part 1 | S0 by source host | One host responsible for the bulk of scans |
Part 1 | Port analysis | Mirai signature — ports 23, 22, 2323 |
Part 1 | Clean host comparison | 17.7% vs 94.6% success rate (SF-only) |
Part 2 | Beacon detection (CV) | 192.168.60.22 → 4 C2 servers, ~755s cadence |
Part 2 | Internal recon | 192.168.10.50 swept 771 internal hosts |
Part 2 | SSH brute force | Multiple external IPs attacking simultaneously |
Part 2 | Intruder deep-dive | 5.45.85.158 — 3h18m session, ~4MB, confirmed inside |
Part 2 | Services query | Network appeared normal while breach was active |
Nine queries, re-audited against the raw logs. One complete breach — external intrusion, two infected hosts, and a controller on a timer — built on a Mac, in SQLite, from a single CSV.
🔗 ISC2 CC Domain Connections
This investigation reinforces Domain 1 (Security Principles — confidentiality and availability both at risk), Domain 4 (Network Security — internal reconnaissance, unauthorized access, C2 communication), and Domain 5 (Security Operations — incident identification, evidence collection, escalation). The 5.45.85.158 finding maps directly to IR concepts around containment and forensic preservation.
🪞 Reflection questions I am sitting with
5.45.85.158 held a session over three hours. In a real environment, at what point should behavioral analytics flag session duration as anomalous?
My first beacon pass over-counted badly. What automated guardrails would keep a production detection rule from making the same mistake at scale?
.50 swept the whole internal network. How would I confirm whether it was a compromised host or an authorized scanner before escalating?
74,000 HTTP sessions completed normally while the breach was live. How do defenders isolate compromised hosts without disrupting legitimate traffic?
The dns.log is still untouched. Given the four C2 IPs from .60.22, what would I expect those lookups to reveal?
What I Would Do Next
Analyze the DNS log. 33,230 completed DNS sessions sit in dns.log untouched. Cross-referencing those lookups with the four C2 IPs from 192.168.60.22 and the 5.45.85.158 sessions would likely name the botnet infrastructure.
Confirm the status of 192.168.10.50. Its network-wide sweep is either lateral movement or an authorized scanner. A single asset-inventory check would resolve which — and that distinction changes the whole containment decision.
Trace the earlier compromise. The timeline shows internal recon and C2 beaconing active before the SSH intrusion in the logs. Pulling earlier captures — or correlating with DNS and auth logs from before this window — would help find how .50 and .60.22 were first compromised, since the SSH intrusion only explains .43.
🔧 Run it yourself
Every query in this two-part investigation is on GitHub — documented in order, each with its purpose and finding, and reproducible against the public dataset. Clone it, load the log, and run the same hunt: github.com/Tash925/mirai-zeek-detection
💼 From the Analyst's Desk to the SOC Floor
This two-part investigation demonstrates end-to-end incident analysis from a single raw log file — no SIEM, no pre-built alerts, no guided lab. I identified a Mirai infection and verified every finding against the raw data, isolated genuine C2 beaconing from bursty false positives using statistical regularity, mapped internal reconnaissance across the network, surfaced simultaneous external brute-force campaigns, and confirmed a persistent external intruder holding multi-hour SSH sessions inside the network. Just as important: where an early pass over-counted, I caught it, corrected it, and documented why. For SOC Analyst and Cyber Analyst roles, building a detection narrative from raw data — and knowing when your own finding doesn't hold — is the skill that matters most.
→Read next
Log Analysis for Beginners — TryHackMe's Intro to Log Analysis, and how structured lab work reframed everything I found in this two-part investigation.

Comments