Malware Analysis

What Is a Command-and-Control (C2) Server?

HuntRule Team · · 9 min read

A lone lattice radio mast rising out of heavy fog at night, lit from below by a single amber lamp
On this page

A Cobalt Strike Beacon configured with sleep 60 0 sends an HTTP request to its team server every sixty seconds, forever, until an operator tells it otherwise. An implant that cannot reach its operator is a file on disk with nobody driving it, so every implant carries a channel back to a command-and-control server. That requirement is the most reliable thing a defender has.

A C2 server is the operator side of that channel: tasks out, output back. The interesting engineering is the channel, and every channel choice buys something while charging somewhere else.

HTTP and HTTPS

Web protocols are the default because blending in costs almost nothing (T1071.001). Port 443 is open outbound from almost every workstation, the traffic is already encrypted, and a proxy that lets a user out lets an implant out. Cobalt Strike's Malleable C2 profiles exist to spend effort here, controlling the URI, User-Agent, cookie and header names, and how tasking is encoded into request and response bodies.

What it costs: a domain, a certificate, and exposure to domain reputation. It also costs rhythm. The implant has to poll, because the server cannot call it, and polling is measurable even under TLS.

DNS

DNS beaconing (T1071.004) exists for the network where HTTP egress is proxied and inspected but the internal resolver still forwards recursively. The implant never talks to the C2 server. It asks the corporate resolver for a name under a domain the operator controls, and the operator's authoritative server answers.

The cost is throughput, and the ceiling is set by the protocol. A DNS name is capped at 255 bytes with each label capped at 63. Payload is usually base32-encoded into the labels at five bits per character, and the attacker-controlled suffix eats into the budget, so a query carries roughly 100 to 150 bytes upstream. Answers come back in TXT records, where a single string is capped at 255 bytes.

Upstream:   ~100-150 bytes per query (base32 labels, 255-byte name limit)
Downstream: 255 bytes per TXT string
10 MB exfil at ~120 bytes/query = on the order of 100,000 queries

Tasking a shell over that is comfortable. Pulling out a 10 MB archive is the loudest thing the host will do all week. iodine, dnscat2 and Cobalt Strike's DNS Beacon sit at different points on that curve.

Legitimate services as the channel

The third option skips attacker infrastructure entirely (T1102). The implant talks to Microsoft Graph, Dropbox, Google Drive, a Telegram bot API, or a GitHub gist. Tasking is a file the operator writes and the implant reads, output is a file it writes back (T1102.002), and a gist holding nothing but an encoded address is a dead drop resolver (T1102.001).

This defeats domain reputation completely. The destination is graph.microsoft.com, the certificate is Microsoft's, the domain is on every allowlist in the building, and TLS inspection that terminates the session still shows a valid API call. MITRE tracks BirdyClient using the Graph API for C2, and APT32 using Dropbox, Amazon S3 and Google Drive.

What it costs the operator: an account. Accounts get suspended, tokens get revoked, the provider keeps logs the operator cannot reach, and rate limits cap throughput.

Keeping the channel up

Blocking one domain should not end an operation, so operators build fallbacks (T1008). Domain generation algorithms (T1568.002) let implant and server derive the same domain list from a shared seed, usually the date, and the implant walks the list until one resolves. Conficker.A generated 250 candidate domains per day and the C variant raised that to 50,000, which is where pre-registering the list stopped being practical for defenders.

Fast flux (T1568.001) keeps one hostname and rotates what it points at. A record TTLs drop to a few minutes and the address pool cycles through compromised hosts acting as proxies. Double flux rotates the NS records too. CISA and partners published a joint advisory on fast flux in 2025.

Domain fronting (T1090.004) is the one that genuinely declined. It puts one domain in the TLS SNI and a different one in the HTTP Host header, relying on a shared CDN to route on the Host field after unwrapping TLS. Google and Amazon shut it down on their CDNs in 2018. Microsoft blocked it on Azure Front Door for resources created after 8 November 2022 and began enforcing against existing domains on 22 January 2024, returning HTTP 421 Misdirected Request and logging SSLMismatchedSNI. Treat it as context for old reporting, not as a live default.

Redirectors replaced it, and they are boring on purpose. Apache with mod_rewrite, nginx, or plain socat sits on a cheap VPS in front of the team server. Requests matching the profile's URI and User-Agent are proxied through, everything else gets a 302 to a real website. The team server IP never appears in victim telemetry, and burning the redirector costs five dollars.

A brass metronome mid-swing on a dark surface, its arm caught in motion

What the beacon leaves behind

Encryption hides content. It does not hide shape, and shape is where the detections live.

Periodicity and jitter come first. An implant polling on a timer produces a near-constant gap between connections to the same destination. Jitter randomises the sleep, but only within a bound. For a sleep of s seconds with jitter j, the interval is roughly uniform between s(1-j) and s, so the coefficient of variation of the gaps lands near (j/3.46) / (1-j/2). That is about 0.10 at 30 percent jitter and 0.14 at 40 percent. Jitter has to pass roughly 50 percent before the rhythm blurs into normal traffic, and that costs the operator responsiveness.

Request size regularity survives encryption too. A check-in with no tasking waiting is the same message every time, so orig_ip_bytes clusters tightly around one value. The excursions, when a task lands, are as interesting as the baseline.

