SindriKit: Offensive Development Deserves Better Architecture

The current state of offensive C development lacks basic architecture.

While exploits and evasion techniques are evolving, the infrastructure underneath always remained “primitive”. The industry standard for deploying novel capabilities heavily relies on importing decade-old GitHub PoCs. When an operator needs to integrate a reflective loader for example or a custom injection primitive, they are usually forced to copy-paste undocumented code, resulting in hours spent debugging someone else’s bugs rather than focusing on the exploit itself.

People assume you have to sacrifice clean code for operational stealth in low-level dev. That’s just lazy engineering. SindriKit treats execution mechanics as a primary engineering boundary, applying Dependency Injection to ensure capabilities remain stable while operational footprints evolve independently.

You can explore the source code, view the architecture, and contribute to the project over on GitHub at SindriKit.

The Execution Coupling Problem

The issue with traditional offensive tooling is the tight coupling between technique logic and execution mechanics.

For example, a typical reflective loader handles PE header parsing and memory mapping, but hardcodes its Win32 primitives relying directly on functions like VirtualAlloc or LoadLibrary. The overall technique and its execution profile are fused at the source code level.

This creates a bottleneck. When an EDR starts monitoring or hooking those specific Win32 APIs, the developer is forced to rewrite the entire capability. This leads to the “two-codebase problem” common in red team infrastructure: keeping a clean, readable version for testing, and a messy, unmaintainable “opsec” build packed with custom macros and obscure functions for deployment. When the target environment switches security vendors, the cycle repeats. At that point, your own technical debt becomes a bigger hurdle than the EDR.

The Architecture

The core idea behind SindriKit is: isolate the intent of an offensive technique from its underlying execution mechanics.

Rather than hardcoding APIs, execution is orchestrated through stateful context objects. The loader module provides the first proof of this architecture in action, using snd_loader_ctx_t to orchestrate a payload:

typedef struct {
  const snd_buffer_t *raw_source;
  snd_pe_parser_t pe;
  snd_pe_target_t target;
  snd_loader_stage_t stage;
} snd_loader_ctx_t;

As the payload executes, ctx.stage tracks where it’s at to keep cleanup predictable. By centering everything on this context object and its state, the framework offers a layered API:

            +-----------------------------------------------------------+
            |                      Chains (High-Level)                  |
            |  snd_prepare_reflective_image(&ctx)                       |
            +-----------------------------------------------------------+
                                          |
                                          v
            +-----------------------------------------------------------+
            |                      Engine (Low-Level)                   |
            |  snd_apply_relocations / snd_resolve_imports              |
            +-----------------------------------------------------------+
                                          |
                                          v
            +-----------------------------------------------------------+
            |                     Universal Modules                     |
            |  snd_pe_parse (parsers/pe)                                |
            +-----------------------------------------------------------+

Operators get abstracted chains at the top. Developers who need exact control drop down to the engine level.

Dependency Injection

In a traditional loader, execution mechanics are fused directly to the logic. Your code looks like this:

// Traditional: Hardcoded execution mechanics
pMemory = VirtualAlloc(NULL, size, MEM_COMMIT, PAGE_READWRITE);
pModule = LoadLibraryA("ntdll.dll");
pFunc = GetProcAddress(pModule, "NtProtectVirtualMemory");

When that gets flagged by an EDR, you have to rewrite the capability. SindriKit removes the hardcoded APIs. Before starting the loading chain, you populate your context with an execution profile:

// SindriKit: Injected execution mechanics
ctx.mem_api = &snd_mem_win;
ctx.mod_api = &snd_mod_win;

When you need to evade an EDR and shift to direct syscalls, instead of rewriting the loader or changing core algorithm, you just update two lines:

ctx.mem_api = &snd_mem_native;
ctx.mod_api = &snd_mod_native;

A capability can move from Win32 APIs to native syscalls without touching the underlying logic.

This separation of concerns is achieved through Dependency Injection (DI), a standard pattern in software engineering that is rarely formalized in low-level C tooling.

The consequence is that capabilities become portable across execution profiles. Any capability built on SindriKit can change its entire operational character.

Native execution profiles require syscall resolution, but no single extraction technique works everywhere. SindriKit treats syscall resolution as another injectable execution mechanic:

snd_set_syscall_strategy(...)

If one strategy fails, the framework falls through to the next. The capability remains unchanged because syscall resolution is not part of the capability itself; it is simply another execution detail that can be replaced.

The same architectural principle applies throughout the framework: capabilities remain stable while execution mechanics evolve independently.

Debugging Without Going Blind

A valid concern with low-level abstraction is: when something breaks deep inside a 3000-line codebase and you can’t use printf or GetLastError, you’re flying blind.

SindriKit answers this with the snd_status_t system. Every function returns a status object that propagates subsystem, failure location, and failure reason up the call chain:

status = snd_execute_reflective_image(&ctx);
if (status.code != SND_SUCCESS) {
    snd_status_print(status);
    return status.code;
}

During development, this tells you exactly what broke and where. When you deploy, flip SND_ENABLE_DEBUG=OFF at build time and the entire status system collapses to silent integer codes with absolutely zero debug strings. The debugging experience doesn’t cost you anything in production.

Composability in Practice

The value of the framework becomes obvious during integration. When your exploit gains execution, you need to load a post-exploitation module cleanly, without cluttering your vulnerability logic with loading mechanics

Two lines in your CMakeLists.txt:

add_subdirectory(libs/SindriKit)
target_link_libraries(exploit_common PUBLIC Advapi32 sindri::engine)

Your exploit just inherited a full loading engine, a PE parser, and dynamic syscall resolution strategies. The moment SYSTEM is confirmed, the transition looks like this:

// Resolve NTDLL via PEB walk
PVOID ntdll;
snd_peb_get_module_base_by_hash(SND_HASH_NTDLL_DLL, &ntdll);
snd_set_ntdll(ntdll);

// Native execution profile
ctx.mem_api = &snd_mem_native;
ctx.mod_api = &snd_mod_native;

// Load and execute
snd_buffer_load_from_disk("admin.exe", &file_buf);
ctx.raw_source = &file_buf;
snd_prepare_reflective_image(&ctx);
snd_execute_reflective_image(&ctx);

And if your threat model demands something the built-in profiles don’t cover, a topology-aware allocator, a custom memory primitive the EDR isn’t watching, you write one function, copy the base profile, and swap the mechanic. SindriKit provides the architecture so you can easily plug in whatever custom mechanics you need.

The Point

The “two-codebase problem” is a structural failure born from tight coupling, not an inherent necessity of low-level C programming.

SindriKit breaks the cycle where every change in an EDR’s detection strategy forces you to rewrite working capabilities.

Detection logic will constantly evolve, and so will the execution mechanics. It’s inevitable. But having to rewrite working capabilities every time shouldn’t be the norm.

Architecture should be the one absorbing those changes, not you.

*