CET-Compliant Callstack Spoofing via Thread Pool Enum Callback Trampolining

CET-Compliant Callstack Spoofing via Thread Pool Enum Callback Trampolining

Original text: “CET-Compliant Callstack Spoofing via Thread Pool Enum Callback Trampolining”Tiziano Marra, Tiziano’s Cybersecurity Blog (12 July 2026). Code blocks, tables, and figures below are reproduced verbatim with attribution captions.
Research published for educational and defensive purposes. Always obtain explicit written authorization before testing security techniques on any computer system. Unauthorized access is illegal.

Executive Summary

Modern EDR solutions increasingly rely on call stack inspection to detect suspicious syscalls. When sensitive operations like NtProtectVirtualMemory fire, EDR systems walk the thread’s call stack and flag any return addresses pointing to unbacked memory. This research presents a new approach to callstack spoofing that combines three established primitives into a novel arrangement: thread pool execution for a clean stack base, enum callback trampolining to create legitimate mid-stack frames, and indirect syscalls for clean return addresses. The critical contribution is achieving this while maintaining full compliance with Intel CET (Control Flow Enforcement Technology), which uses a hardware-protected shadow stack that cannot be directly modified.

The key innovation is a CET reconciliation mechanism using direct shadow stack pointer manipulation via RDSSPQ/INCSSPQ instructions combined with a jmp-based context switch, rather than attempting to modify unwind metadata. Every frame on the call stack is backed by a signed Windows module at the exact moment the syscall executes—no synthetic frames, no fabricated data, no smoke and mirrors. The PoC is implemented in Rust with full CET support (/CETCOMPAT, /guard:ehcont, control-flow guard) and compiles successfully on modern Windows toolchains.

1. A Brief History of Syscall Evasion

1.1 User-Mode Hooking Era (2019–2020)

For years, EDRs relied on user-mode API hooking by injecting a DLL into every process and placing trampolines at the beginning of sensitive ntdll.dll functions. The offensive response was direct syscalls: load the System Service Number (SSN) into RAX and execute syscall from custom code, bypassing hooks entirely. SysWhispers and Hell’s Gate made this technique accessible by automating SSN resolution.

1.2 Kernel Telemetry Counterattack (2021)

EDR vendors shifted focus to kernel-level telemetry and call stack walking from the kernel side. Direct syscalls left a dead giveaway: the call stack showed unbacked memory at the top, immediately flagging the operation as suspicious. This was the first real barrier to syscall evasion.

1.3 Indirect Syscalls (2021–2022)

The next evolution was indirect syscalls: instead of executing syscall from attacker code, jump to an existing syscall; ret sequence inside ntdll.dll. The immediate return address now points into a legitimate module, passing initial inspection. However, the rest of the stack still revealed the real caller, so EDRs simply walked deeper.

1.4 Call Stack Spoofing (2022–2023)

Multiple researchers demonstrated call stack spoofing: ThreadStackSpoofer, SilentMoonwalk, VulcanRaven, and others forged entire call stacks by manipulating return addresses on the normal stack. All this work was solid, but shared a critical problem: they all manipulated the normal stack alone. Then Intel released CET.

1.5 Intel CET and the Shadow Stack (2023–Present)

Intel CET introduced a hardware-enforced second copy of return addresses called the shadow stack. On every CALL, the CPU pushes to both. On every RET, both are popped and compared. Mismatch means #CP fault and process termination. Traditional stack spoofing became ineffective: you can overwrite the normal stack all day, but the shadow stack has the real addresses and is hardware-protected against normal memory writes.

1.6 Where This Technique Fits

This research achieves CET compliance through two mechanisms: using jmp instead of ret for context switches (the shadow stack is not involved in jmp instructions), and directly advancing the Shadow Stack Pointer via RDSSPQ/INCSSPQ to realign it. Combined with thread pool and enum callback trampolining, this produces a genuinely clean call stack backed by signed modules at the moment of syscall execution.

2. Background Concepts

2.1 Windows x64 Calling Convention

