Call Stack Spoofing: How Spoofers Beat the Unwinder and Where the Truth Still Lives

Read time12 min
PublishedMar 19, 2026

Table of Contents

Every modern C2 framework ships call stack spoofing now. Cobalt Strike has it in the sleep mask kit, Nighthawk builds it into the core, Havoc and Brute Ratel have their own variants. The reason is simple: an EDR that walks the thread’s stack at the moment of a sensitive API call can tell instantly whether that call came from a legitimate module or from unbacked shellcode in private memory. A single unbacked return address on the stack is enough to flag the whole thread. So the red-team answer has been to make the stack lie.

I went into the x64 unwinding machinery to understand what exactly a spoofer has to defeat, how three generations of spoofing tools have done it, and where the detection still holds. The short version: the first two generations fool the surface-level check (unbacked return addresses) but leave structural tells. The third generation, SilentMoonwalk, fools the structural check too, but there is a hardware invariant it cannot touch.

How x64 stack walking actually works

On x86, unwinding was easy. Every function pushed RBP, set RBP = RSP, and you walked the chain of saved RBP values back to the base. On x64, that convention is gone. The compiler omits frame pointers by default (/Oy is the MSVC default), so there are no RBP chains to follow. Instead, the OS relies entirely on metadata baked into the PE at compile time.

Every non-leaf function gets an entry in the PE’s .pdata section: a RUNTIME_FUNCTION structure.

				
					typedef struct _RUNTIME_FUNCTION {
    DWORD BeginAddress;      // RVA of function start
    DWORD EndAddress;        // RVA of function end
    DWORD UnwindInfoAddress; // RVA to UNWIND_INFO in .xdata
} RUNTIME_FUNCTION;
				
			

The array is sorted by BeginAddress. When the OS needs to unwind a frame, it calls RtlLookupFunctionEntry with the current instruction pointer, which binary-searches .pdata to find the matching entry. Leaf functions (those that do not allocate stack space or save any nonvolatile registers) have no entry at all. For a leaf function, RSP points directly at the return address, so the unwinder just reads [RSP] and moves on.

The UnwindInfoAddress points to an UNWIND_INFO structure in the .xdata section. This is the part that matters for spoofing:

				
					typedef struct _UNWIND_INFO {
    UBYTE Version       : 3;   // 1 or 2
    UBYTE Flags         : 5;   // EHANDLER, UHANDLER, CHAININFO
    UBYTE SizeOfProlog;
    UBYTE CountOfCodes;
    UBYTE FrameRegister : 4;   // 0 = no frame pointer
    UBYTE FrameOffset   : 4;   // FP = RSP + 16 * FrameOffset
    UNWIND_CODE UnwindCode[];
} UNWIND_INFO;
				
			

Each UNWIND_CODE records one prologue operation: UWOP_PUSH_NONVOL (a register push, RSP -= 8), UWOP_ALLOC_SMALL or UWOP_ALLOC_LARGE (the sub rsp, N that reserves the local frame), UWOP_SET_FPREG (establishing a frame pointer from RSP), UWOP_SAVE_NONVOL (saving a register to the stack), and several others for XMM saves and machine frames.

RtlVirtualUnwind reads these codes and simulates executing the prologue in reverse: it undoes each allocation and register push to recover the caller’s RSP, then reads the return address from [RSP]. The key insight is that this entire process trusts the metadata. There is no runtime check that the stack contents match what the unwind codes describe. The unwinder assumes the prologue ran exactly as the compiler said it would, and that the stack has not been tampered with between the prologue and now.

This is the attack surface for call stack spoofing: the unwinder is a metadata-driven state machine, and if you can arrange the stack so the metadata-driven walk produces a legitimate-looking sequence of return addresses, the walk will report whatever you want.

What an EDR sees when it walks the stack

When a thread calls a sensitive API (say, NtAllocateVirtualMemory or NtProtectVirtualMemory), an EDR with an inline hook or a syscall trampoline intercepts the call. At that point it calls RtlCaptureStackBackTrace or walks the stack manually using RtlLookupFunctionEntry and RtlVirtualUnwind in a loop. For each return address it recovers, it asks one question: does this address fall inside a loaded image?

