Module 1: EDR Call Stack Analysis
Every API call leaves a trail of return addresses. EDRs read that trail like a forensic report.
Module Objective
Before understanding how Draugr spoofs the call stack, you need to understand what EDRs look at when your code makes a sensitive API call. This module covers kernel callbacks, stack walking, backed vs unbacked memory, and why shellcode can never hide from call stack inspection — unless you construct synthetic frames.
1. What EDRs See When Code Runs
Modern EDR products don't just scan files on disk. They install kernel-mode drivers that subscribe to a set of notification callbacks built into the Windows kernel. These callbacks fire in the context of the calling thread, giving the EDR direct access to the thread's user-mode stack.
Key Kernel Callbacks
| Callback Registration | Triggers On | What EDR Inspects |
|---|---|---|
PsSetCreateProcessNotifyRoutine | Process creation / exit | Parent PID, image path, command line |
PsSetCreateThreadNotifyRoutine | Thread creation / exit | Start address, thread context |
PsSetLoadImageNotifyRoutine | DLL / image load | Image name, base address, size |
ObRegisterCallbacks | Handle operations (open/duplicate) | Desired access mask, target process |
CmRegisterCallbackEx | Registry operations | Key path, value data |
The critical detail: when ObRegisterCallbacks fires because your code called NtOpenProcess with PROCESS_ALL_ACCESS, the callback executes on your thread. The EDR kernel driver can read your thread's user-mode stack directly from kernel mode. It doesn't need to inject anything — it's already running in your context.
Why This Matters for Attackers
You cannot avoid these callbacks by being clever in user mode. They fire in Ring 0. The only question is: what does the EDR see when it inspects your call stack? If it sees return addresses pointing into unbacked heap memory, you are caught. Draugr exists to control exactly what the EDR finds when it walks your stack.
2. Stack Walking with RtlWalkFrameChain
When the kernel callback fires, the EDR calls one of two APIs to capture the current thread's stack frames:
C (Kernel Mode)// Captures an array of return addresses from the current call stack
ULONG RtlWalkFrameChain(
PVOID *Callers, // Output: array of return addresses
ULONG Count, // Max frames to capture
ULONG Flags // 0 = kernel frames, 1 = user frames
);
// Alternative: captures from the calling thread's stack
USHORT RtlCaptureStackBackTrace(
ULONG FramesToSkip,
ULONG FramesToCapture,
PVOID *BackTrace,
PULONG BackTraceHash
);
Both functions return an array of return addresses. Each return address is the instruction pointer (RIP) that will be returned to when the current function completes. The EDR iterates through this array and, for each address, determines which module it belongs to.
Stack Walk Resolution
(kernel context)
(capture return addrs)
find owning module
unbacked memory?
The EDR checks each return address against the list of loaded modules (from the PEB's InMemoryOrderModuleList or via MmGetSystemAddressForMdl). If a return address falls within ntdll.dll, kernel32.dll, or kernelbase.dll, that's expected. If it falls in a region that isn't mapped to any file on disk, that's a problem.
3. Backed vs Unbacked Memory
This distinction is the foundation of call stack-based detection:
Backed Memory (Legitimate)
- Memory-mapped from a file on disk (DLL, EXE, SYS)
- Has a corresponding entry in the VAD tree with a file object
- Examples: ntdll.dll .text section, kernel32.dll code
- Return addresses here are expected and normal
- EDR can verify the on-disk file matches the in-memory content
Unbacked Memory (Suspicious)
- Allocated with
VirtualAlloc/NtAllocateVirtualMemory - No file mapping — exists only in RAM
- Examples: shellcode buffers, BOF memory, injected code
- Return addresses here indicate code running from dynamic allocation
- Primary indicator of malicious in-memory execution
When the EDR walks your call stack and finds a return address like 0x000001A4F2C01337, it checks: does this address fall within any loaded module's address range? If not, it checks the VAD (Virtual Address Descriptor) tree. If the VAD entry shows MEM_PRIVATE with no file backing, the verdict is clear: code is executing from unbacked memory.
Call Stack Comparison: Legitimate vs Suspicious
Legitimate Call Stack
Suspicious Call Stack
4. What a Legitimate Call Stack Looks Like
Every Windows thread begins execution at the same two functions. This is hardcoded into the operating system's thread initialization path and is the same for every process on every Windows version:
Call Stack// Every normal thread in Windows starts with this exact chain:
ntdll!RtlUserThreadStart ; Thread entry point (set by kernel)
ntdll!BaseThreadInitThunk ; Called by RtlUserThreadStart (kernel32 forwarded)
application!actual_function ; The function passed to CreateThread()
... ; Whatever the function calls
When a thread calls NtAllocateVirtualMemory, the EDR expects to see a call stack that terminates with RtlUserThreadStart at the bottom and BaseThreadInitThunk above it. These two functions are the anchor frames that prove the thread was legitimately created by the OS.
The Standard Thread Init Chain
| Frame | Function | Module | Role |
|---|---|---|---|
| Bottom | RtlUserThreadStart | ntdll.dll | Kernel sets RIP to this on thread creation |
| Bottom + 1 | BaseThreadInitThunk | kernel32.dll | Calls the user-supplied thread start routine |
| ... | user code | varies | The actual application logic |
| Top | current function | varies | Where execution is right now |
EDRs validate this pattern. If the bottom two frames are missing, if they're in the wrong order, or if there's unbacked memory between the Nt* call and BaseThreadInitThunk, the call stack is flagged as anomalous.
5. The Problem for Shellcode and BOFs
When shellcode or a Beacon Object File calls a sensitive API like NtAllocateVirtualMemory, the call stack reveals the truth:
Suspicious Stack// Shellcode calling NtAllocateVirtualMemory via indirect syscall:
ntdll!NtAllocateVirtualMemory ; syscall stub (return addr in ntdll = good)
0x00000234A8001200 ; UNBACKED - shellcode called the stub
0x00000234A8000050 ; UNBACKED - shellcode entry point
; Missing: RtlUserThreadStart, BaseThreadInitThunk!
Indirect Syscalls Do NOT Solve This
Even with indirect syscalls (jumping to the syscall instruction inside ntdll), the caller's return address is still in unbacked memory. The syscall itself originates from ntdll (passing InstrumentationCallback checks), but the call stack one frame up reveals unbacked memory. EDR kernel callbacks see the entire stack, not just the top frame.
This is the fundamental problem. Every technique that runs code from dynamically allocated memory — shellcode, BOFs, reflective DLLs — produces a call stack with unbacked return addresses. Direct syscalls, indirect syscalls, and even simple function pointer calls all have the same weakness: the return address chain exposes the true origin of the call.
Why Indirect Syscalls Still Fail Stack Analysis
unbacked memory
syscall;ret
(InstrumentationCB: PASS)
unbacked caller
(Stack analysis: FAIL)
6. ETW and Kernel-Level Telemetry
Beyond call stack analysis, EDRs also leverage Event Tracing for Windows (ETW) and the Microsoft-Windows-Threat-Intelligence (EtwTi) provider. This kernel-level telemetry is separate from stack walking and monitors specific API patterns:
Key Telemetry Sources for EDRs
| Source | What It Reports | Draugr Relevance |
|---|---|---|
| EtwTi Provider | VirtualAlloc with RWX, cross-process writes, handle operations | Draugr doesn't suppress ETW — it only fixes the call stack |
| Kernel Callbacks | Process/thread creation, image loads, handle ops | These are where stack walking occurs |
| InstrumentationCallback | Validates syscall return addresses | Solved by indirect syscall (return addr in ntdll) |
| AMSI / Script Logging | PowerShell, .NET, script content scanning | Not relevant — BOFs are native code |
It is important to understand that Draugr addresses a specific detection surface: call stack analysis during kernel callbacks. It does not suppress ETW telemetry, avoid kernel callback registration, or interfere with other detection layers. A comprehensive evasion strategy would combine Draugr's stack spoofing with additional techniques for ETW and other telemetry sources.
Key Terminology
| Term | Definition |
|---|---|
| Backed Memory | Virtual memory region mapped from a file on disk (DLL, EXE, SYS). Has a file object in the VAD tree. |
| Unbacked Memory | Virtual memory allocated with VirtualAlloc/NtAllocateVirtualMemory. No file mapping — exists only in RAM. |
| VAD Tree | Virtual Address Descriptor tree — kernel data structure tracking all virtual memory regions in a process. |
| Synthetic Frame | A stack frame manually constructed to mimic a real function call, without an actual CALL instruction having occurred. |
| Anchor Frames | RtlUserThreadStart and BaseThreadInitThunk — the two bottom frames every Windows thread should have. |
| Stack Walking | The process of iterating through return addresses on the stack to reconstruct the call chain. |
7. What Draugr Does
The Draugr Solution: Synthetic Stack Frames
Draugr constructs synthetic stack frames on the stack before executing a syscall. These frames make the call stack look exactly like what a normal Windows thread produces:
Spoofed Stack (what the EDR sees)ntdll!NtAllocateVirtualMemory ; The actual syscall
kernel32!BaseThreadInitThunk ; SYNTHETIC FRAME (Draugr placed this)
ntdll!RtlUserThreadStart ; SYNTHETIC FRAME (Draugr placed this)
; Clean termination - looks like normal thread init
The frames are "synthetic" because no real CALL instruction created them. Draugr manually writes return addresses and adjusts RSP so that the stack walker's unwind logic produces exactly the right sequence. The key insight: the stack walker doesn't verify that a CALL actually occurred — it just follows RUNTIME_FUNCTION metadata to compute frame sizes and locate return addresses.
Draugr's approach is fundamentally different from timer-based stack spoofing (like ThreadStackSpoofer). Instead of spoofing the stack only during sleep, Draugr spoofs the stack at the exact moment of the sensitive API call — when the kernel callback fires and the EDR inspects the stack.
Draugr vs Other Approaches
| Technique | When Stack is Spoofed | Survives Kernel Callback? |
|---|---|---|
| ThreadStackSpoofer | During sleep only | No — stack is real during API calls |
| SilentMoonWalk | During API call | Yes — synthetic frames at call time |
| Draugr | During API call | Yes — synthetic frames at call time |
| LayeredSyscall | Inherently real stack | Yes — uses real API chain |
Module 1 Quiz: EDR Call Stack Analysis
Q1: Why can EDR kernel callbacks inspect a user-mode thread's call stack?
Q2: What is the primary indicator EDRs use to detect shellcode during call stack analysis?
Q3: What two functions form the expected bottom of every normal Windows thread's call stack?