Difficulty: Intermediate

Module 4: Donut Module Architecture

The internal data structures that define how Donut packages, encrypts, and configures payloads — DONUT_INSTANCE and DONUT_MODULE.

Module Objective

Understand the two core data structures at the heart of Donut: the DONUT_MODULE (which wraps the payload with metadata) and the DONUT_INSTANCE (which configures the loader with API hashes, keys, and runtime parameters). Learn how compression with aPLib and LZNT1/Xpress reduces shellcode size.

1. Two-Layer Architecture

Donut uses a two-layer data structure design. The outer layer (DONUT_INSTANCE) configures the loader itself — which APIs to resolve, what keys to use, where the module is. The inner layer (DONUT_MODULE) wraps the actual payload with execution metadata.

Data Structure Nesting

PIC Loader Code
DONUT_INSTANCE
Encrypted (Key 1)
API hashes, config, Key 2
DONUT_MODULE
Encrypted (Key 2)
Compressed payload + metadata

The loader first decrypts DONUT_INSTANCE using a key derived from its position in the shellcode. This gives it the API hashes needed to resolve functions, plus the key to decrypt DONUT_MODULE. It then decrypts the module, decompresses the payload, and dispatches to the correct handler.

2. The DONUT_MODULE Structure

The DONUT_MODULE contains everything the loader needs to execute the payload. It is defined in donut.h:

Ctypedef struct _DONUT_MODULE {
    DWORD   type;           // Module type: DLL, EXE, .NET, VBS, JS, XSL
    DWORD   thread;         // Run payload in a new thread?
    DWORD   compress;       // Compression engine used (aPLib, LZNT1, Xpress, None)

    // .NET-specific fields
    CHAR    runtime[DONUT_MAX_NAME]; // CLR runtime version (e.g., "v4.0.30319")
    CHAR    domain[DONUT_MAX_NAME];  // AppDomain name (random or user-supplied)
    CHAR    cls[DONUT_MAX_NAME];     // Class name for .NET DLL (e.g., "Rubeus.Program")
    CHAR    method[DONUT_MAX_NAME];  // Method name (e.g., "Main")

    // Native DLL-specific fields
    CHAR    function[DONUT_MAX_NAME]; // Export function to call (for native DLLs)
    CHAR    param[DONUT_MAX_NAME];    // Parameters / command-line arguments

    // Unicode versions of the above (for COM/CLR interfaces)
    WCHAR   wRuntime[DONUT_MAX_NAME];
    WCHAR   wDomain[DONUT_MAX_NAME];
    WCHAR   wClass[DONUT_MAX_NAME];
    WCHAR   wMethod[DONUT_MAX_NAME];

    // Payload data
    DWORD   mac;            // MAC for integrity verification after decryption
    DWORD   zlen;           // Compressed size of the payload
    DWORD   len;            // Original uncompressed size
    BYTE    data[];         // The payload bytes (flexible array member)
} DONUT_MODULE;
FieldPurposeUsed By
typeIdentifies the payload type, controls dispatchAll loaders
runtimeCLR version string for .NET hostinginmem_dotnet.c
cls / methodClass and method name for .NET DLL invocationinmem_dotnet.c
functionExport name for native DLL callinmem_pe.c
paramCommand-line arguments for the payloadAll loaders
compressCompression algorithm indicatorDecompressor dispatch
zlen / lenCompressed and original sizes for decompressionDecompressor
data[]The actual payload bytes (compressed + encrypted)All loaders

3. Module Type Constants

The type field determines which handler function the loader calls. Donut defines these module types:

C#define DONUT_MODULE_NET_DLL  1  // .NET DLL (requires class + method)
#define DONUT_MODULE_NET_EXE  2  // .NET EXE (has Main entry point)
#define DONUT_MODULE_DLL      3  // Native unmanaged DLL
#define DONUT_MODULE_EXE      4  // Native unmanaged EXE
#define DONUT_MODULE_VBS      5  // VBScript file
#define DONUT_MODULE_JS       6  // JScript file
#define DONUT_MODULE_XSL      7  // XSL file (with embedded script)

The loader uses a simple switch/dispatch based on this value to call the appropriate in-memory execution function: RunPE(), RunDotNET(), or RunScript().

4. The DONUT_INSTANCE Structure