The check is straightforward. Call VirtualQuery on the return address. If the region’s Type is MEM_IMAGE, the address lives in a memory-mapped PE (a DLL or EXE). If it is MEM_PRIVATE, the address lives in privately allocated memory, the kind you get from VirtualAlloc. A return address in MEM_PRIVATE memory with execute permissions is the canonical sign of injected code: shellcode, a reflectively loaded beacon, or an in-memory implant that was never a proper PE on disk.

This is the foundational detection. Sysmon exposes a version of it in Event ID 10 (ProcessAccess): the CallTrace field prints the call stack as a pipe-delimited list of module+offset entries, and any frame from unbacked memory shows up as UNKNOWN. An analyst hunting for injected code can search for CallTrace contains UNKNOWN and get a high-fidelity hit list. The Sigma rule at the end of this post implements exactly that.

The false-positive surface is narrow but real. JIT engines (.NET’s CLR, V8, Java’s HotSpot) generate executable code in private memory at runtime, so managed applications will produce UNKNOWN call trace entries during legitimate operation. Exclude your known JIT hosts (the CLR, browser processes, Java) and the signal is clean.

Three generations of stack spoofing

The red-team response to unbacked-RA detection has evolved through three generations, each defeating a deeper level of stack inspection.

Generation 1: return address zeroing. ThreadStackSpoofer (Mariusz Banach/mgeeky, 2021) hooks kernel32!Sleep inside the beacon. When the beacon sleeps, the hook walks the stack backward from the current frame and overwrites the return addresses in subsequent frames with zero. Now a scanner that examines sleeping threads sees a stack that terminates cleanly at ntdll!RtlUserThreadStart with no frames pointing into unbacked memory. When the sleep ends, the hook restores the original return addresses so execution resumes normally.

The limitation is obvious: the stack only looks clean while sleeping. The moment the beacon wakes up and calls a monitored API, the real return addresses are back on the stack, and the unbacked-RA check fires. It also leaves a structural tell: a stack that terminates prematurely (zeroed frames cause the walk to stop early) looks different from a real stack, which walks all the way down to BaseThreadInitThunk and RtlUserThreadStart.

Generation 2: synthetic frame fabrication. Instead of zeroing return addresses, this approach fabricates multiple fake frames that chain into legitimate functions. The spoofer builds frames pointing to kernel32!BaseThreadInitThunk, ntdll!RtlUserThreadStart, and other well-known call chain roots, with a zero terminator at the base. The stack now looks like a legitimate thread that started from BaseThreadInitThunk. Return-address spoofing via gadgets fits here too: instead of pointing to arbitrary addresses in a module, the spoofer uses jmp [rbx] or similar gadgets inside signed DLLs so that each frame’s return address is inside a legitimate image section.

This defeats the unbacked-RA check completely: every return address on the stack points into a loaded module’s .text section. But it introduces a new tell. A return address should point to the instruction immediately after a CALL. A jmp [rbx] gadget is not preceded by a CALL to the “caller” function. If the defender disassembles backward from the return address and checks whether the preceding bytes are a CALL instruction, the gadget is exposed. This is the call-site validation check, and some EDRs now implement it.

Generation 3: unwinder-consistent frame stitching (SilentMoonwalk). SilentMoonwalk (klezVirus, Arash Parsa, Athanasios Tserpelis, 2022) is the state of the art. Instead of placing arbitrary gadget addresses on the stack, it constructs frames that are internally consistent with the .pdata/.xdata metadata of real functions. The technique builds a four-frame ROP chain with specific properties:

1. A frame in a function whose UNWIND_INFO contains UWOP_SET_FPREG, which tells the unwinder to set RSP from the frame pointer register. This lets the spoofer control what the unwinder thinks RSP is after unwinding this frame.
2. A frame in a function with UWOP_PUSH_NONVOL(RBP), which the unwinder pops from the stack. Combined with frame 1, this lets the spoofer plant an arbitrary value as the simulated stack pointer.
3. A jmp [rbx] gadget frame that redirects real execution to the target function while remaining back-linked to frame 2, keeping the chain walkable.
4. A stack pivot frame (add rsp, X; ret) that cleans up the gadget’s own frame.

The result is that RtlVirtualUnwind walks the stack and produces a fully legitimate trace. Each frame has a matching RUNTIME_FUNCTION entry, each frame’s unwind codes are consistent with the stack layout, and the walk terminates naturally at RtlUserThreadStart. This defeats not just the unbacked-RA check but also the call-site validation check, because the return addresses land in real function bodies at points that the .pdata metadata describes correctly.