On 64-bit Windows, the first four function arguments pass in registers: RCX, RDX, R8, R9. Beyond four arguments, values go on the stack starting at [RSP+0x28] from the callee’s perspective. The caller must reserve 32 bytes of “shadow space” on the stack for all functions, regardless of argument count. This layout is critical when manually constructing stack frames.

              ┌─────────────────────────┐
  RSP+0x00 →  │ Return address          │  ← pushed by CALL
              ├─────────────────────────┤
  RSP+0x08 →  │ Home space for RCX      │  ← 32 bytes, callee can trash these
  RSP+0x10 →  │ Home space for RDX      │
  RSP+0x18 →  │ Home space for R8       │
  RSP+0x20 →  │ Home space for R9       │
              ├─────────────────────────┤
  RSP+0x28 →  │ 5th argument            │  ← extra args start here
  RSP+0x30 →  │ 6th argument            │
              └─────────────────────────┘

2.2 Intel CET and the Shadow Stack

Intel CET provides two user-mode instructions for shadow stack manipulation:

InstructionOpcode bytesPurpose
RDSSPQ regF3 48 0F 1E C8Read Shadow Stack Pointer into reg. Returns 0 if CET is off.
INCSSPQ regF3 48 0F AE E9Advance SSP forward by reg × 8 bytes (one entry per unit on x64).
CET Instructions. Source: original article.

Critical: there is also INCSSPD (32-bit variant) which advances by reg × 4 bytes. On x64, shadow stack entries are 8 bytes each. Using INCSSPD with a value of 1 advances by only 4 bytes, leaving the SSP misaligned and causing #CP faults on the next ret. Always use INCSSPQ on x64.

2.3 Indirect Syscalls

Instead of executing syscall from attacker-controlled code, jump to an existing syscall; ret sequence (bytes 0F 05 C3) inside ntdll.dll. Load the SSN into RAX, set arguments in the appropriate registers, then jump. The trampoline executes the syscall and returns with the result in RAX. The return address immediately after the syscall points into ntdll.dll, appearing legitimate to stack walkers.

fn get_trampoline(func_addr: *mut u8) -> Result {
    let mut image_base: u64 = 0;
    let func_entry = unsafe {
        RtlLookupFunctionEntry(func_addr as u64, &raw mut image_base, null_mut())
    };

    let func_size = unsafe {
        (*func_entry).EndAddress - (*func_entry).BeginAddress
    } as usize;

    if func_size >= 3 {
        for i in (0..func_size - 2).rev() {
            let ptr = unsafe { func_addr.add(i) };

            if unsafe { read_unaligned(ptr as *const [u8; 3]) } == [0x0F, 0x05, 0xC3] { // syscall; ret
                return Ok(ptr as u64);
            }
        }
    }

    Err(())
}

2.4 The Windows Thread Pool

The Thread Pool API (CreateThreadpoolWork, SubmitThreadpoolWork, etc.) lets you queue work items for execution by OS-managed worker threads. The crucial property for this technique: thread pool workers have naturally clean call stacks because the OS created them. A thread pool worker’s stack looks like a normal thread spawned by the OS, with no attacker code at the bottom. By running exploit code in a thread pool callback, you get a clean stack foundation for free.

2.5 Enum Callback Functions

Windows has dozens of “Enum” functions that accept a callback pointer and call it iteratively. EnumSystemLocalesEx enumerates locales, EnumResourceNamesW enumerates resource names, and so on. The critical property: when the OS calls your callback, the enum function itself appears as a real frame on the stack. Not faked, not synthetic—the enum function is genuinely calling your code. An EDR walking the stack sees a legitimate frame from a signed module.

Console output demonstrating successful CET-compliant syscall spoofing
Successful execution of CET-compliant callstack spoofing demonstrated in console output. Source: original article.

3. Technique Design

3.1 High-Level Overview

The technique operates in three phases on the same thread:

  • Phase 1 (Thread pool worker): Creates a clean stack base, unwinds one frame, redirects to an enum function.
  • Phase 2 (Enum callback, first invocation): Unwinds again, sets up syscall registers, redirects to a syscall; ret trampoline.
  • Phase 3 (Enum callback, second invocation if needed): Cleans up stack slots, returns 0 to stop enumeration.

Each “redirect” goes through user_mode_continue, a custom inline assembly routine that reconciles the CET shadow stack and restores CPU state before jumping to the target.

3.2 The Three-Phase Call Stack

At the exact moment syscall executes, the call stack is:

