Thread start address inspection: how EDR kernel drivers catch shellcode
PsSetCreateThreadNotifyRoutine gives you a kernel callback for every thread creation in the system. You get the PID, TID, and a Create flag. With those two IDs you can open the thread, query its start address, and inspect the backing memory region. An RWX private allocation as a start address is a high-confidence shellcode indicator. A mapped image is normal.
This post walks the full detection logic, fixes a type bug in the reference implementation, and covers where it falls apart against start-address spoofing.
For loading a kernel driver to test this, see Walking a driver’s IOCTL dispatch by hand.
The callback
Registration is a single call from DriverEntry:
NTSTATUS status = PsSetCreateThreadNotifyRoutine(ThreadNotifyCallback);Deregistration in DriverUnload:
PsRemoveCreateThreadNotifyRoutine(ThreadNotifyCallback);The callback signature:
VOID ThreadNotifyCallback(HANDLE ProcessId, HANDLE ThreadId, BOOLEAN Create);Create is TRUE on creation, FALSE on termination. ProcessId and ThreadId are IDs, typed as HANDLE by convention. They are not handles you can close or pass directly to thread/process APIs. Use them to fill a CLIENT_ID and open a real handle. The callback fires synchronously in the context of the thread that called the thread-creation syscall, before the new thread starts executing.
That last point matters: at callback time, the new thread’s start address is already set in the ETHREAD structure, but the thread hasn’t run yet. You’re inspecting the intent, not the execution.
The inspection pipeline
The implementation
Full detection callback. Uses documented WDK types throughout.
#include <ntddk.h>
#include <ntstrsafe.h>
// ZwOpenThread is not in the standard DDK headers.
// It is an Nt/Zw-layer export from ntoskrnl.
NTSYSCALLAPI
NTSTATUS
NTAPI
ZwOpenThread(
OUT PHANDLE ThreadHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN PCLIENT_ID ClientId
);
// ThreadBasicInformation is class 0.
// The structure is stable across Windows versions.
// Class 0 returns exactly these 6 fields; no start address.
typedef struct _THREAD_BASIC_INFORMATION {
NTSTATUS ExitStatus;
PVOID TebBaseAddress;
CLIENT_ID ClientId;
KAFFINITY AffinityMask;
KPRIORITY Priority;
KPRIORITY BasePriority;
} THREAD_BASIC_INFORMATION, *PTHREAD_BASIC_INFORMATION;
#ifndef THREAD_QUERY_INFORMATION
#define THREAD_QUERY_INFORMATION 0x0040
#endif
// Called by ZwQueryVirtualMemory; defined in wdm.h but sometimes missing
// from older DDK installs. Include here for clarity.
#ifndef MEM_IMAGE
#define MEM_IMAGE 0x01000000
#endif
#ifndef MEM_PRIVATE
#define MEM_PRIVATE 0x00020000
#endif
VOID
ThreadNotifyCallback(
HANDLE ProcessId,
HANDLE ThreadId,
BOOLEAN Create
)
{
if (!Create) {
return;
}
NTSTATUS status;
HANDLE hThread = NULL;
OBJECT_ATTRIBUTES oa;
CLIENT_ID cid;
THREAD_BASIC_INFORMATION tbi = { 0 };
ULONG retLen = 0;
InitializeObjectAttributes(&oa, NULL, OBJ_KERNEL_HANDLE, NULL, NULL);
cid.UniqueProcess = ProcessId;
cid.UniqueThread = ThreadId;
status = ZwOpenThread(&hThread,
THREAD_QUERY_INFORMATION,
&oa,
&cid);
if (!NT_SUCCESS(status)) {
// Thread may have exited before we got here; not unusual.
return;
}
// ThreadBasicInformation (class 0) fills ExitStatus, TebBaseAddress,
// ClientId, AffinityMask, Priority, BasePriority. No start address.
status = ZwQueryInformationThread(hThread,
ThreadBasicInformation,
&tbi,
sizeof(tbi),
&retLen);
if (!NT_SUCCESS(status)) {
ZwClose(hThread);
return;
}
// ThreadQuerySetWin32StartAddress (class 9) returns a single PVOID:
// the Win32StartAddress field from the kernel's ETHREAD structure. This is the value to inspect.
PVOID startAddr = NULL;
status = ZwQueryInformationThread(hThread,
(THREADINFOCLASS)9, // ThreadQuerySetWin32StartAddress
&startAddr,
sizeof(startAddr),
&retLen);
if (!NT_SUCCESS(status)) {
ZwClose(hThread);
return;
}
// ZwQueryVirtualMemory takes the *process* handle, not the thread handle.
// Open a handle to the owning process to query its VAD.
HANDLE hProcess = NULL;
OBJECT_ATTRIBUTES poa;
CLIENT_ID pcid;
InitializeObjectAttributes(&poa, NULL, OBJ_KERNEL_HANDLE, NULL, NULL);
pcid.UniqueProcess = ProcessId;
pcid.UniqueThread = NULL;
// ZwOpenProcess is available in the kernel; use PROCESS_VM_READ-equivalent
// class (PROCESS_QUERY_INFORMATION covers virtual memory queries).
status = ZwOpenProcess(&hProcess,
PROCESS_QUERY_INFORMATION | PROCESS_VM_READ,
&poa,
&pcid);
if (!NT_SUCCESS(status)) {
ZwClose(hThread);
return;
}
MEMORY_BASIC_INFORMATION mbi = { 0 };
SIZE_T mbiSize = 0;
status = ZwQueryVirtualMemory(hProcess,
startAddr,
MemoryBasicInformation,
&mbi,
sizeof(mbi),
&mbiSize);
if (!NT_SUCCESS(status)) {
ZwClose(hProcess);
ZwClose(hThread);
return;
}
// Core heuristic: RWX + private allocation = high-confidence shellcode
if (mbi.State == MEM_COMMIT &&
(mbi.Type == MEM_PRIVATE) &&
(mbi.Protect & PAGE_EXECUTE_READWRITE))
{
DbgPrint("[edr] HIGH: RWX private start addr PID=%lu TID=%lu addr=%p\n",
(ULONG)(ULONG_PTR)ProcessId,
(ULONG)(ULONG_PTR)ThreadId,
startAddr);
// Terminate the process. See note below on ObOpenObjectByPointer.
TerminateOwningProcess(ProcessId);
}
else if (mbi.State == MEM_COMMIT &&
(mbi.Type == MEM_PRIVATE) &&
(mbi.Protect & (PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_WRITECOPY)) &&
!(mbi.Protect & PAGE_EXECUTE_READWRITE))
{
// Private executable but not RWX: lower confidence, log only.
// PAGE_EXECUTE (0x10), PAGE_EXECUTE_READ (0x20), PAGE_EXECUTE_WRITECOPY (0x80)
// are mutually exclusive protection values, not bitmask flags.
// Checking each explicitly is required; PAGE_EXECUTE_READ does not
// have bit 0x10 set, so (Protect & PAGE_EXECUTE) alone misses RX pages.
DbgPrint("[edr] MED: private exec start addr PID=%lu TID=%lu addr=%p\n",
(ULONG)(ULONG_PTR)ProcessId,
(ULONG)(ULONG_PTR)ThreadId,
startAddr);
}
ZwClose(hProcess);
ZwClose(hThread);
}Fixing the termination bug
The reference driver calls ZwTerminateProcess(targetProcess, STATUS_SUCCESS) where targetProcess is a PEPROCESS. ZwTerminateProcess takes a HANDLE, not a kernel object pointer. Passing a PEPROCESS as a HANDLE will crash or silently fail, depending on whether the value happens to be a valid handle index.
The correct pattern uses ObOpenObjectByPointer to convert the kernel object to a handle first:
NTSTATUS
TerminateOwningProcess(HANDLE ProcessId)
{
PEPROCESS proc = NULL;
HANDLE hProc = NULL;
NTSTATUS status;
status = PsLookupProcessByProcessId(ProcessId, &proc);
if (!NT_SUCCESS(status)) {
return status;
}
// Convert PEPROCESS -> HANDLE.
// OBJ_KERNEL_HANDLE: keeps the handle in the kernel handle table,
// safe to use at any IRQL without attaching to the target process.
status = ObOpenObjectByPointer(
proc,
OBJ_KERNEL_HANDLE,
NULL,
PROCESS_TERMINATE,
*PsProcessType,
KernelMode,
&hProc);
// ObOpenObjectByPointer calls ObReferenceObject internally; the
// reference from PsLookupProcessByProcessId is still ours to release.
ObDereferenceObject(proc);
if (!NT_SUCCESS(status)) {
return status;
}
status = ZwTerminateProcess(hProc, STATUS_SUCCESS);
ZwClose(hProc);
return status;
}Same issue in TerminateThreadById in the original: ZwTerminateThread(targetThread, ...) with a PETHREAD. Apply the same fix using PsThreadType instead of PsProcessType.
Why the ZwQueryVirtualMemory call uses the process handle
One subtlety in the code above: ZwQueryVirtualMemory takes a process handle, not a thread handle. The reference implementation passes threadHandle as the first argument. That fails with a type mismatch error. ZwQueryVirtualMemory performs an object type check in the kernel; passing an ETHREAD when it expects an EPROCESS returns an error status. Thread handles don’t represent address spaces; process handles do. Pass the process handle.
What this catches
The detection fires on threads whose start address is in an MEM_PRIVATE + PAGE_EXECUTE_READWRITE region. That covers:
VirtualAllocEx+CreateRemoteThreadwith a default RWX allocation- Early Bird APC shellcode in a freshly-allocated RWX region (the APC function pointer becomes the effective start address when queried via
ThreadQuerySetWin32StartAddress) - Reflective DLL injection into a manually-mapped private region with RWX permissions
- Process hollowing variants that set the thread start directly to shellcode
What it misses:
- Shellcode in RX (not RWX) private regions. The attacker allocates as
PAGE_EXECUTE_READafter writing. Common in more careful loaders. - Shellcode written into an existing mapped image region (text-section stomp).
mbi.Typewill beMEM_IMAGE, notMEM_PRIVATE. The start address looks like a legitimate module. - Any technique that doesn’t put the start address in suspicious memory, because the payload is reached via a different path after the first instruction runs.
That last category is where start-address spoofing lives.
Start-address spoofing
The start address the EDR inspects via ThreadBasicInformation is the initial Rip value that will be set when the thread is first scheduled. An attacker can set this to a legitimate function, LoadLibraryA or RtlUserThreadStart, and park the actual payload elsewhere.
The thread starts executing at the spoofed address. The EDR callback sees LoadLibraryA in kernel32.dll (a MEM_IMAGE region), concludes it’s normal, and moves on. The payload reaches execution through:
- An ROP chain: the spoofed function returns into gadgets that pivot to the shellcode
- An APC queued to the new thread before it runs
- A hardware breakpoint set on the spoofed start address that redirects execution when the debugger is the attacker
- The spoofed function being a legitimate loader (
LoadLibraryA) that loads a DLL whoseDllMaincontains the payload
What catches it
NtQueryInformationThread with class ThreadQuerySetWin32StartAddress (9) returns the Win32StartAddress field from the kernel’s ETHREAD structure. ThreadBasicInformation (class 0) does not return a start address; its six fields cover exit status, TEB pointer, client ID, affinity, and priorities only. To get the kernel-level initial Rip, read CONTEXT.Rip via NtGetContextThread at callback time before the thread is scheduled. On a spoofed thread these two values diverge: ThreadQuerySetWin32StartAddress may still show the real payload address (if the attacker only overwrote the initial Rip and not the ETHREAD field), while CONTEXT.Rip shows the spoofed function.
In practice, spoofed threads often leave ThreadQuerySetWin32StartAddress pointing at the shellcode, because the spoofing trick only overwrites the initial Rip, not the ETHREAD field. Inspecting both catches a meaningful fraction of spoofed starts.
The harder case: comparing the start address to the actual first instruction the thread will execute by reading CONTEXT.Rip at creation time via NtGetContextThread. At callback time, the thread hasn’t been scheduled yet, so Rip reflects the intended first instruction. If the EDR captures this before the thread runs and verifies it against the module list, it sees through ROP entry-point spoofing.
// At callback time, before the thread is scheduled:
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_CONTROL;
// Requires a handle with THREAD_GET_CONTEXT access.
status = ZwGetContextThread(hThread, &ctx);
if (NT_SUCCESS(status)) {
// ctx.Rip is the actual first instruction.
// Compare against module list to check if it's image-backed.
CheckAddressAgainstModuleList(ctx.Rip, ProcessId);
}ZwGetContextThread is callable from kernel mode on a thread that hasn’t run. If ctx.Rip points into a private region while ThreadQuerySetWin32StartAddress returns a legitimate function, you have a spoofed thread.
Build and load
The driver targets Windows 10 1903+ (build 18362). Earlier builds need no changes for the detection logic itself, but ExAllocatePool2 (used in the full driver) requires 2004+. Use ExAllocatePoolWithTag(NonPagedPool, ...) on older builds.
INF snippet for test-signing:
[Version]
Signature="$WINDOWS NT$"
Class=System
ClassGuid={4D36E97D-E325-11CE-BFC1-08002BE10318}
Provider=%ManufacturerName%
DriverVer=02/03/2025,1.0.0.0
CatalogFile=ThreadEDR.cat
[DestinationDirs]
DefaultDestDir = 12
[DefaultInstall.ntamd64]
CopyFiles=Drivers_Dir
[Drivers_Dir]
ThreadEDR.sys
[DefaultInstall.ntamd64.Services]
AddService = ThreadEDR, 0x00000002, Service_Inst
[Service_Inst]
DisplayName = %ServiceDesc%
ServiceType = 1 ; SERVICE_KERNEL_DRIVER
StartType = 3 ; SERVICE_DEMAND_START
ErrorControl = 1 ; SERVICE_ERROR_NORMAL
ServiceBinary = %12%\ThreadEDR.sys
[Strings]
ManufacturerName = "Test"
ServiceDesc = "Thread start address EDR"Build with WDK 10.0.22621 targeting x64 Release:
msbuild ThreadEDR.vcxproj /p:Configuration=Release /p:Platform=x64Sign and load on a test machine with test-signing mode enabled (bcdedit /set testsigning on):
signtool sign /v /s TestCertStoreName /n "TestCert" ThreadEDR.sys
sc create ThreadEDR type= kernel binPath= "C:\path\ThreadEDR.sys"
sc start ThreadEDRValidate with WinDbg’s !process 0 0 and !thread to confirm the callback fires. DbgPrint output appears in DebugView or a kernel debugger.
HVCI note
The detection logic itself (no memory writes, no function pointer patching) survives HVCI. PsSetCreateThreadNotifyRoutine, ZwOpenThread, ZwQueryInformationThread, ZwQueryVirtualMemory, and ObOpenObjectByPointer are all documented kernel APIs with no write-side requirements. The termination path calls ZwTerminateProcess, also write-free from the kernel’s perspective.
If you extend this to inline-hook any notification routine or write into another driver’s memory, HVCI will stop you. The callback-registration approach doesn’t touch that.