The timer/fiber alternative. A separate family of techniques sidesteps spoofing entirely. Cobalt Strike’s timer-based sleep (4.7+) queues a CreateTimerQueueTimer callback, encrypts the beacon in memory, and exits the current thread context. When the timer fires, the beacon code runs inside a Windows threadpool callback, which has a naturally clean call stack: ntdll!TppWorkerThread to kernel32!BaseThreadPoolInitThunk to ntdll!RtlUserThreadStart. No spoofing needed, because the call stack is genuinely from the OS scheduler. The fiber-based variant uses ConvertThreadToFiber / SwitchToFiber to achieve the same thing: swap to a clean fiber context during sleep. These are harder to detect at the stack level because the stack is real, but they have their own indicators (unusual timer callbacks, fiber creation in processes that never use fibers, encrypted heap regions).

Where the detection still holds

The progression above makes it sound like the defense is always one step behind, and for user-mode stack inspection it mostly is. But there are detection layers that even generation-3 spoofing cannot reach.

1. The kernel's own stack walk. When an ETW consumer enables EVENT_ENABLE_PROPERTY_STACK_TRACE on a provider, the kernel captures the stack at event-fire time from kernel mode, not from user mode. This walk happens at the moment the syscall transitions to kernel mode, before any user-mode spoofer has a chance to restore the stack. If the beacon calls NtAllocateVirtualMemory and the ETW-TI (Threat Intelligence) provider is enabled with stack capture, the kernel records the real stack at syscall entry. That said, the kernel’s walk uses the same .pdata metadata. A SilentMoonwalk-style spoof that has already arranged the stack before the syscall will fool the kernel walker too, because the stack contents are consistent with the metadata at the moment of the walk. So the kernel stack capture is not a magic bullet against generation-3 spoofing, but it is immune to the post-call restore trick (generation 1), because the walk happens before the sleep function returns control.

2. Call-site validation. For each return address on the stack, read the bytes immediately before it. A legitimate return address points to the instruction right after a CALL. Disassemble backward (which on x64 is ambiguous, but CALL instructions are typically 5 bytes: E8 xx xx xx xx for near relative, or FF 15 xx xx xx xx for indirect) and check whether a CALL sits there. Generation-2 gadget spoofing fails this check: a jmp [rbx] gadget at offset X is not preceded by a CALL to offset X. Generation-3 spoofing can pass this check because it places return addresses at real function boundaries where CALL sites exist, but the call-site check still catches a large population of deployed spoofing implementations.

3. Frame count and stack depth anomalies. A real thread calling NtAllocateVirtualMemory from, say, HeapAlloc has a stack depth of 8-12 frames. A spoofed stack that chains through BaseThreadInitThunk directly to the syscall has 3-4 frames. The shallow depth is an anomaly. Combined with the specific API being called, this is a useful heuristic, though it requires tuning per-environment.

4. Intel CET shadow stack. This is the fundamental barrier. Control-flow Enforcement Technology, available on Intel 11th gen (Tiger Lake, 2020) and later and AMD Zen 3 and later, maintains a write-protected shadow stack in a separate memory region. On every CALL, the processor pushes the return address onto both the regular stack and the shadow stack. On every RET, it pops from both and compares: if they differ, the processor raises a #CP (Control Protection) exception.

The shadow stack pages are marked with a special page type that cannot be written by normal store instructions. Only the WRSS (Write Shadow Stack) instruction can write to them, and WRSS is typically restricted by the kernel. User-mode code cannot forge shadow stack entries.

For detection, the shadow stack is ground truth. A spoofer can arrange the regular stack however it wants, but the shadow stack records the real CALL/RET pairs. If the regular stack shows kernel32!BaseThreadInitThunk called ntdll!NtAllocateVirtualMemory, but the shadow stack shows the call originated from a private RX page, the spoof is exposed. The divergence between regular stack and shadow stack is the detection signal.