The DONUT_INSTANCE is the loader’s runtime configuration. It contains everything the PIC loader needs to bootstrap itself:

Ctypedef struct _DONUT_INSTANCE {
    DWORD   len;            // Total size of this instance
    CHASKEY master;         // Chaskey key for decrypting the module

    // Chaskey cipher state for module decryption
    BYTE    mod_key[CIPHER_KEY_LEN];   // Module encryption key
    BYTE    mod_ctr[CIPHER_BLK_LEN];   // Module CTR nonce

    DWORD   mod_len;        // Size of the encrypted module
    ULONGLONG mod_offset;   // Offset to DONUT_MODULE from start of instance

    // Exit behavior
    DWORD   exit_opt;       // DONUT_OPT_EXIT_THREAD, DONUT_OPT_EXIT_PROCESS, or DONUT_OPT_EXIT_BLOCK

    // Entropy / evasion
    DWORD   entropy;        // Entropy level (random names vs. fixed)
    DWORD   oep;            // Original entry point (for PE payloads)

    // AMSI/WLDP/ETW bypass configuration
    DWORD   bypass;         // Bypass level (none, abort, continue)

    // API hashing: pre-computed hashes for all needed functions
    ULONGLONG hash[DONUT_MAX_API]; // Array of API name hashes

    // Staging configuration
    DWORD   type;           // Staging type (HTTP, DNS, embedded)
    CHAR    server[256];    // Staging server URL/hostname
    CHAR    modname[256];   // Module name on the staging server

    // Instance encryption (decrypted by initial key derivation)
    BYTE    key[CIPHER_KEY_LEN];  // Instance-level Chaskey key
    BYTE    ctr[CIPHER_BLK_LEN];  // Instance-level CTR nonce
    DWORD   mac;                   // MAC for integrity verification

    // Resolved API pointers (filled at runtime by the loader)
    // These are populated after PEB walking resolves each hash
    struct {
        // kernel32.dll
        LoadLibraryA_t         LoadLibraryA;
        GetProcAddress_t       GetProcAddress;
        VirtualAlloc_t         VirtualAlloc;
        VirtualFree_t          VirtualFree;
        VirtualProtect_t       VirtualProtect;
        // oleaut32.dll
        SafeArrayCreate_t      SafeArrayCreate;
        SafeArrayAccessData_t  SafeArrayAccessData;
        SysAllocString_t       SysAllocString;
        // ole32.dll
        CoInitializeEx_t       CoInitializeEx;
        CoCreateInstance_t     CoCreateInstance;
        // ... additional API function pointers
    } api;
} DONUT_INSTANCE;

API Hash Table

The hash[] array contains pre-computed hashes of API function names (e.g., hash of "LoadLibraryA", hash of "VirtualAlloc"). The PIC loader walks the PEB, finds loaded DLLs, and compares export names against these hashes. When a match is found, the resolved address is stored in the corresponding api struct field. This avoids having plaintext API strings in the shellcode.

5. Payload Compression

Donut supports three compression engines to reduce shellcode size. Compression is applied to the raw payload bytes before encryption:

EngineConstantCharacteristicsImplementation
NoneDONUT_COMPRESS_NONENo compression; payload stored as-isN/A
aPLibDONUT_COMPRESS_APLIBSmall decompressor (~150 bytes PIC code), good ratiodepack.c (aPLib decompression)
LZNT1DONUT_COMPRESS_LZNT1Uses RtlDecompressBuffer from ntdll.dllWindows native API
Xpress HuffmanDONUT_COMPRESS_XPRESSUses RtlDecompressBufferEx, better ratio than LZNT1Windows native API

Compression Trade-offs

aPLib is self-contained — the decompressor is compiled into the PIC loader, so it works without resolving any APIs. This makes it the safest choice. LZNT1 and Xpress produce better compression ratios but require resolving RtlDecompressBuffer from ntdll.dll at runtime, adding API resolution overhead and a dependency on the ntdll export.