ntdll!NtProtectVirtualMemory+12           ← syscall; ret trampoline
kernelbase!Internal_EnumSystemLocales+348 ← real frame
kernelbase!EnumSystemLocalesEx+1F         ← real frame
ntdll!TppWorkpExecuteCallback+4D0         ← thread pool
ntdll!TppWorkerThread+801                 ← thread pool
kernel32!BaseThreadInitThunk+17
ntdll!RtlUserThreadStart+2C

Every single frame is backed by ntdll.dll, kernelbase.dll, or kernel32.dll. No unbacked memory. No suspicious frames.

WinDbg debugger showing call stack and shadow stack pointer at syscall execution
WinDbg callstack at the moment of syscall execution, showing legitimate module-backed frames. Source: original article.

4. Implementation Deep Dive

4.1 The EmbeddedContext Structure

This structure holds the shared state across all three phases: syscall parameters, bookkeeping, and stack backups.

#[repr(C)]
#[derive(Default)]
struct EmbeddedContext {
    ssn             : u32, // System Service Number
    // [4 bytes padding]
    trampoline      : u64, // Address of "syscall; ret" in ntdll
    args_len        : u64, // Number of syscall arguments (up to 11)
    arg1            : u64, // Syscall arguments 1..11
    arg2            : u64,
    // ... arg3 through arg11 ...
    invoke_count    : u64, // How many times the callback was invoked
    backup_addr_arg5: u64, // Phase 1 stack backups (address + value)
    backup_val_arg5 : u64,
    backup_addr_arg6: u64,
    backup_val_arg6 : u64,
    backup_addr_arg7: u64,
    backup_val_arg7 : u64,
    init_once       : u64,
    worker_ctx_ptr  : u64, // Pointer to Phase 1 CONTEXT
    callback_ctx_ptr: u64, // Pointer to Phase 2 CONTEXT
    saved_aup       : u64, // Original TEB ArbitraryUserPointer
    magic           : u64, // Self-referencing pointer for validation
    cb_sp           : u64, // Phase 2 stack pointer (for cleanup)
    cb_orig_5       : u64, // Phase 2 stack backups (original values)
    // ... cb_orig_6 through cb_orig_11 ...
}

The magic field stores the structure’s own address. In the callback, the pointer from TEB+0x28 is validated by checking whether *(ptr + 0xC8) equals ptr itself. This prevents accidental corruption or interference from unrelated thread-local pointer writes.

4.2 Phase 1: Thread Pool Worker Setup

The thread pool worker captures the current CPU context with RtlCaptureContext, then unwinds one frame to get the parent’s (TP internals) return address and stack pointer. It builds a new stack frame with the correct return address, saves stack slots that the enum function will overwrite, sets up registers for the enum function call, writes the context pointer to TEB+0x28, and switches context via user_mode_continue.

fn thread_pool_dispatcher(embedded_ctx: &mut EmbeddedContext) {
    embedded_ctx.magic = from_ref(embedded_ctx) as u64;

    loop {
        embedded_ctx.invoke_count = 0;
        let context_addr = from_ref::(embedded_ctx) as u64;

        let work = unsafe {
            CreateThreadpoolWork(
                Some(thread_pool_worker_enum),
                context_addr as *mut _,
                null_mut()
            )
        };
        unsafe { SubmitThreadpoolWork(work) };
        unsafe { WaitForThreadpoolWorkCallbacks(work, 0) };
        unsafe { CloseThreadpoolWork(work) };

        if embedded_ctx.invoke_count > 0 {
            break;
        }
    }
}

4.3 Phase 2: Enum Callback and Syscall Setup

The enum callback (which is invoked from within EnumSystemLocalesEx) retrieves the EmbeddedContext from TEB+0x28, validates via magic, restores the backed-up stack slots, and performs another capture/unwind cycle. It then sets up the syscall: RAX = SSN, RIP = trampoline address, and arguments in the appropriate registers and stack slots. The return address from the unwind is placed on the new stack so the trampoline’s ret returns into the enum function’s iteration logic.