The honest caveat: CET is not universally deployed yet. On Windows, user-mode shadow stacks require Windows 11 22H2 or later, a compatible CPU, and the process must opt in via the IMAGE_DLLCHARACTERISTICS_EX_CET_COMPAT PE flag (set by MSVC’s /CETCOMPAT linker option) or programmatically via SetProcessMitigationPolicy with ProcessUserShadowStackPolicy. Most third-party software has not opted in. Kernel-mode shadow stacks (“Hardware-enforced Stack Protection”) are a separate toggle. So today, CET is a detection frontier that early adopters can leverage, not a universal control. But it is the direction of travel, and any spoofing technique that depends on manipulating return addresses on the stack is fundamentally incompatible with CET enforcement.

The emerging red-team response to CET is not to fight the shadow stack but to avoid the problem entirely: use fibers, timer callbacks, or message-passing patterns where the monitored API is called from a legitimate code path with a naturally clean shadow stack. For the defender, this means CET kills the spoofing arms race but shifts the battlefield to detecting abnormal use of fibers, timers, and other legitimate OS primitives, a different detection problem with different signals.

What to actually run

Tier 1: unbacked return addresses (Sigma-compatible). Hunt Sysmon Event ID 10 CallTrace for UNKNOWN entries. This catches generation 0 (no spoofing at all) and generation 1 (sleep-only spoofing, because the beacon’s stack is real when it calls sensitive APIs). It does not catch generation 2 or 3. The Sigma rule below implements this.

Tier 2: call-site validation. For each return address, check whether the preceding bytes are a CALL instruction. This requires an EDR or a custom tool that can disassemble at scan time, not a log-based detection. It catches generation 2 (gadget-based spoofing). SilentMoonwalk can survive this check because it picks return addresses at real call sites.

Tier 3: unwind-info consistency + call-chain plausibility. Walk the stack with RtlVirtualUnwind and verify that each frame’s unwind codes are consistent with the actual stack contents. A SilentMoonwalk spoof arranges the stack to be consistent with the metadata, so this alone does not catch it. But combine it with a check that the function at each frame’s RIP actually calls the function in the next frame (not just any call site), and the fabricated chains start to look implausible. A real NtAllocateVirtualMemory call comes from RtlpAllocateHeapInternal or VirtualAlloc, not from an arbitrary function that happens to have the right unwind codes. This is call-chain plausibility analysis, and it is where the current research frontier sits.

Tier 4: CET shadow stack divergence. Compare the regular stack walk against the shadow stack. Any divergence is a confirmed spoof. This is the endgame, but requires CET-compatible hardware, Windows 11 22H2+, and a process that has opted into shadow stacks.

Supplementary: Hunt-Sleeping-Beacons. Tools like thefLink’s Hunt-Sleeping-Beacons proactively scan sleeping threads for suspicious indicators: unbacked memory in the call stack, stomped modules (where the memory-mapped content diverges from the file on disk), and timer/APC callback analysis to catch the timer-based sleep evasion. This is a point-in-time sweep, not a continuous monitor, but it complements the event-driven detections above.

A Sigma rule to hunt this

The honest scoping: Sigma operates on single events, so the only stack-spoof detection it can express is the unbacked-RA check via Sysmon’s CallTrace field. This catches the most common real-world case (injected code calling sensitive APIs with no stack spoofing or only sleep-time spoofing), but it will not catch a careful operator running generation-2 or generation-3 spoofing. For those, you need the deeper checks described above, which require an EDR or custom tooling, not a log query.

				
					title: Suspicious Process Access With Unbacked Call Stack Region
id: a7f3e2c1-9d84-4b1e-a6c0-8e2f7d3b5a19
status: experimental
description: >
    Detects process access events where the call stack contains frames from
    unbacked (non-image-backed) memory regions, indicated by UNKNOWN in
    Sysmon's CallTrace field. This is the foundational signal for in-memory
    implants, injected shellcode, and reflectively loaded code calling
    sensitive APIs. Sophisticated call stack spoofers (generation 2+) defeat
    this check by placing return addresses inside legitimate modules, so
    treat this as a first tripwire, not a complete control.
references:
    - https://valhguard.com/2026/03/19/call-stack-spoofing-detection/
    - https://github.com/thefLink/Hunt-Sleeping-Beacons
author: Adrian Diaz, Valhguard
date: 2026-03-19
tags:
    - attack.stealth
    - attack.t1055
    - attack.t1620
logsource:
    category: process_access
    product: windows
