SindriKit 1.3.0: Defeating EDR Telemetry with Dynamic Stack Spoofing

In version 1.2.0, SindriKit decoupled syscall invocation to support indirect syscalls. By jumping to a legitimate syscall; ret instruction inside ntdll.dll, payloads evaded user-mode API hooking. However, it’s far from enough.

Modern EDRs leverage ETW telemetry and thread suspension to inspect the call stack during kernel transitions. Even if the instruction pointer (RIP) originates from a legitimate NTDLL address, the return address on the stack points into unverified payload memory.

SindriKit 1.3.0 closes this gap by introducing native Call Stack Spoofing into the execution pipeline.

Source, architecture docs, and PoCs: SindriKit on GitHub.

Stack Spoofing Issues

Conceptually, spoofing a return address is simple: overwrite the top of the stack with the address of a legitimate function (e.g., kernel32.dll!SleepEx) before invoking the syscall. But EDRs don’t just look at the raw bytes on the stack. They use Microsoft’s RtlVirtualUnwind API to reconstruct the call chain.

When attempting to spoof a call stack, devs run into three issues:

  1. Top-of-Stack Exposure: The syscall gadget returns to whatever address is at [RSP]. If [RSP] is a spoofed address, how does the CPU eventually return to your payload’s execution?
  2. Binary Fragility: Hardcoding stack offsets or relying on specific, static gadgets (like a JMP RBX at a known offset) is fragile. When Windows updates, the gadget shifts, and the payload crashes.
  3. Unwind Metadata Inconsistency: This is the worst issue. When RtlVirtualUnwind analyzes a spoofed address, it checks the Exception Directory (.pdata) of the corresponding module. The .pdata directory defines exactly how much shadow stack space that specific function allocates. If your spoofed stack frame doesn’t perfectly match the mathematical offset defined in .pdata, the EDR’s virtual unwinder becomes desynchronized and crashes. A crashed stack unwind is an Indicator of Compromise (IoC).

Dynamic Fat Frames

fat

Fat Frames diagram for extra clarity

SindriKit 1.3.0 introduces snd_syscall_find_spoof_scan.

Instead of picking a random function to spoof, SindriKit manually parses the Exception Directory (.pdata) of the natively loaded kernel32.dll. It iterates through the RUNTIME_FUNCTION array and parses the underlying UNWIND_INFO structures to calculate the exact stack allocation of every function.

The engine hunts for a “Fat Frame”, a legitimate function that allocates at least 120 bytes of stack space. Why 120 bytes? Because we need enough shadow space to hide our syscall arguments and our trampoline gadget without overflowing into other frames.

Entropy & Randomization

If the payload spoofs the exact same kernel32.dll function for every single syscall, the telemetry becomes highly deterministic and suspicious. To combat this, snd_syscall_find_spoof_scan incorporates a randomizer that calculates entropy using the SSN hash, a static counter, and the ASLR-dependent memory addresses of the module and the execution stack. It uses this entropy to randomly skip valid Fat Frames, ensuring the spoofed stack trace constantly shifts during execution.

Once a randomized Fat Frame is chosen, the engine scans the function body for a 0xC3 (RET) instruction. This address becomes our Trampoline Gadget, and the calculated frame size is passed directly to our MASM stub.

The JMP-Trampoline

In this implementation, we must satisfy two entirely different execution models simultaneously: the physical CPU and the EDR’s virtual unwinder:

+------------------------------------------------------+  <-- [RSP+0]
| Trampoline Gadget (RET inside Fat Frame)             |
+------------------------------------------------------+  <-- [RSP+8]
| True Return Address (SyscallCleanup)                 |
+------------------------------------------------------+
| ... Syscall Arguments (Arg 5+) ...                   |
+------------------------------------------------------+  <-- [RSP + 8 + Fat Frame Size]
| Caller's Original Return Address                     |
+------------------------------------------------------+

And here is the exact assembly logic that builds it:

; Build the Top-of-Stack Exposure JMP-Trampoline
; 1. The syscall gadget returns to [RSP]
; 2. The Trampoline Gadget executes RET, popping [RSP+8] into RIP
mov [rsp+0], r8     ; r8 = Trampoline Gadget (pSpoofAddr)
lea rax, SyscallCleanup
mov [rsp+8], rax    ; The CPU will ultimately return here

; 3. The EDR's RtlVirtualUnwind simulates unwinding the Fat Frame
; It adds the spoof_frame_size to the Virtual RSP and reads the return address.
; We place the caller's original return address exactly where the EDR expects it.
mov rax, [rbp+16]   ; Caller's original return address
; Because SindriKit compiled implants run from disk-backed executable memory, 
; leaving the original caller's address ([rbp+16]) provides a perfectly legitimate 
; baseline anchor for the thread trace
mov [rsp + r12 + 8], rax ; r12 = spoof_frame_size

The Divergence

When the kernel suspends the thread and the EDR inspects it:

  1. The CPU returns to [RSP+0] (the Trampoline Gadget). The Trampoline Gadget immediately executes a RET, popping [RSP+8] and returning execution safely to SyscallCleanup inside our payload.
  2. The EDR’s RtlVirtualUnwind analyzes the Trampoline Gadget. It sees that the gadget is located inside our Fat Frame. It reads the .pdata for the Fat Frame, sees the 120+ byte allocation, and mathematically adds that size to its virtual RSP. It completely skips over our SyscallCleanup pointer and reads the original caller’s return address at [RSP + 8 + Fat Frame Size].

The unwinder parses a perfectly intact call chain.

x86 vs x64: The Hardcoded Compromise

On x86 architectures, SindriKit falls back to resolving BaseThreadInitThunk and scanning forward/backward for a simple RET gadget.

This is a necessary compromise due to architectural differences. Unlike x64, the x86 architecture does not use .pdata exception directories. Instead, it relies on Structured Exception Handling (SEH) chained via the FS:0 register, and traditional frame pointer (EBP) chains.

Without .pdata, it is impossible to dynamically calculate the frame size of an arbitrary function without shipping a full x86 disassembler/emulator inside the payload. Because payload size and evasion are top priorities, we rely on known functions for x86 spoofing while leveraging the full power of dynamic frame discovery on x64.

Conclusion

By completely decoupling resolution, invocation, and now spoofing, SindriKit allows operators to trivially integrate advanced evasion techniques into their tools. The C-based engines handle the complex PE parsing and geometric math, while the MASM stubs execute the precise physical stack manipulations.

*