Finding EPROCESS from the GS register
Every EPROCESS walk I’ve seen starts from PsInitialSystemProcess. That requires a clean export table and a way to resolve it – either MmGetSystemRoutineAddress, a pattern scan, or loading a copy of ntoskrnl.exe into user space. All of those paths depend on something you may not have.
There is another anchor. The GS register in kernel mode always points to the current processor’s KPCR. It’s always valid. KASLR doesn’t affect it. And the path from KPCR to the current process is a handful of fixed offsets.
The GS register in kernel mode
On x64 Windows, the kernel uses GS as a per-processor data pointer. In user mode, GS points to the TEB. The switch isn’t automatic: the kernel’s own entry stub executes swapgs, which exchanges the current GS base with the value parked in IA32_KERNEL_GS_BASE (MSR 0xC0000102). So after a syscall lands in the kernel and the entry stub has run swapgs, GS points to the KPCR for the current processor, and the exit path swaps it back before returning to user mode.
The gap matters if you’re writing anything that runs at the very edge of that transition. Between the syscall instruction and the swapgs in the entry stub, you are executing in ring 0 with GS still pointing at the user-mode TEB. Ordinary driver code never observes that window, but it’s the reason “GS always points to the KPCR in kernel mode” is a statement about normal driver context rather than a hardware invariant.
IA32_GS_BASE is MSR 0xC0000101. It holds the virtual address that the CPU uses as the GS segment base. In kernel mode, that value is the KPCR virtual address for whichever CPU is currently executing.
From kernel code, you don’t read the MSR directly. The WDK exposes __readgsqword(offset) as a compiler intrinsic that generates a single mov rax, gs:[offset] instruction. Reading the KPCR pointer itself:
PKPCR pcr = (PKPCR)__readgsqword(FIELD_OFFSET(KPCR, Self));That’s the approach KeGetCurrentPcr() uses internally. The Self field at KPCR+0x18 is a pointer back to the KPCR’s own base address.
Reading GS base from kernel code
If you’re writing a driver and you want the KPCR VA explicitly, the easiest path is the Self field via __readgsqword:
#include <intrin.h>
#include <ntddk.h>
ULONG_PTR GetKpcrVa(void)
{
/* KPCR.Self is at +0x18 -- returns the KPCR base VA */
return (ULONG_PTR)__readgsqword(0x18);
}There’s no function call, no lock, no exported symbol. The CPU fetches it directly from the GS-relative address. On any processor, in any interrupt or DPC context, this returns the KPCR for that specific CPU.
If you’re reading GS base from outside the kernel (for example, through an arbitrary read primitive), MSR 0xC0000101 gives you the same value. From kernel mode using the __readmsr intrinsic:
ULONG64 kpcr_va = __readmsr(0xC0000101); /* IA32_GS_BASE */Both paths give you the KPCR virtual address. The MSR path is more useful when you’re validating the value out-of-band.
KPCR layout
The full KPCR struct is documented in the WDK but its interesting fields are at fixed, stable offsets:
| Offset | Field | Type |
|---|---|---|
+0x00 | GdtBase (union with NT_TIB.ExceptionList) | PKGDTENTRY64 |
+0x08 | TssBase | PKTSS64 |
+0x10 | UserRsp | ULONG64 |
+0x18 | Self | PKPCR |
+0x20 | CurrentPrcb | PKPRCB (pointer to embedded PRCB) |
+0x180 | embedded KPRCB begins |
The embedded KPRCB starts at KPCR+0x180. The KPRCB struct is not exposed in public WDK headers, but its first significant field is CurrentThread at KPRCB+0x8. That gives:
KPCR + 0x180 = &KPRCB
KPCR + 0x188 = KPRCB.CurrentThread (pointer to current KTHREAD)The WDK makes this explicit. wdm.h implements KeGetCurrentThread() as:
PKTHREAD KeGetCurrentThread(VOID)
{
return (struct _KTHREAD *)__readgsqword(0x188);
}One instruction. GS+0x188 is the current thread pointer. Always, in kernel mode, on every CPU, in every context.
The chain: KPCR to EPROCESS
From the current KTHREAD, the current process is one more offset away. KTHREAD.ApcState is an embedded KAPC_STATE struct. The public WDK headers don’t expose the _KTHREAD layout, but ApcState sits at KTHREAD+0x98 across modern Windows 10 and 11 builds (confirmed via dt nt!_KTHREAD in WinDbg). KAPC_STATE.Process is a KPROCESS * at KAPC_STATE+0x20 (after the two LIST_ENTRY ApcListHead[2] entries at +0x00 and +0x10, which ntifs.h exposes). That makes KTHREAD.ApcState.Process land at:
KTHREAD + 0x98 + 0x20 = KTHREAD + 0xB8KPROCESS is always the first field in EPROCESS, so the pointer you get is PEPROCESS. No cast games needed.
Full chain in C:
#include <intrin.h>
#include <ntddk.h>
PEPROCESS GetCurrentEprocess(void)
{
/* GS+0x188 = KPRCB.CurrentThread = current KTHREAD */
PKTHREAD thread = (PKTHREAD)__readgsqword(0x188);
/* KTHREAD+0xB8 = ApcState.Process = current KPROCESS/EPROCESS */
return *(PEPROCESS *)((ULONG_PTR)thread + 0xB8);
}No exports. No pattern scan. No PsInitialSystemProcess. The CPU hands you the answer.
There’s also a documented API for this exact operation:
PEPROCESS current = PsGetCurrentProcess();That function resolves to the same GS-relative chain internally. The hand-rolled version above is only useful when you’re working without the normal driver infrastructure – an injected payload, a constrained environment, or when you don’t want to import anything.
The memory layout
Walking ActiveProcessLinks from the current process
Once you have a PEPROCESS, you can walk ActiveProcessLinks to reach any process. The ActiveProcessLinks offset varies by Windows build. From the symbol tables (Vergilius) for build 26100 (Windows 11 24H2), ActiveProcessLinks is at EPROCESS+0x1D8. The UniqueProcessId field is at EPROCESS+0x1D0.
#define EPROC_PID_OFFSET 0x1D0 /* EPROCESS.UniqueProcessId -- Win11 24H2 build 26100 */
#define EPROC_APL_OFFSET 0x1D8 /* EPROCESS.ActiveProcessLinks */
PEPROCESS FindProcessByPid(ULONG_PTR targetPid)
{
PEPROCESS current = GetCurrentEprocess();
PEPROCESS start = current;
do {
ULONG_PTR pid = *(ULONG_PTR *)((ULONG_PTR)current + EPROC_PID_OFFSET);
if (pid == targetPid)
return current;
PLIST_ENTRY apl = (PLIST_ENTRY)((ULONG_PTR)current + EPROC_APL_OFFSET);
current = (PEPROCESS)((ULONG_PTR)apl->Flink - EPROC_APL_OFFSET);
} while (current != start);
return NULL;
}The offsets for other Windows builds:
| Build range | UniqueProcessId | ActiveProcessLinks |
|---|---|---|
| Win7 SP1 (7601) | +0x180 | +0x188 |
| Win8 / Win8.1 (9200-9600) | +0x2E0 | +0x2E8 |
| Win10 TH1-TH2 + RS1 (10240-14393) | +0x2E8 | +0x2F0 |
| Win10 RS2-RS4 (15063-17134) | +0x2E0 | +0x2E8 |
| Win10 RS5 / 1809 (17763) | +0x2E0 | +0x2E8 |
| Win10 19H1-19H2 (18362-18363) | +0x2E8 | +0x2F0 |
| Win10 20H1 through Win11 23H2 (19041-22631) | +0x440 | +0x448 |
| Win11 24H2 (26100) | +0x1D0 | +0x1D8 |
The 24H2 offsets dropped significantly. Microsoft reorganised the EPROCESS layout in that build. The values above match the Vergilius symbol data and the EPROCESS offset table in the BYOVD independent pages post.
Treat that table as a snapshot, not an API. These offsets move between releases and occasionally within one, and a hardcoded table is exactly the thing that fails silently on a build you didn’t test: you don’t get an access violation, you get a plausible-looking value read from the wrong field. Resolve against Vergilius or the live symbols for whatever kernel you’re actually targeting. The +0x188 and +0xB8 offsets earlier in this post are a different proposition, since the first is baked into every compiled driver by the WDK’s own KeGetCurrentThread() and the second sits behind fixed-size fields.
Verify with WinDbg: Attach kernel debugger →
dt nt!_EPROCESS→ checkUniqueProcessIdandActiveProcessLinksoffsets match the table for your build. Cross-check with!process 0 0to confirm the walk reaches the expected processes.
Why this is KASLR-immune
KASLR randomises the load address of ntoskrnl.exe, driver images, and pool allocations. Every symbol-based route to EPROCESS has to defeat that first: resolve a base, then add an offset, then hope the export you wanted is still where you expect.
The GS route never asks the question. The KPCR’s own address varies from boot to boot like everything else, but you don’t need to know it, because the CPU is already holding it for you in the GS base. You’re not locating a structure, you’re dereferencing one the hardware hands over on every instruction that touches GS. The KPCR VA is also stable for the lifetime of the boot, so it doesn’t shift under you between context switches or interrupts.
The chain therefore works regardless of where ntoskrnl.exe loaded. You don’t need the kernel base. You don’t need any exported symbol. The CPU tells you where the current thread is.
When the export table isn’t an option
The usual alternative – MmGetSystemRoutineAddress to find PsInitialSystemProcess – requires the export table to be intact and accessible. In practice this is nearly always true for legitimate drivers.
There are scenarios where it isn’t:
- A constrained execution environment where import resolution is blocked or the
ntoskrnl.exeimage range isn’t accessible for parsing. - Post-exploitation payloads injected into kernel memory without a clean driver context.
- Environments where the export directory has been selectively tampered to block specific symbol lookups (a detection technique some hypervisor-based products use).
- Early boot environments before the export table is ready.
In any of those cases, GS+0x188 still works. The CPU always knows what thread it’s running, and the thread always knows what process it belongs to.
The tradeoff is the hard-coded offsets. KeGetCurrentThread() at GS+0x188 is stable because it’s implemented that way in the WDK itself and Microsoft can’t change it without breaking every compiled driver. KTHREAD.ApcState.Process at +0xB8 is stable across modern builds but not guaranteed across all of Windows history. EPROCESS.ActiveProcessLinks changes between major releases and needs build-specific values.
If you need PsGetCurrentProcess() to work, just call it. The GS-based chain is the same code path, wrapped. The raw version matters when you can’t call anything.