PE-to-Shellcode (Donut)

← pe index · docs/index

TL;DR

Convert a native EXE / DLL, .NET assembly, or scripting payload (VBS / JS / XSL) into position-independent shellcode via the Donut framework — flat byte buffer ready to feed any injection primitive in inject/. Built-in AMSI / WLDP bypass + optional dual-mode (x86 + x64) output. Pure Go, cross-compiles from Linux.

Primer

Windows insists executables live on disk as .exe / .dll files; you can't normally hand the loader a flat byte buffer and say "run this PE". Donut wraps an arbitrary PE (or .NET assembly, or script) with a small position-independent loader stub that bootstraps PE headers in memory, applies relocations, resolves imports, and calls the entry point — all from a flat byte buffer the operator can pass to any injection primitive.

The technique works for native PEs, .NET assemblies (no managed runtime needed on disk — Donut hosts the CLR in process), and scripts (VBScript / JScript / XSL through a built-in mshta-equivalent runner). Output is one buffer regardless of input format, sized roughly +5–10 % over the original.

How It Works

sequenceDiagram
    participant Caller
    participant Donut as "srdi (go-donut)"
    participant Stub as "Loader stub<br>(in-memory)"
    participant Payload as "PE / .NET / Script"

    Caller->>Donut: ConvertFile(path, cfg)
    Donut->>Donut: Parse + classify input
    Donut->>Donut: Compress payload bytes
    Donut->>Donut: Embed AMSI / WLDP bypass
    Donut->>Donut: Attach PIC loader stub
    Donut-->>Caller: shellcode []byte

    Note over Caller,Stub: After injection into target process
    Stub->>Stub: PIC bootstrap (locate self)
    Stub->>Stub: AMSI / WLDP patch (configurable)
    Stub->>Stub: Decompress payload
    Stub->>Payload: Map sections + relocate + resolve imports
    Payload->>Payload: Call entry point

Generated shellcode layout:

[ PIC Donut loader stub ]   ← position-independent x64 / x86 / x84
[ embedded config block ]   ← Arch, Bypass, Method, Class, Params
[ compressed payload ]      ← original PE / .NET / script bytes

Input format matrix

FormatType constantClass requiredMethod required
Native EXEModuleEXE
Native DLLModuleDLLexport name
.NET EXEModuleNetEXE
.NET DLLModuleNetDLLyesyes
VBScriptModuleVBS
JScriptModuleJS
XSLModuleXSL

API Reference

type Arch int / type ModuleType int

godoc

ArchMeaning
ArchX3232-bit only
ArchX6464-bit only (default)
ArchX84dual-mode (32 + 64)

ModuleType values are listed in the matrix above.

type Config

godoc

FieldDescription
ArchTarget architecture (default ArchX64)
TypeInput format (0 = auto-detect from filename in ConvertFile)
Class.NET class name (required for ModuleNetDLL)
Method.NET method or native DLL export to call
ParametersCommand-line passed to the payload
BypassAMSI/WLDP: 1 skip · 2 abort on fail · 3 continue on fail
ThreadRun entry point in a new thread

DefaultConfig() *Config

ArchX64 + ModuleEXE + Bypass = 3.

ConvertFile(path string, cfg *Config) ([]byte, error)

Auto-detect module type from extension when cfg.Type == 0.

ConvertBytes(data []byte, cfg *Config) ([]byte, error)

Convert in-memory PE / script bytes. cfg.Type must be set explicitly.

ConvertDLL(path string, cfg *Config) ([]byte, error) / ConvertDLLBytes(data []byte, cfg *Config) ([]byte, error)

Shorthand wrappers that pin cfg.Type = ModuleDLL.

Examples

Simple — convert a native EXE

import "github.com/oioio-space/maldev/pe/srdi"

cfg := srdi.DefaultConfig()
shellcode, _ := srdi.ConvertFile("payload.exe", cfg)

Composed — DLL with named export

cfg := srdi.DefaultConfig()
cfg.Type = srdi.ModuleDLL
cfg.Method = "ReflectiveLoader"
shellcode, _ := srdi.ConvertDLL("payload.dll", cfg)

Advanced — .NET DLL + dual-mode + remote injection

End-to-end: convert a .NET DLL to dual-mode shellcode, then hand it to inject.NewWindowsInjector with indirect syscalls.

import (
    "github.com/oioio-space/maldev/inject"
    "github.com/oioio-space/maldev/pe/srdi"
    wsyscall "github.com/oioio-space/maldev/win/syscall"
)

cfg := &srdi.Config{
    Arch:   srdi.ArchX84, // dual x86 + x64
    Type:   srdi.ModuleNetDLL,
    Class:  "Loader.Stub",
    Method: "Run",
    Bypass: 3,
}
sc, _ := srdi.ConvertFile("loader.dll", cfg)

icfg := inject.DefaultWindowsConfig(inject.MethodCreateRemoteThread, targetPID)
icfg.SyscallMethod = wsyscall.MethodIndirect

inj, _ := inject.NewWindowsInjector(icfg)
_ = inj.Inject(sc)

See ExampleConvertFile

OPSEC & Detection

ArtefactWhere defenders look
Donut loader stub byte signatureYARA / memory scanners — Defender, MDE, CrowdStrike all carry Donut signatures by default
RWX page allocation in targetBehavioural EDR — Donut's mini-loader writes then executes; RWX is the canonical "shellcode" tell
AMSI / WLDP patch ranges in lsass / current processMicrosoft-Windows-Threat-Intelligence ETW provider
.NET assembly load events without a corresponding .exe on diskETW Microsoft-Windows-DotNETRuntime; Defender flags managed runtime hosting from non-managed processes
Sustained LoadLibraryW / GetProcAddress from a freshly-allocated regionEDR API correlation

D3FEND counters:

  • D3-PA — RWX + execute-from-allocation telemetry.
  • D3-FCA — YARA on the loader stub byte pattern.

Hardening for the operator:

  • Encrypt the shellcode with crypto before the injector writes it to RWX — the stub stays detectable but only after the implant has staged.
  • Use inject's sleep-mask + indirect syscall combination so the stub bytes are absent from memory between callbacks.
  • Avoid ArchX84 unless dual-mode is genuinely required — the larger blob carries both x86 + x64 signatures.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: Dynamic-link Library Injectionpartial — produces shellcode for downstream injection (consumer side)D3-PA
T1620Reflective Code Loadingfull — Donut loader stub is a textbook reflective loaderD3-FCA, D3-PA

Limitations

  • Detectable stub. Donut's loader carries a known byte pattern; signature-based YARA + memory scans flag it.
  • RWX allocation. The mini PE loader writes and then executes — RWX is the canonical shellcode tell.
  • No built-in obfuscation. Stub bytes are not encrypted by default; pair with crypto + sleep masking.
  • Windows payloads only. Shellcode generation runs cross-platform; the produced shellcode targets Windows.
  • +5–10% size overhead. Donut compresses the input but adds the loader stub; expect modest growth.

Credits

See also