Lesson 7.3 · Malware Behaviours· 50 min
Command and Control
Find C2 in network logs through beacon timing, DNS anomalies and TLS pivots, recover it from samples, and contain it without losing the evidence.
Objectives
- Explain what a C2 channel carries, why it is the most observable phase of an intrusion, and how it maps to ATT&CK TA0011
- Describe the observable artefacts of HTTP(S) polling, DNS channels, legitimate-service C2, peer-to-peer and DGAs
- Recover C2 parameters from a sample and record them in a form detection engineers and responders can use
- Hunt for C2 in Zeek logs with beacon scoring, long-connection, rare-destination, DNS and TLS pivots, and reason about benign look-alikes
- Choose between sinkholing, blocking and host isolation with the trade-offs for evidence and scoping in mind
Persistence Mechanisms covered what an intrusion leaves on disk. Command and control (C2) is what it does on the wire. An implant that has installed itself and gone quiet is still waiting for instructions, and to receive them it has to reach out through your network, again and again, for as long as the intrusion lasts.
You have already seen the pieces from the analyst's bench. Simulating and Capturing Network Traffic kept a sample's traffic inside the lab and read beacon intervals and TLS fingerprints from a capture; Extracting Malware Configurations pulled the C2 list out of the binary; Writing Network Signatures turned the request format into Suricata rules. This lesson joins those skills to the other side of the job: finding C2 in logs you did not generate, blocking it, and using it to work out how far an incident reaches.
What C2 is for
From a defender's seat, a C2 channel moves three kinds of traffic:
| Direction | What it carries | What you typically see |
|---|---|---|
| Check-in (implant to server) | Host ID, user, OS, privileges, "anything for me?" | Small, regular requests; often the same size every time |
| Tasking (server to implant) | Commands: run a shell command, list files, sleep longer, uninstall | Usually empty or tiny responses; an occasional larger one |
| Results and staging (both ways) | Command output, stolen data going out; tools and next stages coming in | Bursts that break the pattern: large uploads, large downloads |
ATT&CK groups the techniques under tactic TA0011, with sub-techniques for the protocol (T1071, Application Layer Protocol), how the server is found (T1568, Dynamic Resolution), use of third-party services (T1102, Web Service), encryption (T1573), proxies (T1090) and fallback channels (T1008). Exfiltration and tool transfer ride on the same channel but belong to other tactics, which is why a single implant often maps to half a dozen IDs.
C2 is the most detectable phase for a simple reason: it repeats. Initial access happens once. Persistence is written once. Credential theft may happen once per host. But an implant polls its server every few seconds, minutes or hours for weeks, and every poll crosses a boundary you can instrument: a DNS resolver, a proxy, a firewall, a Zeek sensor. Each repetition is another chance to catch it, and the repetition itself is a signature. It is also the phase with the most leverage: cut the channel and the operator loses control of every infected host at once, even ones you have not found yet.
Channel families and what they look like
Authors choose a channel for reliability and for blending in. The families below are how you meet them in logs, not how you would build them.
HTTP(S) polling
The commonest design: the implant sleeps, wakes, sends a request, reads the response, and sleeps again. The server cannot push, so latency equals the sleep interval, and most frameworks add random jitter to that sleep. Frameworks with malleable profiles let the operator dress the request as ordinary web traffic.
Observable artefacts: regular connections from one host to one destination; consistent request and response sizes during idle periods; a user-agent that does not match the host's browser; URIs that repeat or follow one pattern; TLS to a young domain or a bare IP; certificates that are self-signed, freshly issued or reused across unrelated domains. With domain fronting or a CDN in front, the SNI and the destination IP belong to a reputable provider and only the timing and host-level context give it away.
DNS channels
DNS is allowed out of nearly every network, directly or through a forwarder.
DNS tunnelling encodes data in the queried
name (<encoded-chunk>.t.example.net) and receives answers in TXT, CNAME,
NULL or A records. The operator controls the authoritative server for the
parent domain, so every query reaches them through your own resolvers.
Observable artefacts: many unique subdomains under one parent domain; long labels (up to the 63-character limit) made of base32, base64 or hex alphabets; high query volume from one client to that domain; unusual record types such as TXT or NULL from workstations; responses with short TTLs. DNS over HTTPS moves the same pattern into HTTPS to a public resolver, where it looks like TLS to that resolver from a process that should not be using it.
Legitimate cloud and collaboration services
Posting commands to a chat bot API, a code-hosting repository, a paste site or a cloud-storage folder gives the implant a destination that no blocklist will flag (web service C2). A variant, the dead drop resolver, stores only the address of the real C2 in a public profile or post.
Observable artefacts: API endpoints (not the web front-end) contacted by hosts or processes that have no business using them; a server or a service account talking to a messaging API; regular polling of one storage path; the right domain with the wrong process, which you only see if proxy or EDR telemetry records the process name. Network-only analytics struggle here; this is where the join between endpoint and network data pays off.
Peer-to-peer
In a peer-to-peer design there is no single server: infected hosts relay commands to each other, from peer lists the implant carries and updates. Internally, some frameworks chain implants over SMB named pipes or TCP so that only one host talks to the outside.
Observable artefacts: workstations accepting inbound connections from other workstations, often on high or unusual ports; outbound UDP or TCP to many residential IP addresses with no DNS lookup before it; the same fixed port across many external peers. Takedown is hard because there is no single domain to seize; detection is usually easier, because workstation-to- workstation traffic is rare in a well-segmented network.
Domain generation algorithms
A DGA derives a list of candidate domains from a seed and, typically, the current date. The implant tries them in turn; the operator only has to register one. Blocking one domain buys nothing, because tomorrow's list is different. Fast flux is the complementary trick on the IP side, rotating many addresses behind one name.
Observable artefacts: bursts of NXDOMAIN responses from one host, dozens to thousands of unregistered names in a short window; names with no dictionary words, high character entropy and a consistent length and top-level domain; eventually one name that resolves, followed by the connections that matter. Recover the algorithm from the sample and you can compute every future domain, pre-register or sinkhole them, and match past DNS logs against past dates.
Recovering C2 details from a sample
Hunting starts faster when you know exactly what you are hunting for. Work the sample through the sources you already know:
- Configuration extraction. The config usually holds the C2 list, ports, URIs, sleep and jitter, campaign ID and any encryption key. An extractor built as in config extraction gives you all of it at once, and for every sibling sample.
- Strings. Strings analysis and
scripted string decryption recover
user-agents, URI templates, header names and format strings such as
%s/%08x/submit. - Network code. Imports such as
WinHttpOpen,InternetConnectA,DnsQuery_Aorgetaddrinfo, and the functions around them, show which fields are hard-coded, which come from the config and which are computed per host. Look for a loop around the send-sleep-receive sequence: it reveals the sleep call, the jitter arithmetic and any fallback order. - Dynamic capture. A controlled run shows the traffic as the server sees it. Let it run long enough to observe several check-ins, and remember that sandboxes that fast-forward sleeps distort the timing.
Record the result as a table, one row per channel, because each field feeds a different consumer:
| Field | Example | Who uses it |
|---|---|---|
| Hosts and ports, in fallback order | cdn-static.example[.]org:443, then 203.0.113[.]50:8443 | Blocking, DNS and proxy searches |
| Resolution method | Static list; DGA seeded by date; dead drop on a paste site | Sinkholing plan, retro-hunting |
| URIs and methods | GET /assets/<16 hex>.js, POST /assets/upload | Proxy searches, Suricata |
| Interval and jitter | 60 s, jitter 10 % | Beacon analytics thresholds |
| User-agent and headers | Old browser string with a typo | Proxy searches, Suricata |
| TLS details | SNI, JA4 of the client, certificate subject, issuer, serial, validity, SHA-256 | TLS log pivots, CT searches |
| Encoding and crypto | XOR key, RC4 key, base64 alphabet | Decoding captured traffic |
| Observed vs. code-only | "POST path seen in code, never observed" | Confidence in the report |
Tip: Record the interval and jitter as the sample defines them, not as your sandbox observed them. The configured values are what your analytics should look for in production; the observed ones are only correct if nothing accelerated or delayed the run.
Hunting for C2 in network logs
The sample gives you indicators. Hunting finds the infections your
indicators miss: other builds, other families, other channels. Zeek is the
reference data source for this work: conn.log records every connection
with its timestamps, byte counts and duration; dns.log every query and
answer; ssl.log the SNI and handshake details; x509.log the certificates;
http.log the requests it can see. Proxy and firewall logs carry similar
fields under other names.
Beacon periodicity and jitter
A beacon is a host-to-destination pair whose connections arrive at regular intervals. Take the inter-arrival times (the gaps between successive connections) and ask how tightly they cluster:
- The coefficient of variation (standard deviation over mean) is near 0 for a fixed timer and close to 1 or above for human activity. Uniform jitter of plus or minus J percent gives a CV of about J divided by the square root of 3, so 10 % jitter lands near 0.058.
- The median absolute deviation (MAD) relative to the median gap is robust: a handful of missed beacons while a laptop slept will not wreck it.
- Connection count matters because two regular gaps prove nothing. Score over a long window, 24 hours or more in production, so slow beacons have time to show.
- Size consistency: an idle implant sends the same request and receives the same "no tasks" answer, so byte counts barely move.
No single measure is enough. The lab shows why: bursty browsing produces a median gap of a few seconds within bursts, which makes the MAD look regular while the CV exposes it. RITA, the open-source tool from Active Countermeasures, scores beacons on the same principles over Zeek logs at scale, and is the natural next step after the script in this lesson.
Long connections, rare destinations and DNS anomalies
Not every channel polls. Some hold one connection open for hours and push tasking through it. Sum connection duration per pair and look at the top: you will find meeting clients, VPNs and streaming, and occasionally something that is none of those.
Rarity is the most useful filter you can add to any of these analytics. A destination contacted by every host in the company is a product; one contacted by a single host, recently first seen, and with a young domain is worth a look. For DNS, compute per-client counts of NXDOMAIN responses, of unique subdomains per parent domain, and of TXT or NULL queries, and score labels by length and entropy. Expect false positives from CDNs, which put hash-like labels in hostnames, and from security products, which encode lookups in DNS.
TLS pivots: JA3, JA4 and certificates
When you cannot read the payload, the handshake is the fingerprint. A JA3 or JA4 hash of the client identifies the TLS stack and its configuration; the server's certificate identifies how the operator set up the server. Once one host is confirmed, pivot outward: which other internal hosts presented the same client JA4 to a destination nobody else uses? Which other destinations served a certificate with the same subject, issuer, serial or public key? JA3 has become noisy for browsers, which now randomise extension order; JA4 sorts those fields and survives that. Neither identifies a family on its own: both are strong only when rare in your environment and combined with a destination or timing signal.
Infrastructure pivoting, with OPSEC
From one confirmed C2 domain or certificate you can often find the rest of the operator's infrastructure, and hunt for all of it in your logs before the implant fails over to a backup.
- Passive DNS records which names resolved to which IPs over time. Pivot from the domain to its IPs, from those IPs to every other domain seen on them, and filter out shared hosting.
- Certificate transparency logs publish every publicly trusted certificate. Searching them by domain, by organisation string or by a naming pattern finds certificates issued for sibling domains, sometimes before those domains are used.
- Your own logs are the richest passive source of all: every internal host that ever resolved or connected to the infrastructure is in scope.
Warning: Stay passive. Resolving a suspected C2 name from your corporate resolver, browsing to it or scanning it tells an operator who watches their authoritative DNS or web server that someone is looking. So can uploading a targeted sample to a public sandbox, and so can searching a unique indicator on a third-party service that shares queries. Use passive datasets, approved research infrastructure and your team's rules, as set out in the OPSEC section of the network lab lesson.
Containment and its trade-offs
Every containment action also changes what you can still learn. Decide with the incident lead, and in the right order: scope first where you safely can, then cut.
| Action | What it gives you | What it costs |
|---|---|---|
| DNS sinkhole (RPZ or resolver override) pointing C2 names at an internal server | Every infected host announces itself by querying or connecting to the sinkhole; covers hosts you have not found | Hard-coded IPs, DoH and P2P bypass it; the operator sees check-ins stop |
| Block at proxy or firewall | Immediate, fleet-wide cut of known channels | Implant may fail over to a backup channel or DGA you have not mapped; you lose visibility of what it would have done next |
| Isolate the host with EDR | Stops all traffic except to the EDR console; keeps memory and processes alive for collection | One host at a time; the operator notices that host going dark |
| Reimage or power off | Removes the implant from that host | Destroys memory evidence (keys, unpacked payloads, live connections) unless collected first |
Before you act, collect: a memory image of at least one infected host, the implant files and persistence, and the relevant log windows. Map the fallback channels from the config so that blocking the primary does not push the implant onto a channel you are not watching. Where possible, contain all known channels and all known hosts in one coordinated window, the same logic persistence cleanup follows.
Reporting C2
The triage report lists network indicators; for an incident, expand C2 into its own section:
- Each channel from the recovery table above, with ATT&CK IDs, defanged indicators and whether each was observed or seen only in code.
- The detections that fired or were written: rule IDs, analytic names, queries.
- The scope: which hosts beaconed, first and last seen, and how that was established (sinkhole hits, DNS logs, beacon analytics).
- The containment actions taken, when, and what they may have hidden.
- Sharing level for each indicator, as in sharing configurations responsibly.
Lab: score beacons and DNS labels in synthetic Zeek logs
This lab is detection-side only. You generate four hours of synthetic
conn.log and dns.log data for six workstations in Zeek's JSON format,
with one host beaconing to a demo destination, then rank every
host-to-destination pair by beacon likelihood. All addresses are from
documentation ranges and all names are reserved example names. Nothing
connects to anything.
-
Set up a working folder and virtual environment (standard library only):
bash mkdir m7c && cd m7c python3 -m venv .venv .venv/bin/python --versiontext Python 3.14.7 -
Generate the logs. The generator writes irregular browsing bursts for every host, a software update check every 15 minutes on every host, one long meeting session, and an implant on
10.20.0.17that sleeps 60 s with 10 % jitter:python # gen_logs.py - write synthetic Zeek-style conn.log and dns.log (JSON lines) import json, random random.seed(7) START = 1790668800 # 2026-09-29 08:00:00 UTC END = START + 4 * 3600 # four hours of traffic HOSTS = [f"10.20.0.{n}" for n in (11, 12, 13, 14, 15, 17)] BEACON_HOST = "10.20.0.17" # Destinations: name -> IP (documentation ranges only) SITES = {f"site{i:02d}.example.com": f"198.51.100.{i + 10}" for i in range(1, 25)} UPDATES = ("updates.example.net", "192.0.2.80") C2 = ("cdn-static.example.org", "203.0.113.50") MEET = ("meet.example.com", "198.51.100.200") conns, dns, uid = [], [], 0 def conn(ts, src, dst, port, dur, ob, rb, service="ssl", proto="tcp"): global uid uid += 1 conns.append({"ts": round(ts, 6), "uid": f"C{uid:06d}", "id.orig_h": src, "id.orig_p": random.randint(49152, 65535), "id.resp_h": dst, "id.resp_p": port, "proto": proto, "service": service, "duration": round(dur, 6), "orig_bytes": ob, "resp_bytes": rb, "conn_state": "SF"}) def lookup(ts, src, name, ip): dns.append({"ts": round(ts - 0.02, 6), "id.orig_h": src, "query": name, "qtype_name": "A", "rcode_name": "NOERROR", "answers": [ip], "TTLs": [300.0]}) # 1. Human browsing: bursts of connections at irregular times for h in HOSTS: t = START + random.uniform(0, 600) while t < END: name = random.choice(list(SITES)) lookup(t, h, name, SITES[name]) for _ in range(random.randint(1, 6)): conn(t, h, SITES[name], 443, random.uniform(0.2, 30), random.randint(400, 3000), random.randint(2000, 400000)) t += random.uniform(0.1, 5) t += random.expovariate(1 / 240) # mean 4 min between bursts # 2. Benign periodic: software update check every 15 min, all hosts, no jitter for h in HOSTS: t = START + random.uniform(0, 900) while t < END: lookup(t, h, *UPDATES) conn(t, h, UPDATES[1], 443, 0.35, 517, 1420) t += 900 # 3. Benign long connection: one meeting client holding a session open lookup(START + 3600, "10.20.0.12", *MEET) conn(START + 3600, "10.20.0.12", MEET[1], 443, 5400, 38_000_000, 41_000_000) # 4. The implant: 60 s sleep with 10 % jitter, small consistent requests t = START + 1234.5 while t < END: lookup(t, BEACON_HOST, *C2) conn(t, BEACON_HOST, C2[1], 443, random.uniform(0.25, 0.4), random.randint(410, 430), random.randint(250, 262)) t += 60 * random.uniform(0.9, 1.1) conns.sort(key=lambda c: c["ts"]) dns.sort(key=lambda d: d["ts"]) with open("conn.log", "w") as f: f.writelines(json.dumps(c) + "\n" for c in conns) with open("dns.log", "w") as f: f.writelines(json.dumps(d) + "\n" for d in dns) print(f"conn.log: {len(conns)} connections, dns.log: {len(dns)} queries")bash .venv/bin/python gen_logs.py head -1 conn.logtext conn.log: 1422 connections, dns.log: 637 queries {"ts": 1790668949.570667, "uid": "C000391", "id.orig_h": "10.20.0.13", "id.orig_p": 55295, "id.resp_h": "198.51.100.24", "id.resp_p": 443, "proto": "tcp", "service": "ssl", "duration": 19.054157, "orig_bytes": 1892, "resp_bytes": 59160, "conn_state": "SF"}The field names are Zeek's, so the scorer in the next step also reads a real
conn.logwritten with Zeek's JSON logging enabled. -
Write the beacon scorer. It groups connections by source, destination and port, computes four sub-scores between 0 and 1 and averages them. It also counts how many internal hosts talk to each destination, and lists the pairs with the most cumulative connection time:
python # beacon_score.py - rank host->destination pairs in a Zeek conn.log by beacon likelihood import json, statistics as st, sys from collections import defaultdict MIN_CONNS = 10 # too few connections -> no meaningful timing statistics COUNT_SATURATE = 100 # this many connections in the window earns the full count score def load(path): with open(path) as f: return [json.loads(line) for line in f if line.strip()] conns = load(sys.argv[1]) names = {} # resp IP -> last name that resolved to it for d in load(sys.argv[2]) if len(sys.argv) > 2 else []: for ip in d.get("answers") or []: names[ip] = d["query"] pairs, dur_sum, talkers = defaultdict(list), defaultdict(float), defaultdict(set) for c in conns: key = (c["id.orig_h"], c["id.resp_h"], c["id.resp_p"]) pairs[key].append((c["ts"], c["orig_bytes"])) dur_sum[key] += c["duration"] or 0 talkers[c["id.resp_h"]].add(c["id.orig_h"]) def score(events): ts = sorted(t for t, _ in events) gaps = [b - a for a, b in zip(ts, ts[1:])] med = st.median(gaps) mad = st.median(abs(g - med) for g in gaps) cv = st.pstdev(gaps) / st.mean(gaps) sizes = [b for _, b in events] size_cv = st.pstdev(sizes) / st.mean(sizes) if st.mean(sizes) else 1.0 parts = { "cv": max(0.0, 1 - cv), # regular gaps -> low CV "mad": max(0.0, 1 - mad / med) if med else 0.0, # robust to a few outliers "count": min(1.0, len(events) / COUNT_SATURATE), "size": max(0.0, 1 - size_cv), # same request size every time } return sum(parts.values()) / len(parts), med, cv, parts rows = [] for (src, dst, port), ev in pairs.items(): if len(ev) < MIN_CONNS: continue total, med, cv, p = score(ev) rows.append((total, src, f"{dst}:{port}", names.get(dst, "?"), len(ev), med, cv, p, len(talkers[dst]))) rows.sort(reverse=True) all_hosts = len({c["id.orig_h"] for c in conns}) print(f"{'score':>5} {'src':<11} {'dst':<19} {'name':<24} {'n':>4} {'median':>7} " f"{'cv':>5} {'madS':>5} {'cntS':>5} {'sizeS':>5} {'hosts':>5}") for total, src, dst, name, n, med, cv, p, hosts in rows[:8]: print(f"{total:5.3f} {src:<11} {dst:<19} {name:<24} {n:4d} {med:6.1f}s " f"{cv:5.3f} {p['mad']:5.3f} {p['count']:5.3f} {p['size']:5.3f} {hosts:3d}/{all_hosts}") print("\nLongest cumulative connection time per pair:") for key, secs in sorted(dur_sum.items(), key=lambda kv: -kv[1])[:3]: src, dst, port = key print(f" {src:<11} {dst + ':' + str(port):<19} {names.get(dst, '?'):<24} " f"{secs / 3600:5.2f} h over {len(pairs[key])} connection(s)") -
Run it:
bash .venv/bin/python beacon_score.py conn.log dns.logtext score src dst name n median cv madS cntS sizeS hosts 0.971 10.20.0.17 203.0.113.50:443 cdn-static.example.org 220 60.1s 0.055 0.954 1.000 0.985 1/6 0.790 10.20.0.17 192.0.2.80:443 updates.example.net 16 900.0s 0.000 1.000 0.160 1.000 6/6 0.790 10.20.0.15 192.0.2.80:443 updates.example.net 16 900.0s 0.000 1.000 0.160 1.000 6/6 0.790 10.20.0.14 192.0.2.80:443 updates.example.net 16 900.0s 0.000 1.000 0.160 1.000 6/6 0.790 10.20.0.13 192.0.2.80:443 updates.example.net 16 900.0s 0.000 1.000 0.160 1.000 6/6 0.790 10.20.0.12 192.0.2.80:443 updates.example.net 16 900.0s 0.000 1.000 0.160 1.000 6/6 0.790 10.20.0.11 192.0.2.80:443 updates.example.net 16 900.0s 0.000 1.000 0.160 1.000 6/6 0.398 10.20.0.11 198.51.100.14:443 site04.example.com 11 3.5s 2.957 0.889 0.110 0.591 6/6 Longest cumulative connection time per pair: 10.20.0.12 198.51.100.200:443 meet.example.com 1.50 h over 1 connection(s) 10.20.0.13 198.51.100.22:443 site12.example.com 0.13 h over 26 connection(s) 10.20.0.14 198.51.100.19:443 site09.example.com 0.12 h over 27 connection(s) -
Read the ranking as an analyst. The implant ranks first: 220 connections with a median gap of 60.1 s and a CV of 0.055, close to the 0.058 that 10 % uniform jitter predicts, and request sizes that barely move. The update check is more regular (CV 0) and only ranks lower because it has 16 connections in four hours; over a week it would match or overtake the implant. What separates them is context, not timing: the update destination is contacted by all six hosts on an identical schedule (
6/6), resolves from a vendor name you can check against your software inventory, and every host repeats it on the same 15-minute schedule. The implant destination is contacted by one host (1/6). The browsing pair on the last line shows why the MAD cannot be used alone: its median gap is a within-burst 3.5 s, so the MAD score is a flattering 0.889, while the CV of 2.957 gives it away. In the duration list, the 1.5-hour session tomeet.example.comis the benign long connection; confirm it with the process or proxy category before dismissing it. -
Add the rarity filter. Keep only destinations contacted by a single host:
bash .venv/bin/python beacon_score.py conn.log dns.log | awk '$NF == "1/6"'text 0.971 10.20.0.17 203.0.113.50:443 cdn-static.example.org 220 60.1s 0.055 0.954 1.000 0.985 1/6In production you would filter against prevalence over a longer baseline (a destination is rare if few hosts touched it in the last 30 days), not within one four-hour window.
-
Score DNS labels. Put a handful of names in
queries.txt: normal hostnames, a CDN-style hashed label, three DGA-style names, a base32 tunnelling label and a long but human-readable name:text www.example.com mail.example.org login.example.net cdn-static.example.org d3k9x2mfq0z7ab.cdn.example.net xjqvkzrwpbtmnc.example kq7zf0p3xw9lm2vr.example tgbhuiplmwqzxa.example ovzwk4r5mrsw23z3nbxxg5b5o5ztany.t.example.net internationalization-docs.example.compython # label_score.py - score DNS labels by length and character entropy import math, sys from collections import Counter def entropy(s): counts = Counter(s) return max(0.0, -sum(n / len(s) * math.log2(n / len(s)) for n in counts.values())) def leftmost_label(name): return name.split(".")[0].lower() names = [line.strip() for line in open(sys.argv[1]) if line.strip()] print(f"{'query':<46} {'len':>3} {'H':>5} {'digits':>6} verdict") for name in names: lab = leftmost_label(name) h = entropy(lab) digits = sum(ch.isdigit() for ch in lab) / len(lab) suspicious = len(lab) >= 12 and h >= 3.3 print(f"{name:<46} {len(lab):3d} {h:5.2f} {digits:6.2f} " f"{'DGA-like' if suspicious else '-'}")bash .venv/bin/python label_score.py queries.txttext query len H digits verdict www.example.com 3 0.00 0.00 - mail.example.org 4 2.00 0.00 - login.example.net 5 2.32 0.00 - cdn-static.example.org 10 2.92 0.00 - d3k9x2mfq0z7ab.cdn.example.net 14 3.81 0.36 DGA-like xjqvkzrwpbtmnc.example 14 3.81 0.00 DGA-like kq7zf0p3xw9lm2vr.example 16 4.00 0.31 DGA-like tgbhuiplmwqzxa.example 14 3.81 0.00 DGA-like ovzwk4r5mrsw23z3nbxxg5b5o5ztany.t.example.net 31 4.09 0.26 DGA-like internationalization-docs.example.com 25 3.43 0.00 DGA-likeThe three DGA-style names and the base32 label (it decodes to
user=demo;host=ws07) are flagged, as intended. So are two benign names: the CDN's hashed label and the long, readableinternationalization-docs. Entropy of a short string is capped by its length (a 14-character label cannot exceed log2(14), about 3.81 bits), so length and entropy are correlated, and a long label of real words crosses the line. Label scoring is a prefilter. Pair it with the per-host signals that DGAs and tunnels cannot avoid: NXDOMAIN bursts, many unique subdomains under one parent, and a parent domain nobody else in the company resolves.
Questions to answer: If the implant slept 8 hours with 30 % jitter, what
would the scorer need (window length, COUNT_SATURATE, weights) to still
rank it above the update check? Which single field in conn.log would you
add to the size score to catch a "no tasks" response that is always the same
length, and why might a large upload break the pattern without meaning the
host is clean? How would you confirm that updates.example.net is benign
without contacting it? For 10.20.0.17, list the containment actions you
would take, in order, and what you would collect first. Which additional
feature would stop internationalization-docs being flagged without
losing the DGA names?
Key takeaways
- C2 carries check-ins, tasking, results and staging; it repeats for as long as the intrusion lasts, which makes it the phase with the most detection chances and the most containment leverage.
- Each channel family leaves its own traces: regular small requests for HTTP polling, long high-entropy subdomains for DNS tunnels, API calls from the wrong process for cloud-service C2, workstation-to-workstation or residential-peer traffic for P2P, and NXDOMAIN bursts for DGAs.
- Recover C2 from the sample through config, strings, network code and a controlled run, and record hosts, fallback order, URIs, interval, jitter, user-agent, TLS and certificate details, and what was observed versus inferred.
- Beacon analytics combine timing regularity (CV and MAD), connection count and size consistency; rarity across hosts is what separates an implant from an equally regular update check.
- Pivot from a confirmed indicator through JA4, certificates, passive DNS and certificate transparency, without touching the infrastructure yourself.
- Collect evidence and map fallback channels before containing; sinkholing finds hosts, blocking cuts them off, isolation keeps memory, and each hides something from you.