JA3 and JA4 fingerprint the ClientHello: version, cipher list, extension list, ALPN. JA4 sorts the cipher and extension lists before hashing, which is why it stayed stable when browsers began randomising ClientHello extension order and JA3 did not. Be honest about what it identifies: the TLS stack, not the malware. An implant using WinHTTP looks like every other WinHTTP client on the box, so a JA3 or JA4 value is a grouping key, not a verdict.

For DNS the signals are query name entropy, unique subdomain count per registered domain per host, query volume per host, and TXT ratio. For DGA, watch rcode_name=NXDOMAIN bursts in Zeek's dns.log, since walking a generated list means failing most of it.

Destination rarity is cheapest of all. Count the internal hosts that contacted a given external destination over the last 30 days. C2 lands in the tail, usually at a count of one.

A hunting query for periodic beacons

This runs over Zeek conn.log in Splunk, with Corelight TA field naming. Use id.orig_h and id.resp_h if you index raw Zeek JSON.

index=zeek sourcetype=zeek:conn earliest=-24h
| where NOT (cidrmatch("10.0.0.0/8", id_resp_h)
          OR cidrmatch("172.16.0.0/12", id_resp_h)
          OR cidrmatch("192.168.0.0/16", id_resp_h))
| sort 0 id_orig_h, id_resp_h, _time
| streamstats current=f last(_time) as prev_ts by id_orig_h, id_resp_h
| eval delta = _time - prev_ts
| where delta > 0
| stats count as conns,
        median(delta) as med_gap,
        stdev(delta) as gap_sd,
        median(orig_ip_bytes) as med_out,
        stdev(orig_ip_bytes) as out_sd
        by id_orig_h, id_resp_h
| where conns >= 60 AND med_gap >= 5 AND med_gap <= 3600 AND med_out > 0
| eval gap_cv = round(gap_sd / med_gap, 3), out_cv = round(out_sd / med_out, 3)
| where gap_cv < 0.15 AND out_cv < 0.10
| sort - conns

The thresholds are the whole rule, so they should be arguable. conns >= 60 needs sixty connections in twenty-four hours between one source and one destination. med_gap between 5 and 3600 seconds bounds the search to sleeps a human would operate. gap_cv < 0.15 is the jitter filter, holding up to roughly 40 percent jitter by the arithmetic above. out_cv < 0.10 demands near-constant outbound sizes, which separates a beacon from a chatty application. RITA runs the same class of analysis over Zeek logs.

What this query misses

Long-sleep implants walk straight past it. A sleep 43200 beacon checks in twice a day, so sixty connections take a month to accumulate. Widening to thirty days and dropping conns to 20 catches some of it, at a large cost in compute and false positives. Wide jitter survives too: push past 50 percent and gap_cv clears 0.19.

C2 over a service the business genuinely uses is the hardest case. Traffic to graph.microsoft.com beacons cleanly, but the destination IP is a shared Microsoft front end that hundreds of legitimate flows also hit, so aggregating by id_resp_h buries the beacon in noise. The same happens behind any large CDN. That case needs per-URI or per-account detail from proxy or Graph audit logs, not conn.log.

Known false positives, roughly in the order they will land in the results:

  • EDR and monitoring agent heartbeats, perfect beacons by design

  • Software update checkers and telemetry uploaders

  • NTP, OCSP and CRL fetches

  • Collaboration clients holding keepalives

  • Internal-to-DMZ health checks the RFC1918 exclusion did not catch

Do not suppress these blindly. Allowlist by destination and process, and treat each new entry as a question.

ATT&CK techniques

ID

Name

T1071.001

Application Layer Protocol: Web Protocols

T1071.004

Application Layer Protocol: DNS

T1102.001

Web Service: Dead Drop Resolver

T1102.002

Web Service: Bidirectional Communication

T1568.001

Dynamic Resolution: Fast Flux DNS

T1568.002

Dynamic Resolution: Domain Generation Algorithms

T1090.004

Proxy: Domain Fronting

T1008

Fallback Channels

T1573

Encrypted Channel

Summary

A C2 server is only reachable because the implant keeps reaching for it, and the reaching is what you hunt. HTTP pays in rhythm, DNS pays in throughput, legitimate services pay in account fragility. Every resilience mechanism above exists to make the destination disposable, which tells you what a destination is worth as a detection. Blocking a C2 domain takes minutes and expires the moment the operator registers the next one, which is the general problem with treating an atomic indicator of compromise as a control. Detecting the beacon pattern is expensive and needs tuning, and it keeps working against infrastructure that did not exist when you wrote it.

Collect these first:

Zeek conn.log:  ts, id.orig_h, id.resp_h, id.resp_p, proto, service,
                duration, orig_ip_bytes, resp_ip_bytes, conn_state
Zeek dns.log:   ts, id.orig_h, query, qtype_name, rcode_name, answers
Zeek ssl.log:   ts, id.orig_h, server_name, ja3, ja3s, validation_status
Proxy logs:     client IP, full URL, user agent, bytes sent, bytes received
Retention:      30 days minimum for conn.log, or long-sleep hunting is impossible

Network-side detections in the catalog live under /rules?domain=network, and the beaconing rules specifically under /rules?q=beacon.

Related articles