EkkoNtProtect
Arbitrary NtProtectVirtualMemory calls from Ekko-style timer-based sleep obfuscation using internal ntdll functions
The Problem
Ekko uses RtlCreateTimer with NtContinue to chain API calls during sleep obfuscation. Each timer callback fires on the same thread pool worker thread, and every ROP frame shares the same RSP value (captured once via RtlCaptureContext).
On x64 Windows, the first four function arguments are passed in registers (RCX, RDX, R8, R9) and are set directly in the CONTEXT structure. The 5th and subsequent arguments must be placed on the stack at [RSP+0x28], [RSP+0x30], etc. Under normal circumstances, these are completely reliable. The problem comes when you are attempting to use Ekko with API calls that require stack arguments in an environment where the API call instructions have been modified somehow (e.g. EDR userland hooks).
The problem: Before the next timer can be executed, TppCallbackEpilog overwrites [RSP+0x28] between every timer callback. Any value written to that stack slot is destroyed before the next callback reads it. This was confirmed empirically with a hardware write watchpoint:
0:009> ba w8 @rsp+0x28
...
0:009> g
Breakpoint 4 hit
ntdll!TppCallbackEpilog+0x74:
00007ffa`5bc52aa4 e857050000 call ntdll!TppCallbackCheckThreadAfterCallback (00007ffa`5bc53000)
The watchpoint fires on TppCallbackEpilog between every callback dispatch, proving the timer infrastructure actively clobbers the 5th argument slot. This is why the original Ekko PoC exclusively uses APIs with ≤4 parameters (VirtualProtect, SystemFunction032, WaitForSingleObject, SetEvent) and why NtProtectVirtualMemory (5 arguments) cannot be called directly from the timer chain.
This constraint was first articulated in the context of comparing Ekko's timer dispatch model to Foliage's APC-based model, where each call gets its own RSP position (Rsp -= 0x1000 * N), isolating stack arguments between frames. Havoc's Demon implementation confirms this design decision; its Ekko/Zilean path uses VirtualProtect (4 args) while its Foliage path uses NtProtectVirtualMemory (5 args).
Why This Matters
Using VirtualProtect (kernel32) instead of NtProtectVirtualMemory (ntdll) introduces non-ntdll frames into the call stack at syscall time. EDR solutions like Elastic detect this pattern; rules such as "Windows API via a CallBack Function" fire on call stacks showing kernel32/kernelbase frames dispatched through thread pool callbacks. Similarly, calling VirtualProtect through a wrapper function in a module-stomped DLL triggers detections for hollowed/stomped module execution.
The goal: call NtProtectVirtualMemory with arbitrary parameters from the timer chain, producing a call stack composed exclusively of ntdll frames with every return address preceded by a legitimate CALL instruction.
The Solution
Instead of calling NtProtectVirtualMemory directly (which requires placing the 5th argument on the clobbered stack), we call unexported ntdll-internal functions that internally call NtProtectVirtualMemory via a normal call rel32 instruction. These functions handle the by-ref parameter indirection, NtCurrentProcess handle, OldProtect pointer, and 5th argument placement on their own stack frame; a stack frame that exists within a single callback invocation, immune to inter-callback clobbering.
The technique works because these internal functions were designed to operate on struct pointers passed in registers. We construct fake structs with the fields these functions read (BaseAddress, RegionSize, NewProtect) while zeroing everything else to safely bypass post-call validation paths.
Systematic Enumeration
All 27 internal callers of NtProtectVirtualMemory within ntdll were identified by scanning the .text section for E8 (CALL rel32) instructions whose target resolves to NtProtectVirtualMemory's address. Each caller was disassembled and classified:
| Category | Count | Issue |
|---|---|---|
| Globals-dependent | 1 | BaseAddress/RegionSize from ntdll internal globals (LdrpMrdataBase, LdrpMrdataSize); cannot redirect to arbitrary memory |
| Hardcoded parameters | 2 | NewProtect and/or RegionSize are compile-time constants (e.g., PAGE_NOACCESS, 0x1000) |
| MEM_PRIVATE gated | 3 | Skip the NtProtectVirtualMemory call if MBI.Type != MEM_PRIVATE; module-stomped memory is MEM_IMAGE |
| Too coupled | 20 | Mid-function call sites in large complex functions with deep struct dependencies, linked list traversals, SRW locks, and multiple internal calls |
| Viable trampoline | 1 | All parameters controllable from a register-passed struct, survivable post-call path |
Of the 27 callers, only LdrpDoPostSnapWork directly calls NtProtectVirtualMemory with fully controllable parameters and a post-call path that can be safely navigated with a zeroed struct. The remaining 26 are all unusable for the reasons listed above.
From that single viable trampoline, we traced upward through the loader call chain to identify two additional entry points (LdrpSnapModule and LdrpProcessWork) that eventually call LdrpDoPostSnapWork. Each adds a frame to the call stack, producing an increasingly authentic-looking loader chain at the cost of a larger fake struct and additional pre-call functions to survive.
Three Trampoline Tiers
The three viable functions form a natural call chain in the PE loader pipeline, offering increasing call stack depth and authenticity:
Tier 1: LdrpDoPostSnapWork
The leaf function. Takes a single struct pointer in RCX. Reads BaseAddress (+0x70), RegionSize (+0x78), and NewProtect (+0x90) directly from the struct. Handles NtCurrentProcess, by-ref indirection, and OldProtect internally.
Post-call path accesses [struct+0xA0] (must be NULL to skip CFG validation) and dereferences [struct+0x38] at offset +0x6E (must point to readable memory; a self-pointer to the struct itself works). Smallest struct (~0xA8 bytes), fewest side effects.
Tier 2: LdrpSnapModule
Wraps LdrpDoPostSnapWork. This is the core import resolution function; it walks import descriptors, binary-searches export tables, and writes resolved addresses into the IAT. Setting [struct+0x80] >= [struct+0x68] (current import index >= total import count) causes the resolution loop to be skipped entirely, falling through to the LdrpDoPostSnapWork call.
Requires a nested struct with an inner "module" struct at +0x100 and a state block at +0x1C0. Pre-call path calls LdrpHandlePendingModuleReplaced (returns immediately if [struct+0x50] is NULL), LdrpLogDllState (ETW logging; no-op without active trace session), and memset (harmless). Post-call path writes 5 to [inner+0x98]+0x38 (must point to writable memory). Has __security_check_cookie in the epilog, but this works correctly because the function executes its full prologue via NtContinue, computing and verifying the cookie within the same invocation.
Tier 3: LdrpProcessWork
Wraps LdrpSnapModule. This is the thread pool work dispatcher for the parallel module loader. Setting the second argument (RDX) to 1 (IsLoadOwner = TRUE) ensures the clean exit path; no critical sections, no global counter decrements, no event signaling.
Adds two struct requirements: [struct+0x28] must point to a writable DWORD initialized to 0 (status check), and [[struct+0x38]+0x98]+0x38 must be non-zero to take the snap dispatch path (reuses the state block that LdrpDoPostSnapWork writes to). Same struct as Tier 2 with the addition of the status pointer.
This produces the exact call chain the parallel loader generates during normal DLL loading; LdrpProcessWork dispatching LdrpSnapModule on a worker thread, which calls LdrpDoPostSnapWork to restore IAT protection after import resolution.
Build & Usage
gcc.exe EkkoNtProtect.c -o EkkoNtProtect.exe
The PoC allocates a test region and cycles its protection through all three tiers:
=== EkkoNtProtect PoC ===
[*] Test region: 0000029e71b00000 (size: 0x10000)
--- Tier 1 (LdrpDoPostSnapWork) ---
[BEFORE] Base: 0000029e71b00000 Size: 0x10000 Protect: 0x40
[*] Changing to PAGE_READWRITE via timer chain...
[+] Resolved LdrpDoPostSnapWork at 00007ffcb8673dc0
[+] Timer chain completed successfully
[AFTER ] Base: 0000029e71b00000 Size: 0x10000 Protect: 0x4
[*] Changing to PAGE_EXECUTE_READ via timer chain...
[+] Resolved LdrpDoPostSnapWork at 00007ffcb8673dc0
[+] Timer chain completed successfully
[FINAL ] Base: 0000029e71b00000 Size: 0x10000 Protect: 0x20
--- Tier 2 (LdrpSnapModule) ---
[BEFORE] Base: 0000029e71b00000 Size: 0x10000 Protect: 0x40
[*] Changing to PAGE_READWRITE via timer chain...
[+] Resolved LdrpSnapModule at 00007ffcb86acb10
[+] Timer chain completed successfully
[AFTER ] Base: 0000029e71b00000 Size: 0x10000 Protect: 0x4
[*] Changing to PAGE_EXECUTE_READ via timer chain...
[+] Resolved LdrpSnapModule at 00007ffcb86acb10
[+] Timer chain completed successfully
[FINAL ] Base: 0000029e71b00000 Size: 0x10000 Protect: 0x20
--- Tier 3 (LdrpProcessWork) ---
[BEFORE] Base: 0000029e71b00000 Size: 0x10000 Protect: 0x40
[*] Changing to PAGE_READWRITE via timer chain...
[+] Resolved LdrpProcessWork at 00007ffcb868e860
[+] Timer chain completed successfully
[AFTER ] Base: 0000029e71b00000 Size: 0x10000 Protect: 0x4
[*] Changing to PAGE_EXECUTE_READ via timer chain...
[+] Resolved LdrpProcessWork at 00007ffcb868e860
[+] Timer chain completed successfully
[FINAL ] Base: 0000029e71b00000 Size: 0x10000 Protect: 0x20
[*] Done.
Build-Specific Dependencies
Because these are internal, unexported functions, you cannot retrieve their base address using GetProcAddress or similar. For this PoC, we chose to use a naive byte search for each of the three functions. The struct offsets, canary byte signatures, and function entry-point calculations are derived from a specific ntdll build. To adapt for a different build:
-
Locate
NtProtectVirtualMemorycallers: Scan ntdll.textforE8instructions whoserel32resolves toNtProtectVirtualMemory's exported address. -
Disassemble each caller: Classify by parameter controllability; does it read BaseAddress, RegionSize, and NewProtect from a register-passed struct, or are they hardcoded/globals-derived?
-
Trace the post-call path: Identify every dereference after the
NtProtectVirtualMemorycall returns. Determine what struct fields must be non-NULL, what must be NULL, and what must point to writable memory. -
Extract the canary: Pick a unique byte sequence near the function entry (avoid relative displacements like short jumps that change with code layout). Compute the offset from canary to function entry.
-
Validate: Set a breakpoint on
NtProtectVirtualMemoryand verify the call stack, then step through the post-call path to confirm clean return to the timer dispatcher.
References
- Ekko — Original timer-based sleep obfuscation by C5pider
- Foliage — APC-based sleep obfuscation with per-frame RSP isolation
- Havoc Demon Obf.c — Production implementation demonstrating the VirtualProtect (Ekko) vs NtProtectVirtualMemory (Foliage) design choice
- WID_LoadLibrary — Reverse engineering of the Windows loader pipeline
- Windows vs Linux Loader Architecture — Detailed analysis of ntdll loader internals
- Elastic protections-artifacts — Detection rules for callback-based API abuse
Disclaimer
This tool is provided for educational purposes and authorized security research only. The techniques described are intended to advance understanding of sleep obfuscation constraints and defensive detection opportunities. Misuse of this information is solely the responsibility of the user.
Also Claude helped me write ~95% of this documentation. If there's errors, let me know.