🇬🇧 English | 🇹🇷 Türkçe
A statistics-first C2 beaconing detection engine. It hunts jittered
command-and-control traffic in Zeek conn.log data with pure math:
time-series analysis, probabilistic scoring and graph analysis.
Status: v0.1 complete — evaluated on 4 CTU-13 scenarios (Neris ×3 + Virut). MITRE ATT&CK: T1071 (Application Layer Protocol), T1573 (Encrypted Channel) — detection relies on timing/size behaviour, not content signatures.
A C2 implant checks in with its server at regular intervals. Attackers hide this with jitter — but statistics don't lie. Heartbeat Hunter works in three layers:
- Time-series analysis — robust distribution statistics (dominant-cluster CV, MAD, Bowley skewness) + Schuster/Rayleigh periodogram
- Probabilistic scoring — multiple signals combined into a composite score
- Graph analysis — from anomaly to campaign detection
Beacons connect at regular intervals; attackers mask this with jitter. We use two complementary methods:
Dominant-cluster distribution (inspired by RITA): real C2 traffic mixes retry/multi-request bursts in between beacon intervals, so the raw coefficient of variation misleads. Instead we find the densest mode of the inter-arrival distribution and measure the spread inside that cluster. A low cluster-CV alone is not enough (the cluster is narrowly defined, so its CV is mechanically small) — it is used together with cluster support (the share of intervals that fall into the cluster).
Schuster periodogram: R(f) = |Σ exp(2πi·f·tⱼ)|² / n. A direct frequency
scan over event times — no binning required (works where the FFT fails on
irregular sampling), and under Poisson noise R ~ Exp(1), so statistical
significance comes analytically for free.
On CTU-13 Scenario 42 (Neris): no single feature separates the C2 channels from the background — the experimental justification for Layer 2 (multi-signal fusion).
A RITA-style weighted composite score that works without labels: time subscores (dominant-cluster, MAD, Schuster) + byte subscores (beacon payloads have fixed sizes — bursts distort timing but not bytes) + context (destination rarity, persistence, port category). Significance is verified empirically with a BAYWATCH-style bucket-permutation test.
The score alone is not enough: in the CTU-42 exam the entire top-20 was legitimate periodic infrastructure (NTP, SNMP, internal monitoring). The literature's answer is a filter, not a weight (BAYWATCH funnel, Elastic direction filter): the score measures "beacon-likeness", while the scope filter (external destination + non-infrastructure port) narrows the C2 search space. Result: 12,220 pairs → 4,958 candidates; NTP/SNMP cleared from the list, and all four C2 channels rose 2-3× in rank.
hhunter score pairs.parquet --internal-net 147.32.0.0/16 # in-scope list
hhunter score pairs.parquet --all # raw rankingOne beacon is an anomaly; ≥2 internal machines beaconing to the same
destination with a similar period is a campaign. Scored channels go into
a bipartite graph (source↔destination); shared destinations receive a
combined campaign_score from period coherence and channel scores.
Multi-host exam (CTU-13 Scenario 9, Neris, 10 bots): single-channel scoring could only place the first C2 at rank 67; the campaign layer put the real C2 (195.190.13.70, 7 bots, 115 s, coherence 1.0) at rank 1. The main C2 buried at rank 8,177 in Scenario 42 (173.192.170.88) became campaign #5 through the collective evidence of 8 bots. 7 of the top-10 campaigns were botnet infrastructure — including spam/click-fraud channels that carry no CC label (beyond-label detection). Honest limit: in a single-infected-host capture (S42) this layer mechanically cannot fire (≥2 sources required) — there: 55 candidates / 0 CC.
hhunter campaign scored.parquet # campaign candidates| Scenario | First CC: naive −CV | First CC: composite | R@100: naive | R@100: composite |
|---|---|---|---|---|
| S1/42 (Neris, 1 bot) | 59 | 37 | 0.25 | 0.25 |
| S2/43 (Neris, 1 bot) | 869 | 89 | 0.00 | 0.17 |
| S9/50 (Neris, 10 bots) | 87 | 46 | 0.16 | 0.10 (R@500: 0.27→0.41) |
| S13/54 (Virut, 1 bot) | 1,091 | 89 | 0.00 | 0.25 |
The composite score improves the first-CC rank in all four scenarios: 10× on S2 and 12× on a family never seen during tuning (Virut). In the multi-bot S9 the extra layer kicks in: at campaign level the real C2 ranks #1 among 94 candidates (in single-bot captures the ≥2-source requirement mechanically cannot be met — an honest limit).
Layered reading: at channel level, isolating C2 among thousands of legitimate periodic pollers (mail, monitoring, updates) is the shared difficulty of all statistical detectors (AP ≈ 0.03). The tool's real hunt list is the campaign level — the real C2 ranks 1st among 94 candidates. Two design lessons were documented with measurements: the destination-rarity signal inverts in multi-bot captures (removed from the channel score, moved to the campaign layer), and the Rbot scenarios (S10/S11) are out of evaluation scope because they contain no measurable CC beaconing density.
pip install -e ".[dev]"# 1) Zeek conn.log or CTU-13 .binetflow -> pair table
hhunter ingest capture.binetflow -o pairs.parquet
# 2) Scoring (declare your organisation's public block as internal)
hhunter score pairs.parquet --internal-net 147.32.0.0/16 -o scored.parquet
# 3) Campaign detection (>=2 internal sources to the same destination)
hhunter campaign scored.parquet
# JSON output on both commands for SIEM/automation:
hhunter score pairs.parquet --json | jq '.[0]'
hhunter campaign scored.parquet --jsonThe human-friendly face of the CLI funnel — the panel only reads; all analysis happens in the pipeline (no second computation path):
pip install -e ".[demo]"
streamlit run app.pyThree views: filtered candidate table, channel detail (timeline + inter-arrival distribution + periodogram + subscore breakdown — the answer to "why is this score high?") and the campaign list.
- 50%+ jitter is the detection limit — intervals genuinely turn into
noise at that point (robustness matrix:
docs/img/jitter_robustness.png). - Channel-level AP is low (~0.03): thousands of legitimate periodic pollers (mail, monitoring, updates) carry the same temporal signature as a beacon. This tool is a triage funnel, not a verdict machine.
- The campaign layer requires ≥2 infected sources — it mechanically cannot fire on single-bot captures.
- Rbot/DDoS scenarios (S10/S11) could not be evaluated: they contain no measurable CC beaconing density — reported as a data-suitability analysis.
- Future work: PU-learning for few-positive calibration, Elastic-style bucket
autocorrelation (high jitter); moving the ingestion layer to
Polars/lazy-scan for 50GB+/day scale (the schema is source-agnostic, so the
change stays confined to
ingest.py— pandas was a deliberate choice at this scale: 2.8M flows in 14 s).
- Week 1: Zeek conn.log ingestion + beacon simulator
- Week 2: Time-series layer (dominant-cluster CV, MAD, Bowley, Schuster periodogram)
- Week 3: Composite scoring (time+bytes+context) + permutation significance + funnel filter
- Week 3: Graph analysis / campaign detection (networkx) —
hhunter campaign - Week 4: Multi-host scenario (S9) + evaluation infrastructure + PR curves
- Week 4: Evaluation on 4 scenarios (Neris ×3 + Virut) — cross-family generalisation
- Week 4: CLI
--json(SIEM integration) + limitations documentation - Bonus: Streamlit analysis panel (
streamlit run app.py)
MIT



