back

Call stack spoofing without touching a single DLL byte

2026.09

A Win64 thread's call tree is one of the best artifacts a defender has. CreateRemoteThread whose reconstructed chain ends in consumer.exe!shellcode_loader+0x42 reads differently from one whose chain ends in kernel32!BaseThreadInitThunk+0x14. Stack spoofing is the practice of producing the second when the first is true, and every (currently) published technique picks a different place to lie.

Cuckoo lies in execution flow and leaves every byte of unwind metadata alone. This post is the mechanism, the IOC profile, and what four public scanners saw when it ran.

The issue with older techniques

Stack walkers reconstruct a thread's chain by reading frame sizes from .pdata unwind metadata, hopping saved return address to saved return address until they reach the thread's entry point. Two families of technique attack that walk.

The original SilentMoonwalk approach chained call <reg>; <epilog> gadgets in system DLLs so the walker landed on function bodies whose UNWIND_INFO happened to describe the hidden frame it needed to swallow. Control Flow Guard dispatch, body-after-call layouts, and general scarcity mean the exact call reg followed by a clean ret epilog is essentially gone from kernel32, kernelbase, and ntdll on modern Windows 11 24H2. The once plentiful supply of gadges for this.. dried up.

Klez's response was BYOUD. The stack walker does not validate exact return addresses, it validates that frame sizes add up. Rewrite the unwind metadata of a proxy function so one frame of unwind data swallows the hidden subtree, and the reconstructed tree diverges from the one actually executed. BYOUD works, and it is loud in a specific way. It requires writing into .pdata, .rdata, or .xdata in a system DLL's memory image. Those sections carry PAGE_READONLY, so tampering needs a VirtualProtect flip and diverges the byte content from the on-disk reference. An UnwindInspector-style integrity diff will flag this.

Cuckoo aims at the same call tree result through a mechanism that touches no DLL memory at all.

Two observations

RtlVirtualUnwind only reads what is on the regular stack at the moment the walker observes it. Whatever was on the stack a microsecond earlier and got overwritten before the walker arrived is invisible.

Hardware breakpoints are per-thread debug-register state, settable by the thread itself via SetThreadContext on its own handle. A breakpoint placed on the address the spoofed return chain claims as the parent fires as EXCEPTION_SINGLE_STEP the moment the target API tries to return through it.

Plant a fabricated chain on the regular stack before pivoting into the target API. Set a hardware execute breakpoint on the planted "parent" address. Run the API. Catch the trap in a vectored exception handler. Redirect execution back into a continuation labelled inside the dispatcher's own frame. Unwind through the dispatcher's truthful UNWIND_INFO.

The dispatcher

Everything lives in one assembly procedure with truthful FRAME annotations:

cu_dispatch PROC FRAME
    push    r15
    .pushreg r15
    push    r14
    .pushreg r14
    ; ... seven more pushes for nonvol GPRs ...
    sub     rsp, 108h
    .allocstack 108h
    .endprolog

The 0x108 byte allocation is the planting region. Three slots inside it carry the fabricated chain:

planted_top + 0x00 : SlotA (fires the HW BP: BTITN+0x14)
planted_top + 0x08 : SlotB (next walked frame: RtlUTS+0x21)
planted_top + 0x10 : SlotC (terminator: 0)

The remaining bytes serve as scratch and keep the alignment honest. After the prologue settles, RSP is congruent to 8 modulo 16, matching what the Win64 ABI requires a callee to see on entry with a 32 byte shadow space and arguments five and onward at [rsp+0x28] upward. Cuckoo's pivot must land RSP exactly there relative to the SlotA/B/C plant, so the argument count is capped at five. The sixth argument slot would collide with SlotB.

Arming the breakpoint

cu_arm_hwbp runs once the chain is planted and does three things in sequence.

  1. Stash the active CU_SPOOFER pointer in a TLS slot so the vectored handler can find the saved

RSP and the continuation address.

  1. GetThreadContext on the calling thread's pseudo-handle, scan DR7 for the lowest free L_n

enable bit. (A free slot is one whose enable bit is zero, regardless of what content sits in the corresponding DRn register)

  1. Write the planted SlotA value into the chosen DRn, set its L_n bit in DR7, and clear the R/W

and LEN fields for that slot to encode an execute breakpoint (R/W = 00, LEN = 00). SetThreadContext commits.