detection:
    selection:
        CallTrace|contains: 'UNKNOWN'
    filter_dotnet:
        SourceImage|endswith:
            - '\powershell.exe'
            - '\pwsh.exe'
            - '\w3wp.exe'
            - '\MSBuild.exe'
            - '\dotnet.exe'
    filter_debuggers:
        SourceImage|endswith:
            - '\devenv.exe'
            - '\windbg.exe'
            - '\DbgX.Shell.exe'
    filter_browsers:
        SourceImage|endswith:
            - '\chrome.exe'
            - '\msedge.exe'
            - '\firefox.exe'
    filter_java:
        SourceImage|endswith:
            - '\java.exe'
            - '\javaw.exe'
    filter_av:
        SourceImage|endswith:
            - '\MsMpEng.exe'
            - '\MsSense.exe'
    condition: selection and not 1 of filter_*
falsepositives:
    - JIT engines (.NET CLR, Java HotSpot, V8) generate executable code in private memory at runtime and will produce UNKNOWN call trace entries during normal operation; the filters above cover the most common JIT hosts but environments with less common managed runtimes (Unity, Mono, Electron apps) will need additional exclusions
    - Some EDR and anti-cheat agents perform user-mode hooking from unbacked trampolines and may trigger this rule
    - Performance profilers and debugging tools that inject code into target processes
level: medium

				
			

It validates clean and compiles to Splunk, Sentinel (KQL), Elastic, and CrowdStrike. The filter set covers the most common sources of legitimate UNKNOWN entries (JIT engines, debugging tools, .NET hosts), but any environment running less common JIT workloads will need tuning. Treat this as the first tripwire, not the last word.

Conclusion

Call stack spoofing has gone through three generations in five years. The first overwrote return addresses during sleep and was trivially caught by any stack scan that happened outside the sleep window. The second fabricated frames pointing to legitimate modules and defeated the unbacked-RA check but fell to call-site validation. The third, SilentMoonwalk, constructs frames that are consistent with the PE’s unwind metadata and passes both checks, pushing detection into call-chain plausibility analysis and, on newer hardware, CET shadow stack comparison.

The pattern should be familiar if you have been reading this blog: the technique evolves by faking one more layer of the truth, and the detection evolves by finding the next layer the fake cannot reach. For stack spoofing, that next layer is the hardware shadow stack. A return address on the regular stack can be anything the attacker writes. The shadow stack records what the processor actually executed. The mismatch is the detection, the same principle as the ETW event-header comparison in the PPID spoofing post, just at a different layer of the system.

If you take one thing from this: do not rely solely on the unbacked-RA check. It is the right first tripwire, but it is the one every C2 author knows about and every spoofing tool is built to defeat. Layer it with call-site validation, stack-depth heuristics, and sleeping-beacon hunts. And if you are on hardware that supports CET, push your application teams to link with /CETCOMPAT. The shadow stack is the closest thing to ground truth that the architecture gives you.

References

More from the blog

Article banner for In-Memory .NET: red-team execute-assembly with no disk on the left, blue-team CLR ETW, AMSI and out-of-process detection on the right
In-Memory .NET: Where the CLR’s Telemetry Actually Lives

Patch ETW and the CLR goes dark, load from a byte array and you dodge AMSI, Sysmon catches the DLL load anyway. Two of those are wrong on modern .NET and the third is structurally impossible. Where the telemetry for in-memory .NET really is, and which signals survive an operator inside the process.

Read More >>
Article banner for Virtualization as a Weapon: detecting portable QEMU
Virtualization as a Weapon: Detecting Portable QEMU and Red Team VMs

Virtualization is not just infrastructure; it is a weapon. Red Teams leverage portable QEMU instances to bypass host-based EDRs and evade behavioral analysis. We analyze how attackers deploy these “invisible machines” without administrative privileges and provide the KQL hunting queries defenders need to detect unauthorized virtualization on their networks.

Read More >>
Article banner for Detecting Driver Loading: sc.exe versus devcon.exe
Detecting Driver Loading: sc.exe vs. devcon.exe

Loading kernel drivers is a “Holy Grail” operation for attackers, granting Ring 0 privileges for persistence or EDR blinding. We analyze the forensic difference between the loud method (sc.exe) and the stealthy method (devcon.exe) to help Blue Teams build resilient detections.

Read More >>

Discover more from Valhguard

Subscribe now to keep reading and get access to the full archive.

Continue reading