Chapter 71

Cobalt Strike Walkthrough

This is a full end-to-end forensic walkthrough of a Cobalt Strike engagement from the network perspective: from initial beacon contact through lateral movement, privilege escalation, and staged payload delivery. The goal is to see every phase from both Zeek logs and PCAP, and to trace the complete attack chain using only network evidence.

Scenario

It's 09:17 AM. A Suricata alert fires: "ET MALWARE Cobalt Strike Java TLS JA3 Fingerprint" on SID 9005001 from source 10.0.1.75. Your job: determine the full scope of this compromise using network data alone. You have 24 hours of Zeek logs and Arkime PCAP from the last 48 hours.

Investigation Flow

  Cobalt Strike Investigation — Step by Step
  ═══════════════════════════════════════════════════════════════════

  Alert: Cobalt Strike JA3 from 10.0.1.75 at 09:17

  Step 1 — Identify C2 endpoint
  │  Query ssl.log for the JA3 hash + source IP:
  │  grep "51c64c77e60f3980eea90869b68c58a8" ssl.log → dst IP = 185.1.2.3
  │  Check TLS metadata: SNI = "cdn-content.io", self-signed cert
  │  When did it start? First seen in ssl.log at 08:45 (32 minutes ago)

  Step 2 — Characterize the beacon
  │  conn.log: 10.0.1.75 → 185.1.2.3:443
  │  Pattern: 18 connections in 18 minutes → CV = 0.03 → 60-second interval
  │  Bytes: ~200 bytes each way (small heartbeat) → no tasking yet

  Step 3 — Determine initial access (how did 10.0.1.75 get infected?)
  │  Look backwards: what did 10.0.1.75 do before 08:45?
  │  smtp.log: inbound email at 08:31 from external → PDF attachment
  │  http.log: at 08:42, download from "docs.google.com" → 183KB file
  │  Files: files.log shows SHA256 of downloaded file → VirusTotal check

  Step 4 — Track lateral movement
  │  conn.log after 08:45:
  │  10.0.1.75 → 10.0.1.10:445 (SMB) at 09:05
  │  10.0.1.75 → 10.0.1.20:445 (SMB) at 09:07
  │  10.0.1.75 → 10.0.1.30:445 (SMB) at 09:09
  │  → SMB fan-out to 3 hosts (PsExec lateral movement)

  Step 5 — Check for beacon on moved-to hosts
  │  ssl.log from 10.0.1.10: JA3 51c64c77 seen at 09:08 → beacon on new host
  │  ssl.log from 10.0.1.20: JA3 51c64c77 seen at 09:10 → beacon on 2nd host

  Step 6 — Check for privilege escalation
  │  kerberos.log: 10.0.1.75 made 8 TGS-REQ to DC88 between 09:15 and 09:17
  │  Different SPNs each time → Kerberoasting attempt

  Step 7 — Check for exfiltration
  │  conn.log: no large transfers yet (attack is early stage, 09:17)
  │  Monitor for large bytes_toserver to external IPs from any of the 3 hosts

Evidence Compilation

bashcobalt-strike-investigation.sh
#!/bin/bash
LOG_DIR="${1:-/opt/zeek/logs/current}"
SUSPECT="10.0.1.75"
CS_JA3="51c64c77e60f3980eea90869b68c58a8"

echo "=== 1. Find C2 endpoint (JA3 fingerprint match) ==="
awk 'NR>8 && !/^#/' "$LOG_DIR/ssl.log" | \
  awk -v ja3="$CS_JA3" -F'\t' "$(awk 'BEGIN{
    print \"NR>8 && !/^#/ && \$0 ~ ja3 {print \$3, \$5, \$6, \$8, \$9}\"
  }')" 2>/dev/null || \
  grep "$CS_JA3" "$LOG_DIR/ssl.log" | \
  awk -F'\t' '{print $1"\t"$3"\t"$5"\t"$8}' | head -5

echo ""
echo "=== 2. Beacon timing analysis ==="
awk 'NR>8 && !/^#/' "$LOG_DIR/conn.log" | \
  awk -v src="$SUSPECT" -F'\t' '$3==src {print $1"\t"$5"\t"$6}' | \
  grep "185\." | python3 -c "