The free-slot scan exists for a reason. Hardcoding DR0 collides with EDR runtimes that own one or more debug-register slots for their own hooks. Stomping a slot the EDR is actively using produces both a missed hook on the EDR side and a guaranteed alert on the spoofer's side. The lowest-free scan avoids that vector as long as at least one of four slots is available.

The pivot

After cu_arm_hwbp returns, cu_dispatch loads the target function pointer into RAX and the arguments into RCX/RDX/R8/R9 plus the fifth into its shadow-space slot, then:

mov     rsp, r10        ; r10 was loaded with the planted top
jmp     rax

The jmp is load-bearing. A CALL would push the post-call return address inside cu_dispatch (a real EXE address) onto both the regular stack and the shadow stack, defeating the whole point, no thanks. A JMP transfers control without touching either. From the target API's perspective on entry, the regular stack reads exactly as if the thread had started inside BaseThreadInitThunk, run for a while, and called the API normally.

The handler

The vectored exception handler receives every exception in the process. It acts only when three conditions all hold:

  1. Exception code is EXCEPTION_SINGLE_STEP (0x80000004).
  2. The DR6 hit bit corresponding to s->DrSlot is set.
  3. The TLS slot for the active CU_SPOOFER is non-null.

Failing any condition, the handler returns EXCEPTION_CONTINUE_SEARCH and the exception flows to whatever the process would have done with it. The handler is a strict filter, never a swallower.

When all three hold:

  1. Stash the target API's return value from RAX of the exception context into s->ReturnValue.
  2. Disarm the DR slot by clearing its L_n bit in DR7 and its hit bit in DR6.
  3. Restore RSP from s->SavedRsp, the value cu_dispatch saved before pivoting.
  4. Set Rip to cu_dispatch_continuation, a label inside cu_dispatch between the JMP and the

truthful epilog.

  1. Return EXCEPTION_CONTINUE_EXECUTION.

The CPU resumes at the continuation. RAX is reloaded from s->ReturnValue, the add rsp, 108h reverses the allocation, nine pop instructions reverse the nonvol saves, ret returns to the original caller. From outside, cu_dispatch is a normal Win64 function whose UNWIND_INFO matches its actual prologue exactly.

What a walker sees

While the target API is running, an observer that OpenThreads, SuspendThreads, and walks via RtlVirtualUnwind sees:

[0]  user32!MessageBoxA+0x1f
[1]  KERNEL32!BaseThreadInitThunk+0x14
[2]  ntdll!RtlUserThreadStart+0x21
[end]

The chain a thread started directly with CreateThread(MessageBoxA, ...) would produce. cuckoo.exe appears nowhere. After the API returns and the handler redirects execution back into cu_dispatch, the chain transitions to its truthful form during the dispatcher's epilog. The window of plausibility is exactly the duration of the target API call.

Why the unwind data does not lie

Here separates cuckoo from BYOUD.

cu_dispatch is a real function. Its .pushreg, .allocstack, and .endprolog describe its prologue accurately. A walker that lands inside cu_dispatch gets a perfectly accurate frame. A walker that lands inside the target API during the spoofed-chain window does not see cu_dispatch at all, because the planted frame above the target API claims BTITN+0x14 as the parent, and BaseThreadInitThunk's own truthful UNWIND_INFO carries the walker the rest of the way to RtlUserThreadStart.

Every UNWIND_INFO consulted along the spoofed chain is the genuine, on-disk, byte-identical metadata of a Microsoft-shipped function. None of them have been touched. The lie sits in execution flow (the target API was called from cu_dispatch, not from BTITN), never in metadata.

BYOUD leaves execution honest and lies in metadata. Cuckoo leaves metadata honest and lies in execution. Each pays a different IOC cost.

IOC profile

Per call, cuckoo produces:

  • One EXCEPTION_SINGLE_STEP trap, captured in the process's own VEH and not propagated.

Visible in the ETW Threat Intelligence provider, which logs exception dispatch events.

  • One pair of GetThreadContext / SetThreadContext on the calling thread's pseudo-handle.
  • One DR slot held for the duration of the call.

Per call, cuckoo does not produce:

  • Any write to a DLL's .text, .pdata, .rdata, or .xdata section.
  • Any VirtualProtect change on a system DLL's mapping.
  • Any modification of RUNTIME_FUNCTION tables, static or dynamic.
  • Any unwind-metadata divergence from the on-disk image.
  • Any thread-pool dispatch event.
  • Any spawned thread.

