Skip to content

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:

DirectionWhat it carriesWhat 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, uninstallUsually empty or tiny responses; an occasional larger one
Results and staging (both ways)Command output, stolen data going out; tools and next stages coming inBursts 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_A or getaddrinfo, 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:

FieldExampleWho uses it
Hosts and ports, in fallback ordercdn-static.example[.]org:443, then 203.0.113[.]50:8443Blocking, DNS and proxy searches
Resolution methodStatic list; DGA seeded by date; dead drop on a paste siteSinkholing plan, retro-hunting
URIs and methodsGET /assets/<16 hex>.js, POST /assets/uploadProxy searches, Suricata
Interval and jitter60 s, jitter 10 %Beacon analytics thresholds
User-agent and headersOld browser string with a typoProxy searches, Suricata
TLS detailsSNI, JA4 of the client, certificate subject, issuer, serial, validity, SHA-256TLS log pivots, CT searches
Encoding and cryptoXOR key, RC4 key, base64 alphabetDecoding 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.

ActionWhat it gives youWhat it costs
DNS sinkhole (RPZ or resolver override) pointing C2 names at an internal serverEvery infected host announces itself by querying or connecting to the sinkhole; covers hosts you have not foundHard-coded IPs, DoH and P2P bypass it; the operator sees check-ins stop
Block at proxy or firewallImmediate, fleet-wide cut of known channelsImplant 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 EDRStops all traffic except to the EDR console; keeps memory and processes alive for collectionOne host at a time; the operator notices that host going dark
Reimage or power offRemoves the implant from that hostDestroys 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.

  1. Set up a working folder and virtual environment (standard library only):

    bash
    mkdir m7c && cd m7c
    python3 -m venv .venv
    .venv/bin/python --version
    text
    Python 3.14.7
  2. 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.17 that 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.log
    text
    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.log written with Zeek's JSON logging enabled.

  3. 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)")
  4. Run it:

    bash
    .venv/bin/python beacon_score.py conn.log dns.log
    text
    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)
  5. 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 to meet.example.com is the benign long connection; confirm it with the process or proxy category before dismissing it.

  6. 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/6

    In 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.

  7. 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.com
    python
    # 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.txt
    text
    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-like

    The 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, readable internationalization-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.