#[inline(never)]
extern "system" fn manual_stack_unwind_enum() -> i32 {
    // Retrieve context from TEB
    let args_ptr = get_teb_address().add(0x28) as *const u64;
    let args = &*(unsafe { *args_ptr } as *const EmbeddedContext);
    
    // Unwind and set up syscall
    unsafe { 
        write_volatile(&raw mut (*context_record).Rax, u64::from(args.ssn));
        write_volatile(&raw mut (*context_record).Rip, args.trampoline);
        write_volatile(&raw mut (*context_record).Rcx, args.arg1);
        write_volatile(&raw mut (*context_record).R10, args.arg1);
        write_volatile(&raw mut (*context_record).Rdx, args.arg2);
        write_volatile(&raw mut (*context_record).R8,  args.arg3);
        write_volatile(&raw mut (*context_record).R9,  args.arg4);
    }
    
    // Context switch with CET reconciliation
    user_mode_continue(context_record, new_rsp);
}

4.4 CET Reconciliation: The Core Contribution

The user_mode_continue function is where CET compliance is achieved. Because it is marked #[inline(never)], the call into it pushes a return address onto the shadow stack. When we jmp away instead of ret-ing, that entry becomes stale. The reconciliation loop advances the Shadow Stack Pointer past it using INCSSPQ:

#[inline(never)]
fn user_mode_continue(context_record: *mut CONTEXT, new_rsp: u64) -> ! {
    unsafe {
        asm!(
            // --- CET shadow stack reconciliation ---
            "xor rax, rax",
            ".byte 0xF3, 0x48, 0x0F, 0x1E, 0xC8",    // RDSSPQ rax
            "test rax, rax",
            "jz 3f",                                 // CET off → skip
            "mov r9, 16",
            "2:",
            "mov r11, [rax]",
            "cmp r11, [r14]",
            "je 3f",
            "mov ecx, 1",
            ".byte 0xF3, 0x48, 0x0F, 0xAE, 0xE9",    // INCSSPQ rcx
            "add rax, 8",
            "dec r9",
            "jnz 2b",

            // --- Full state restoration ---
            "3:",
            "mov rsp, r14",
            // ... restore all GPRs, EFlags, MXCSR, XMM6-XMM15 ...
            "mov r14, [r15 + 0xE8]",
            "mov r15, [r15 + 0xF0]",
            "jmp r11",                               // → CONTEXT.Rip

            in("r14") new_rsp,
            in("r15") context_record,
            options(noreturn)
        );
    }
}

The reconciliation loop:

  1. Reads the current Shadow Stack Pointer with RDSSPQ rax (returns 0 if CET is off)
  2. If 0, CET is disabled, skip reconciliation
  3. Loop (up to 16 iterations):
    • Read shadow stack entry at [RAX]
    • Compare to expected return address at [new_rsp]
    • Match → done, SSP is aligned
    • No match → execute INCSSPQ rcx (rcx=1) to advance by 8 bytes
    • Repeat
  4. Restore full CPU state from the CONTEXT structure
  5. Jump (not return) to CONTEXT.Rip (the syscall trampoline)

4.5 Why This Approach Works with CET

Traditional stack spoofing writes a fake return address to [RSP]. When the CPU executes ret, it compares the normal stack value against the shadow stack value. Mismatch = #CP fault. But our technique uses jmp, which doesn’t touch either stack. CET is not involved in the jmp instruction. The shadow stack misalignment caused by our non-returning call to user_mode_continue is then fixed by the INCSSPQ reconciliation loop before any ret instruction executes.

Diagram comparing shadow stack pointer with normal call stack during execution
Example of fully working Shadow Stack compared to normal call stack. Source: original article.

5. The 39 Enum Functions

The technique works with 39 different enum/callback functions across kernel32.dll and kernelbase.dll. These functions share a critical property: they stop enumeration when the callback returns 0. When a syscall succeeds, it returns STATUS_SUCCESS = 0. The enum function interprets this as “stop enumerating” and exits, so the callback is invoked only once in the success path. In failure cases (non-zero NTSTATUS), the enum function calls the callback again, but by then TEB+0x28 has been restored, so the magic validation fails and the callback returns 0 to stop enumeration.