The asymmetry against BYOUD is direct. BYOUD avoids the exception-trap signal and pays for it with module-memory tampering. Cuckoo avoids the module-memory signal and pays for it with a single trap.

What four scanners saw

A worker thread was placed in DelayExecution via cuckoo_call(Sleep, 120000) and scanned by Moneta (Forrest Orr), PE-Sieve (hasherezade), Hunt-Sleeping-Beacons, and Get-InjectedThreadEx. A naive control (worker thread starting in VirtualAlloc(PAGE_EXECUTE_READWRITE) private memory) ran alongside so the toolchain fired on something it should.

Control:

Moneta:   private +X region; thread origin off-image
PE-Sieve: 1/4 -- implanted_shc=1 | thread: SUS_START, SUS_CALLSTACK_SHC
HSB:      Thread N | Abnormal Page in Callstack | private memory regions | MID
GIT-Ex:   TID N -> PRIVATE

Cuckoo:

Moneta:   no anomaly markers
PE-Sieve: modified=0/5 (errors=0)
HSB:      Thread N | Identified enabled hardware-breakpoints
GIT-Ex:   TID N -> hw_breakpoint

The chain inspection was clean across all four. Specifically, none of these fired on cuckoo:

  • Moneta's Abnormal private executable memory and Thread within non-image memory region.
  • PE-Sieve's SUS_START, SUS_CALLSTACK_SHC, and implanted_shc indicators (with

/shellc 3 /threads).

  • HSB's return_address_spoofing.cpp, abnormal_intermodular_call.cpp, private_memory.cpp,

and non_executable_memory.cpp modules.

  • Get-InjectedThreadEx's proxy_call, prolog, private_image, and PRIVATE rules.

Both HSB and Get-InjectedThreadEx logged Win32StartAddress: sleeper.exe!worker for the cuckoo'd thread and did not cross-check that against the spoofed chain, which contains no worker frame. The Win32-start vs chain mismatch is the cleanest theoretical detection against cuckoo, and it is not implemented in any of the four scanners at the time of writing.

The spoof itself holds.

What did fire, and why

Both HSB and Get-InjectedThreadEx caught the same orthogonal signal: an enabled hardware breakpoint sitting in a debug register during the call window. HSB labelled it Identified enabled hardware-breakpoints. Get-InjectedThreadEx flagged it as hw_breakpoint.

Two properties of the detection.

It is generic, not cuckoo-specific. Both rules flag any thread with an enabled L_n bit in DR7. The same pattern fires on EDR hooks themselves, anti-cheat, debuggers, and a number of legitimate runtimes. Signal-to-noise on "DR enabled" is poor, so EDRs that ingest it generally treat it as an enrichment factor.

It is structural to the redirect mechanism. The target API's ret consumes the planted BTITN+0x14 and would execute the real bytes at that address (which dispatch into thread-exit code) unless something catches the transition first. The realistic alternatives are page guards on kernel32.dll (much louder), invalid-VA plus access-violation handlers (heavier ETW exception telemetry than #DB), or inline-patching the system DLL itself (defeats the "no DLL tampering" property that motivated the whole design). HWBP is the quietest available primitive for this redirect.

Limits

  • Five argument cap. The sixth collides with SlotB. Fixable by enlarging the plant and shifting

the slot offsets at the cost of a wider plant footprint.

  • One in-flight call per thread. Two overlapping calls on one thread would corrupt the TLS

pointer. The API blocks until the target returns, so this is not reachable in normal use.

  • /CETCOMPAT processes are unsupported. The target API's ret pops the planted BTITN+0x14

off the regular stack and pops caller_RA off the shadow stack. The mismatch raises #CP and the process terminates. Making cuckoo CET-clean requires the state-driven dispatcher shape BYOUD's unreleased SDIE prototype reaches for, where every transition is a real CALL/RET pair and the two stacks stay in sync. That would be a real research direction if /CETCOMPAT ever becomes mandatory. The /CETCOMPAT set today is small and includes essentially no third-party userland software.

  • Four DR slots per thread, and EDRs commonly hold one or two. Three coexisting EDRs holding all

four leaves no room.

  • No varargs APIs whose stack-arg layout depends on runtime types (printf family). The fixed

five-arg ABI handles every standard Win32 API.

Where this sits

BYOUD by Klez was the proximate inspiration for treating the unwind apparatus as the spoofing surface. Cuckoo takes the dual of BYOUD's path, with a different IOC profile and a different detection envelope. Neither dominates the other, and a defender who builds detection for one is not automatically equipped for the other.