C// aPLib decompression in the PIC loader (depack.c)
// Extremely compact decompressor suitable for shellcode
DWORD aP_depack(const void *source, void *destination) {
    const BYTE *src = (const BYTE*)source;
    BYTE *dst = (BYTE*)destination;
    DWORD  tag = 0, bitcount = 0;
    DWORD  offs, len, R0 = ~0;

    *dst++ = *src++;  // First byte is literal

    for (;;) {
        // Bit-packed stream: literals, matches, and short matches
        // Compact implementation ~150 bytes of x86 code
        // ...
    }
    return (DWORD)(dst - (BYTE*)destination);
}

6. Staging: Embedded vs. Remote

The DONUT_MODULE can be embedded directly in the shellcode (default) or hosted on a remote server and downloaded at runtime:

Embedded (Default)

The encrypted module is appended directly after the DONUT_INSTANCE in the shellcode blob. The loader accesses it via the mod_offset field. This produces a single self-contained shellcode file.

HTTP Staging

The module is hosted on a web server. The DONUT_INSTANCE contains the URL (server field) and module name. At runtime, the loader uses WinINet APIs (InternetOpenA, InternetOpenUrlA, InternetReadFile) to download and decrypt the module. This keeps the initial shellcode small.

DNS Staging

The module is served via DNS TXT records. The loader issues DNS queries to retrieve the module data in chunks, reassembles it, and decrypts. This provides a covert download channel that may bypass network monitoring.

Embedded vs. Staged

Embedded
Loader + Instance + Module
= single blob
vs
HTTP Staged
Loader + Instance only
Module downloaded at runtime
vs
DNS Staged
Loader + Instance only
Module via DNS TXT

7. The DONUT_CONFIG Structure

At generation time (not runtime), Donut uses DONUT_CONFIG to collect user options before building the shellcode:

Ctypedef struct _DONUT_CONFIG {
    DWORD   arch;       // Target architecture: x86, x64, or x86+x64
    DWORD   bypass;     // AMSI/WLDP/ETW bypass level
    DWORD   compress;   // Compression engine
    DWORD   entropy;    // Entropy level for string randomization
    DWORD   format;     // Output format: raw, base64, C array, Python, etc.
    DWORD   exit_opt;   // Exit behavior: thread, process, or block
    DWORD   thread;     // Create new thread for payload?
    DWORD   oep;        // Overwrite PE entry point after execution

    CHAR    input[DONUT_MAX_NAME];    // Input file path
    CHAR    output[DONUT_MAX_NAME];   // Output file path
    CHAR    cls[DONUT_MAX_NAME];      // .NET class name
    CHAR    method[DONUT_MAX_NAME];   // .NET method name
    CHAR    param[DONUT_MAX_NAME];    // Parameters / arguments
    CHAR    server[256];              // Staging server URL
    CHAR    modname[256];             // Module name for staging

    // Populated during generation
    DWORD   inst_len;   // Instance size
    DWORD   mod_len;    // Module size
    LPVOID  inst;       // Pointer to generated instance
    LPVOID  mod;        // Pointer to generated module
    LPVOID  pic;        // Pointer to final PIC shellcode
    DWORD   pic_len;    // Final shellcode size
} DONUT_CONFIG;

8. Data Flow: From Input to Output

Complete Data Flow

Input File
EXE/DLL/.NET/Script
Wrap in MODULE
+ metadata fields
Compress
aPLib / LZNT1
Encrypt MODULE
Chaskey (Key 2)
Build INSTANCE
+ API hashes + Key 2
Encrypt INSTANCE
Chaskey (Key 1)
Prepend Loader
Final shellcode

Knowledge Check

1. What is the relationship between DONUT_INSTANCE and DONUT_MODULE?

DONUT_INSTANCE is the outer configuration layer containing API hashes, encryption keys, and bypass settings. DONUT_MODULE is the inner layer wrapping the actual payload with metadata like class name, method, and arguments. They use separate encryption keys.

2. Why is aPLib compression preferred over LZNT1 for shellcode payloads?

aPLib decompression code is compiled directly into the PIC loader (~150 bytes), so it works without resolving any Windows APIs. LZNT1 and Xpress require calling RtlDecompressBuffer from ntdll.dll, adding an API resolution dependency.

3. What does the hash[] array in DONUT_INSTANCE contain?

The hash array contains pre-computed hashes of API names (e.g., "LoadLibraryA", "VirtualAlloc"). The PIC loader walks loaded DLLs via the PEB and compares export name hashes against these values to resolve function addresses without using plaintext strings.