import sys, math
from collections import defaultdict
flows = defaultdict(list)
for line in sys.stdin:
    parts = line.strip().split('\t')
    if len(parts) >= 3:
        try:
            flows[(parts[1], parts[2])].append(float(parts[0]))
        except: pass

for (dst, port), ts in flows.items():
    ts.sort()
    ivs = [ts[i+1]-ts[i] for i in range(len(ts)-1) if 5 < ts[i+1]-ts[i] < 3600]
    if len(ivs) < 3: continue
    mean = sum(ivs)/len(ivs)
    cv = math.sqrt(sum((x-mean)**2 for x in ivs)/len(ivs))/mean if mean > 0 else 999
    print(f'BEACON dst={dst}:{port} n={len(ts)+1} interval={mean:.0f}s cv={cv:.3f}')
"

echo ""
echo "=== 3. Lateral movement via SMB ==="
awk 'NR>8 && !/^#/' "$LOG_DIR/conn.log" | \
  awk -v src="$SUSPECT" -F'\t' '$3==src && $6=="445" {print $5}' | \
  sort -u | head -20

echo ""
echo "=== 4. Kerberoasting (TGS requests) ==="
awk 'NR>8 && !/^#/' "$LOG_DIR/kerberos.log" 2>/dev/null | \
  awk -v src="$SUSPECT" -F'\t' '$3==src && $7=="TGS" {print $1"\t"$5"\t"$6}' | head -20

echo ""
echo "=== 5. Check compromised hosts for secondary beacons ==="
PIVOTS="10.0.1.10 10.0.1.20 10.0.1.30"
for host in $PIVOTS; do
    count=$(grep "$CS_JA3" "$LOG_DIR/ssl.log" 2>/dev/null | grep -c "$host")
    if [ "$count" -gt 0 ]; then
        echo "  SECONDARY BEACON on $host: $count TLS connections with CS JA3"
    fi
done

echo ""
echo "=== 6. Scope summary ==="
echo "Initial compromise: $SUSPECT"
echo "C2 JA3: $CS_JA3"
echo "Lateral movement targets: check SMB connections above"
echo "Kerberoasting: check TGS-REQ count above"
echo "Action: Isolate all affected hosts, collect PCAP for forensic evidence"
Mental model: build the attack timeline before doing anything else

When you receive an alert for what appears to be Cobalt Strike, the temptation is to immediately pull the host offline and start remediation. Resist this. The correct sequence is: (1) Assess scope from network data before alerting the attacker that they've been detected. If you pull the initially compromised host offline while 3 beacons are running on other hosts, you've contained one system but the attacker still has access through the others. (2) Map the full attack chain: which hosts are beaconing, which accounts may be compromised (Kerberoasting), what data has been accessed (SMB connections to file servers). (3) Only then coordinate a simultaneous containment of all affected hosts — pull them all offline at the same moment, change all potentially compromised account passwords, and rotate service account credentials. The network data is what lets you scope the response correctly before executing it. A well-scoped response takes 30 extra minutes to plan but avoids leaving backdoors in place because you missed a secondary beacon.

Q & A

Q: Cobalt Strike Malleable C2 profiles can change all the HTTP indicators. What's left to detect on?

Malleable C2 can change: User-Agent, URI paths, HTTP headers, response headers, sleep interval, jitter, staging mechanism. What it cannot change without significantly impacting functionality: (1) JA3 fingerprint: changing the JA3 requires changing the TLS stack, which requires modifying the Cobalt Strike JRMI/Java runtime. Operators rarely do this. The default Java TLS JA3 is a reliable indicator. (2) Process injection behavior: Cobalt Strike beacons inject into other processes; the resulting network connection has the JA3 of the process, which may differ, but if the injected process is unusual (e.g., notepad.exe making HTTPS connections), that's a behavioral indicator. (3) JARM fingerprint: the Cobalt Strike team server has a distinctive JARM. Active JARM scanning of suspected C2 IPs can confirm. (4) SMB named pipe: the default MSSE-[hex]-server named pipe is visible in SMB traffic even with Malleable C2. Custom pipe names are detectable as non-default named pipes in network traffic. (5) Beacon behavior pattern: regardless of the specific interval, a host connecting to an external IP with consistent periodicity and small, uniform packet sizes is a beacon. The specific interval doesn't matter for CV-based detection. (6) Post-exploitation traffic: credential dumping, Kerberoasting, and lateral movement have network signatures independent of the initial C2 transport.