#FunctionModule
1EnumUILanguagesA / EnumUILanguagesWkernel32 / kernelbase
2EnumSystemLanguageGroupsA / EnumSystemLanguageGroupsWkernel32 / kernelbase
3EnumLanguageGroupLocalesA / EnumLanguageGroupLocalesWkernel32 / kernelbase
4EnumResourceTypesA / EnumResourceTypesW / EnumResourceTypesExA / EnumResourceTypesExWkernel32 / kernelbase
5EnumResourceNamesA / EnumResourceNamesW / EnumResourceNamesExA / EnumResourceNamesExWkernel32 / kernelbase
6EnumResourceLanguagesA / EnumResourceLanguagesW / EnumResourceLanguagesExA / EnumResourceLanguagesExWkernel32 / kernelbase
7EnumCalendarInfoA / EnumCalendarInfoW / EnumCalendarInfoExA / EnumCalendarInfoExW / EnumCalendarInfoExExkernel32 / kernelbase
8EnumDateFormatsA / EnumDateFormatsW / EnumDateFormatsExA / EnumDateFormatsExW / EnumDateFormatsExExkernel32 / kernelbase
9EnumSystemCodePagesA / EnumSystemCodePagesWkernel32 / kernelbase
10EnumSystemGeoID / EnumSystemGeoNameskernel32 / kernelbase
11EnumSystemLocalesA / EnumSystemLocalesW / EnumSystemLocalesExkernel32 / kernelbase
12EnumTimeFormatsA / EnumTimeFormatsW / EnumTimeFormatsExkernel32 / kernelbase
13InitOnceExecuteOncekernelbase
The 39 enum and callback functions suitable as call stack trampolines. Source: original article.

Important caveat: not all functions support all syscall argument counts. Each function allocates a different amount of stack space in its prologue. A function that supports up to 8 syscall arguments on Windows 10 may only support 5 on Windows 11 due to recompilation. In production use, a runtime profiler measures each function’s stack allocation at startup and determines its max_args value. A syscall is only dispatched through a function if that function’s max_args is sufficient.

6. Debugging and Development Challenges

This technique did not work on the first try. Several critical bugs emerged during development:

6.1 Stack Slot Overwriting

In Phase 1, the redirect overwrites stack slots intended for the enum function’s 5th, 6th, and 7th arguments. These slots belong to the calling function’s frame. If not restored before the enum function returns, the calling function (TP internals) finds garbage in its locals and crashes. The fix: back up these values before overwriting and restore them at the start of the callback.

6.2 The 8-Byte Offset Bug

When calculating which stack slots to back up, an off-by-eight error crept in. The issue: after placing a return address at [new_rsp], from the called function’s perspective, home space starts at [RSP+0x08], not [RSP+0x00]. This means positions previously calculated as new_rsp + 0x20 were actually new_rsp + 0x18. The callback was backing up the wrong memory slots, causing the TP internals to crash when trying to use their own locals. This bug took days of hex dump analysis to isolate.

6.3 INCSSPD vs. INCSSPQ: The Misalignment Bug

The original implementation used INCSSPD (32-bit variant) to advance the shadow stack. INCSSPD ecx advances by ecx × 4 bytes. On x64, shadow stack entries are 8 bytes. With ecx=1, the SSP advanced by only 4 bytes, half an entry. This left the SSP pointing to the middle of a return address, causing every subsequent shadow stack read to be garbage. The next ret would compare misaligned values and crash with #CP. The fix: one byte—the REX.W prefix (0x48)—to switch to INCSSPQ, which advances by ecx × 8 bytes. On x64, always use INCSSPQ.

7. Key Takeaways

8. Defensive Recommendations

9. Conclusion

This research demonstrates that call stack spoofing is viable on CET-enabled systems through careful handling of the shadow stack and context switching. The individual components (thread pools, enum functions, indirect syscalls) are not new, but their composition with CET reconciliation produces a technique that was previously thought infeasible. The value of this work lies in understanding the limitations of current defenses and the degree to which sophisticated implementation techniques can evade them. Both offensive and defensive teams benefit from this research: attackers can now bypass CET-based assumptions, and defenders can build countermeasures informed by how this technique actually operates.

Original text: “CET-Compliant Callstack Spoofing via Thread Pool Enum Callback Trampolining” by Tiziano Marra at Tiziano’s Cybersecurity Blog (12 July 2026). Code and technical details reproduced verbatim with attribution. Source code available on GitHub.

Comments are closed.