
Recently, a project required traversing SSDT and ShadowSSDT. The conventional way to obtain the starting addresses of these two tables is to use the readmsr(0xc0000082) method found online. This works fine from Windows 7 to Windows 10, but it was found that it does not work properly under Windows 11. There were no relevant discussions found online, so a simple study was conducted, resulting in this article.
Conventional Acquisition Method
The conventional method found online is to use the readmsr instruction to get the content of register 0xC0000082. The first result from a Google search is a tweet (reference 1):

The corresponding C code is roughly as follows:
- PKSERVICE_TABLE_DESCRIPTOR GetKeServiceDescriptorTableAddress(SEARCH_TABLE_TYPE SearchType)
- {
- PULONG_PTR KiSystemCall64 = NULL;
- PUCHAR CurrentAddr = NULL;
- KiSystemCall64 = (PVOID)__readmsr(0xC0000082);
- for (CurrentAddr = KiSystemCall64; CurrentAddr <= KiSystemCall64 + 1024; ++CurrentAddr)
- {
- switch (SearchType)
- {
- case KeServiceDescriptorTableType:
- if (0x4c == *CurrentAddr && 0x8d == *(PUCHAR)(CurrentAddr + 1) && 0x15 == *(PUCHAR)(CurrentAddr + 2))
- {
- UHALF_PTR Offset = *(PUHALF_PTR)(CurrentAddr + 3);
- ULONG_PTR Base = CurrentAddr + 7;
- return X64_ADDRESS_ADD(Base, Offset);
- }
- break;
- case KeServiceDescriptorTableShadowType:
- if (0x4c == *CurrentAddr && 0x8d == *(PUCHAR)(CurrentAddr + 1) && 0x1d == *(PUCHAR)(CurrentAddr + 2))
- {
- UHALF_PTR Offset = *(PUHALF_PTR)(CurrentAddr + 3);
- ULONG_PTR Base = CurrentAddr + 7;
- return X64_ADDRESS_ADD(Base, Offset);
- }
- break;
- }
- }
- }
As mentioned at the beginning of this article, this code works fine on systems below Windows 10. It can correctly obtain the addresses of KeServiceDescriptor and KeServiceDescriptorShadow tables, but it does not work properly under Windows 11.
Problem Exploration
Let’s first look at what value the readmsr instruction gets under Windows 11:
- 0: kd> vertarget
- Windows 10 Kernel Version 22000 MP (2 procs) Free x64
- Product: WinNt, suite: TerminalServer SingleUserTS
- Edition build lab: 22000.1.amd64fre.co_release.210604-1628
- Machine Name:
- Kernel base = 0xfffff803`14a00000 PsLoadedModuleList = 0xfffff803`15629710
- Debug session time: Mon Oct 31 12:30:14.915 2022 (UTC + 8:00)
- System Uptime: 0 days 0:17:39.229
- 0: kd> rdmsr 0xc0000082
- msr[c0000082] = fffff803`154b4180
- 0: kd> ln fffff803`154b4180
- Browse module
- Set bu breakpoint
- (fffff803`154b4180) nt!KiSystemCall64Shadow | (fffff803`154b5060) nt!_guard_retpoline_icall_handler
- Exact matches:
So we can confirm two points:
- Although Windows 11 is called 11, it still uses the Windows 10 kernel, confirming that it is a re-skinned operating system.
- Under Windows 11, the MSR[0xC0000082] register stores KiSystemCall64Shadow, while before Windows 11, it always stored KiSystemCall64.
The second point mentioned above is the direct reason why we cannot obtain the starting address of KeServiceDescriptor or KeServiceDescriptorShadow table through MSR[0xC0000082] under Windows 11. So, can we really not stably obtain the addresses of these two tables? The answer is no ~
Problem Solution
Let’s first take a look at some basic information of a few key data structures:
- 0: kd> rdmsr 0xc0000082
- msr[c0000082] = fffff803`154b4180
- 0: kd> ln fffff803`154b4180
- Browse module
- Set bu breakpoint
- (fffff803`154b4180) nt!KiSystemCall64Shadow | (fffff803`154b5060) nt!_guard_retpoline_icall_handler
- Exact matches:
- 0: kd> x nt!KiSystemCall64
- fffff803`14e24bc0 nt!KiSystemCall64 (KiSystemCall64)
- 0: kd> uf nt!KiSystemCall64Shadow
- Flow analysis was incomplete, some code may be missing
- nt!KiSystemServiceUser:
- fffff803`14e24e1a c645ab02 mov byte ptr [rbp-55h],2
- fffff803`14e24e1e 65488b1c2588010000 mov rbx,qword ptr gs:[188h]
- fffff803`14e24e27 0f0d8b90000000 prefetchw [rbx+90h]
- fffff803`14e24e2e 0fae5dac stmxcsr dword ptr [rbp-54h]
- ......
- nt!KiSystemServiceRepeat:
- fffff803`14e24ef4 4c8d15c5c99d00 lea r10,[nt!KeServiceDescriptorTable (fffff803`158018c0)]
- fffff803`14e24efb 4c8d1dfe208e00 lea r11,[nt!KeServiceDescriptorTableShadow (fffff803`15707000)]
- fffff803`14e24f02 f7437880000000 test dword ptr [rbx+78h],80h
- ......
- nt!KiSystemCall64Shadow+0x24c:
- fffff803`154b43cc 0faee8 lfence
- nt!KiSystemCall64Shadow+0x24f:
- fffff803`154b43cf 65c604255308000000 mov byte ptr gs:[853h],0
- fffff803`154b43d8 e93d0a97ff jmp nt!KiSystemServiceUser (fffff803`14e24e1a) Branch
- 0: kd> uf nt!KiSystemCall64
- Flow analysis was incomplete, some code may be missing
- nt!KiSystemCall64:
- fffff803`14e24bc0 0f01f8 swapgs
- fffff803`14e24bc3 654889242510000000 mov qword ptr gs:[10h],rsp
- fffff803`14e24bcc 65488b2425a8010000 mov rsp,qword ptr gs:[1A8h]
- ......
- fffff803`14e24ef4 4c8d15c5c99d00 lea r10,[nt!KeServiceDescriptorTable (fffff803`158018c0)]
- fffff803`14e24efb 4c8d1dfe208e00 lea r11,[nt!KeServiceDescriptorTableShadow (fffff803`15707000)]
- fffff803`14e24f02 f7437880000000 test dword ptr [rbx+78h],80h
- fffff803`14e24f09 7413 je nt!KiSystemServiceRepeat+0x2a (fffff803`14e24f1e) Branch
- ......
- fffff803`154b3e19 0f01f8 swapgs
- fffff803`154b3e1c 480f07 sysretq
We can see that at the end of the KiSystemCall64Shadow function, there is a very uncommon instruction: lfence. By querying this instruction, we know that its OP Code is a fixed value: 0x0faee8, as shown in the following figure (reference 2).

Not far after lfence, there is a je instruction, which jumps to: nt!KiSystemServiceUser (fffff803`14e24e1a).
Through windbg output, we can know the second important information: the address range of nt!KiSystemCall64 is from fffff80314e24bc0 to fffff803154b3e1c.
OK, at this point, we find that the last jump of KiSystemCall64Shadow will jump to a place not far from the beginning of KiSystemCall64 to continue execution. The traditional method of obtaining KeServiceDescriptorTable and KeServiceDescriptorTableShadow is actually to search for feature codes within the KiSystemCall64 function.
Therefore, we can locate the je instruction below based on the lfence instruction, calculate the jump address, and then use the traditional method of searching the KiSystemCall64 function to obtain the starting address of the table structure we need.
Coding Time
Without further ado, let’s slightly modify the code at the beginning of this article:
- PKSERVICE_TABLE_DESCRIPTOR GetKeServiceDescriptorTableAddress(SEARCH_TABLE_TYPE SearchType)
- {
- PULONG_PTR KiSystemCall64 = NULL;
- PUCHAR CurrentAddr = NULL;
- KiSystemCall64 = (PVOID)__readmsr(0xC0000082);
- for (CurrentAddr = KiSystemCall64; CurrentAddr <= KiSystemCall64 + 1024; ++CurrentAddr)
- {
- switch (SearchType)
- {
- case KeServiceDescriptorTableType:
- if (0x4c == *CurrentAddr && 0x8d == *(PUCHAR)(CurrentAddr + 1) && 0x15 == *(PUCHAR)(CurrentAddr + 2))
- {
- UHALF_PTR Offset = *(PUHALF_PTR)(CurrentAddr + 3);
- ULONG_PTR Base = CurrentAddr + 7;
- return X64_ADDRESS_ADD(Base, Offset);
- }
- break;
- case KeServiceDescriptorTableShadowType:
- if (0x4c == *CurrentAddr && 0x8d == *(PUCHAR)(CurrentAddr + 1) && 0x1d == *(PUCHAR)(CurrentAddr + 2))
- {
- UHALF_PTR Offset = *(PUHALF_PTR)(CurrentAddr + 3);
- ULONG_PTR Base = CurrentAddr + 7;
- return X64_ADDRESS_ADD(Base, Offset);
- }
- break;
- }
- // Windows 11, msr[0xC0000082] = nt!KiSystemCall64Shadow
- // We need search back
- // fffff803`154b43c8 4883c408 add rsp,8
- // fffff803`154b43cc 0faee8 lfence <-- all of these bytes
- // fffff803`154b43cf 65c604255308000000 mov byte ptr gs:[853h],0 <-- last byte
- // fffff803`154b43d8 e93d0a97ff jmp nt!KiSystemServiceUser (fffff803`14e24e1a) Branch <-- first 1 byte
- if (0x00e8ae0f == (UHALF_PTR)((*(PUHALF_PTR)CurrentAddr) & 0x00FFFFFF))
- {
- PUCHAR j = NULL;
- for (j = CurrentAddr; j < CurrentAddr + 100; ++j)
- {
- if (0xe9 == *j && 0x00 == *(j - 1))
- {
- UHALF_PTR Offset = *(PUHALF_PTR)(j + 1);
- CurrentAddr = X64_ADDRESS_ADD((ULONG_PTR)j, Offset);
- KiSystemCall64 = CurrentAddr;
- break;
- }
- }
- }
- }
- }
Verify the result, reload ntoskrnl.exe, and then verify whether the SSDT table is consistent before and after reloading:

No problem, job done~