Difficulty: Beginner

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 RegistrationTriggers OnWhat EDR Inspects
PsSetCreateProcessNotifyRoutineProcess creation / exitParent PID, image path, command line
PsSetCreateThreadNotifyRoutineThread creation / exitStart address, thread context
PsSetLoadImageNotifyRoutineDLL / image loadImage name, base address, size
ObRegisterCallbacksHandle operations (open/duplicate)Desired access mask, target process
CmRegisterCallbackExRegistry operationsKey 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

Callback fires
(kernel context)
RtlWalkFrameChain
(capture return addrs)
For each address:
find owning module
Check: backed or
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

ntdll!NtOpenProcess (backed: ntdll.dll)
KERNELBASE!OpenProcess (backed: kernelbase.dll)
kernel32!OpenProcessStub (backed: kernel32.dll)
myapp.exe!main (backed: myapp.exe)

Suspicious Call Stack

ntdll!NtOpenProcess (backed: ntdll.dll)
0x1A4F2C01337 (UNBACKED: VirtualAlloc)
0x1A4F2C00100 (UNBACKED: shellcode entry)

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

FrameFunctionModuleRole
BottomRtlUserThreadStartntdll.dllKernel sets RIP to this on thread creation
Bottom + 1BaseThreadInitThunkkernel32.dllCalls the user-supplied thread start routine
...user codevariesThe actual application logic
Topcurrent functionvariesWhere 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

Shellcode in
unbacked memory
JMP to ntdll
syscall;ret
syscall from ntdll
(InstrumentationCB: PASS)
Stack walk finds
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

SourceWhat It ReportsDraugr Relevance
EtwTi ProviderVirtualAlloc with RWX, cross-process writes, handle operationsDraugr doesn't suppress ETW — it only fixes the call stack
Kernel CallbacksProcess/thread creation, image loads, handle opsThese are where stack walking occurs
InstrumentationCallbackValidates syscall return addressesSolved by indirect syscall (return addr in ntdll)
AMSI / Script LoggingPowerShell, .NET, script content scanningNot 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

TermDefinition
Backed MemoryVirtual memory region mapped from a file on disk (DLL, EXE, SYS). Has a file object in the VAD tree.
Unbacked MemoryVirtual memory allocated with VirtualAlloc/NtAllocateVirtualMemory. No file mapping — exists only in RAM.
VAD TreeVirtual Address Descriptor tree — kernel data structure tracking all virtual memory regions in a process.
Synthetic FrameA stack frame manually constructed to mimic a real function call, without an actual CALL instruction having occurred.
Anchor FramesRtlUserThreadStart and BaseThreadInitThunk — the two bottom frames every Windows thread should have.
Stack WalkingThe 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

TechniqueWhen Stack is SpoofedSurvives Kernel Callback?
ThreadStackSpooferDuring sleep onlyNo — stack is real during API calls
SilentMoonWalkDuring API callYes — synthetic frames at call time
DraugrDuring API callYes — synthetic frames at call time
LayeredSyscallInherently real stackYes — 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?

Kernel callbacks like those registered via ObRegisterCallbacks execute on the same thread that triggered the event. Since the kernel has full access to the thread's user-mode stack, no injection or inter-process reads are needed. The EDR simply calls RtlWalkFrameChain with Flags=1 to walk user-mode frames.

Q2: What is the primary indicator EDRs use to detect shellcode during call stack analysis?

The primary signal from call stack analysis is return addresses in unbacked memory. Backed memory is mapped from a file on disk (DLLs, EXEs). Unbacked memory (VirtualAlloc'd regions) containing executable code with return addresses on the stack is the strongest indicator of in-memory shellcode or injected code.

Q3: What two functions form the expected bottom of every normal Windows thread's call stack?

Every Windows thread begins at ntdll!RtlUserThreadStart (the kernel sets the initial RIP to this function) which then calls kernel32!BaseThreadInitThunk, which in turn calls the user-supplied thread start routine. EDRs expect to see these two frames at the bottom of every thread's call stack. Draugr's synthetic frames replicate exactly this pattern.