
The previous blog post discussed the issue of obtaining SSDT and ShadowSSDT table addresses under Windows 11. In this blog post, we will talk about the traversal of these two tables and the issue of Hook detection.
Structure of SSDT and ShadowSSDT Tables
Both tables use the same data structure, just with different names. The specific structure is represented in C language as follows:
- typedef struct _KSYSTEM_SERVICE_TABLE {
- PUCHAR ServiceTableBase;
- PULONG Count;
- ULONG_PTR TableSize;
- PUCHAR ArgumentTable;
- } KSYSTEM_SERVICE_TABLE, *PKSYSTEM_SERVICE_TABLE;
- typedef struct _KSERVICE_TABLE_DESCRIPTOR
- {
- KSYSTEM_SERVICE_TABLE ntoskrnl;
- KSYSTEM_SERVICE_TABLE win32k;
- KSYSTEM_SERVICE_TABLE notUsed1;
- KSYSTEM_SERVICE_TABLE notUsed2;
- } KSERVICE_TABLE_DESCRIPTOR, *PKSERVICE_TABLE_DESCRIPTOR;
Here, _KSERVICE_TABLE_DESCRIPTOR corresponds to nt!KeServiceDescriptorTable and nt!KeServiceDescriptorTableShadow. And nt!KiServiceTable generally points to the starting address of _KSYSTEM_SERVICE_TABLE, that is, ServiceTableBase.
This is still too abstract, let’s look at a few practical examples.
- kd> .process /i fffffa80060a7b30
- You need to continue execution (press 'g' <enter>) for the context
- to be switched. When the debugger breaks in again, you will be in
- the new process context.
- kd> g
- Break instruction exception - code 80000003 (first chance)
- nt!RtlpBreakWithStatusInstruction:
- fffff800`03cd1490 cc int 3
- kd> !process fffffa80060a7b30 0
- PROCESS fffffa80060a7b30
- SessionId: 0 Cid: 0138 Peb: 7fffffde000 ParentCid: 0130
- DirBase: 9e3fa000 ObjectTable: fffff8a006353010 HandleCount: 408.
- Image: csrss.exe
- kd> dps nt!KeServiceDescriptorTable L10
- // typedef struct _KSERVICE_TABLE_DESCRIPTOR {
- fffff800`03f0a840 fffff800`03cda300 nt!KiServiceTable // KSYSTEM_SERVICE_TABLE ntoskrnl.ServiceTableBase;
- fffff800`03f0a848 00000000`00000000 // KSYSTEM_SERVICE_TABLE ntoskrnl.Count;
- fffff800`03f0a850 00000000`00000191 // KSYSTEM_SERVICE_TABLE ntoskrnl.TableSize;
- fffff800`03f0a858 fffff800`03cdaf8c nt!KiArgumentTable // KSYSTEM_SERVICE_TABLE ntoskrnl.ArgumentTable;
- fffff800`03f0a860 00000000`00000000 // KSYSTEM_SERVICE_TABLE win32k.ServiceTableBase;
- fffff800`03f0a868 00000000`00000000 // KSYSTEM_SERVICE_TABLE win32k.Count;
- fffff800`03f0a870 00000000`00000000 // KSYSTEM_SERVICE_TABLE win32k.TableSize;
- fffff800`03f0a878 00000000`00000000 // KSYSTEM_SERVICE_TABLE win32k.ArgumentTable;
- fffff800`03f0a880 fffff800`03cda300 nt!KiServiceTable // KSYSTEM_SERVICE_TABLE notUsed1.ServiceTableBase;
- fffff800`03f0a888 00000000`00000000 // KSYSTEM_SERVICE_TABLE notUsed1.Count;
- fffff800`03f0a890 00000000`00000191 // KSYSTEM_SERVICE_TABLE notUsed1.TableSize;
- fffff800`03f0a898 fffff800`03cdaf8c nt!KiArgumentTable // KSYSTEM_SERVICE_TABLE notUsed1.ArgumentTable;
- fffff800`03f0a8a0 fffff960`00161f00 // KSYSTEM_SERVICE_TABLE notUsed2.ServiceTableBase;
- fffff800`03f0a8a8 00000000`00000000 // KSYSTEM_SERVICE_TABLE notUsed2.Count;
- fffff800`03f0a8b0 00000000`0000033b // KSYSTEM_SERVICE_TABLE notUsed2.TableSize;
- fffff800`03f0a8b8 fffff960`00163c1c // KSYSTEM_SERVICE_TABLE notUsed2.ArgumentTable;
- // } KSERVICE_TABLE_DESCRIPTOR, *PKSERVICE_TABLE_DESCRIPTOR;
- kd> dps nt!KeServiceDescriptorTableShadow L10
- // typedef struct _KSERVICE_TABLE_DESCRIPTOR {
- fffff800`03f0a880 fffff800`03cda300 nt!KiServiceTable // KSYSTEM_SERVICE_TABLE ntoskrnl.ServiceTableBase;
- fffff800`03f0a888 00000000`00000000 // KSYSTEM_SERVICE_TABLE ntoskrnl.Count;
- fffff800`03f0a890 00000000`00000191 // KSYSTEM_SERVICE_TABLE ntoskrnl.TableSize;
- fffff800`03f0a898 fffff800`03cdaf8c nt!KiArgumentTable // KSYSTEM_SERVICE_TABLE ntoskrnl.ArgumentTable;
- fffff800`03f0a8a0 fffff960`00161f00 // KSYSTEM_SERVICE_TABLE win32k.ServiceTableBase;
- fffff800`03f0a8a8 00000000`00000000 // KSYSTEM_SERVICE_TABLE win32k.Count;
- fffff800`03f0a8b0 00000000`0000033b // KSYSTEM_SERVICE_TABLE win32k.TableSize;
- fffff800`03f0a8b8 fffff960`00163c1c // KSYSTEM_SERVICE_TABLE win32k.ArgumentTable;
- fffff800`03f0a8c0 00000000`77c51206 // KSYSTEM_SERVICE_TABLE notUsed1.ServiceTableBase;
- fffff800`03f0a8c8 00000000`00000000 // KSYSTEM_SERVICE_TABLE notUsed1.Count;
- fffff800`03f0a8d0 fffff800`00a06428 // KSYSTEM_SERVICE_TABLE notUsed1.TableSize;
- fffff800`03f0a8d8 fffff800`00a063d8 // KSYSTEM_SERVICE_TABLE notUsed1.ArgumentTable;
- fffff800`03f0a8e0 00000000`00000002 // KSYSTEM_SERVICE_TABLE notUsed2.ServiceTableBase;
- fffff800`03f0a8e8 00000000`00008600 // KSYSTEM_SERVICE_TABLE notUsed2.Count;
- fffff800`03f0a8f0 00000000`000d79f0 // KSYSTEM_SERVICE_TABLE notUsed2.TableSize;
- fffff800`03f0a8f8 00000000`00000000 // KSYSTEM_SERVICE_TABLE notUsed2.ArgumentTable;
- // } KSERVICE_TABLE_DESCRIPTOR, *PKSERVICE_TABLE_DESCRIPTOR;
Since we want to view ShadowSSDT, we first use .process to switch to the process context of fffffa80060a7b30 (a program with SessionID), otherwise the nt!KeServiceDescriptorTableShadow we see may not be valid data.
In the above text, based on the table structure, we can already get the ServiceTableBase address. So how do we get the actual address of each function under this Base?
Calculating the Address of Each Function in the Table
If you are in an x64 environment and you can’t wait to look at the content at ServiceTableBase, you will find that it does not match the real function address. The reason for this phenomenon will be mentioned later in this article. Let’s start with the simplest situation.
x86 Environment
The situation is simplest in the x86 environment. You will find that the address at ServiceTableBase is the address of the corresponding function, whether you look at it statically or dynamically:

Observe the same value and symbol in the debugger:

Due to different base addresses, take NtAccessCheck function as an example. The value in the static environment is 0x00471b6c, loaded at the base address 0x00400000. The value in the dynamic environment is 0x820d5b6c, loaded at the base address 0x82064000.
The values of the two environments are converted into RVA respectively, which can be known:
- 0x00471b6c - 0x00400000 = 0x820d5b6c - 0x08206400 = 0x71b6c
Although the specific values of KiServiceTable in the static and dynamic environments are different (this is caused by relocation), it can be known from the above RVA calculation that the values at their memory locations can be matched. In the end, we can conclude that KiServiceTable directly stores the target function address in the x86 environment.
x64 Environment
Microsoft made adjustments in the x64 environment. In the static environment, in the lower version of the kernel (Windows7, 8, 8.1) and x86, the function address is directly stored. But in the higher version of the kernel (Windows 10 and above), KiServiceTable will no longer directly store the function address, and the data length has also changed from the original 4 bytes to 8 bytes.
The biggest adjustment is the dynamic environment. When using the debugger for kernel debugging, you will find that no matter the high version or the low version, the value at KiServiceTable is not the same as the static file anyway. This is mainly because the kernel will “compress” the value at KiServiceTable, and the compression action is completed by the nt!KeCompactServiceTable function, which we will discuss later in the text.
Next, let’s look at the situation of high and low versions separately.
Low Version Kernel
The following picture is a screenshot of KiServiceTable under Windows 7 x64, which shows that it is still storing the function address. The current loading base address is 0x00000001`40000000.

Then observe the dynamic value at KiServiceTable after the system is running:

If we try to convert the dynamic and static addresses into RVA using the method in the previous section, we will find that no matter what, we cannot get the same value. This is what was said at the beginning of this section: Since the value at the dynamic address has been processed by the nt!KeCompactServiceTable function, it can no longer calculate the same RVA.
High Version Kernel
If you open a high version kernel (such as Windows 10 x64) and locate KiServiceTable, you will find that things are not simple. At this time, IDA can’t even “correctly” identify the functions in the SSDT table, and the data looks like every 4 bytes are a group, which is quite different from the situation where every 8 bytes are a group in the low version:

Here I won’t take a screenshot of the value at KiServiceTable in the debugging state, you just need to know that it is consistent with the situation in the low version: it can’t be matched, that’s it.
So, how do we get the correct function address?
KeCompactServiceTable Function
The above mentioned that the static and dynamic KiServiceTable values cannot correspond to each other in the x64 environment. The ultimate reason for this problem is that the system uses the nt!KeCompactServiceTable function to “compress” the value at this address during the startup phase. Next, let’s look at the decompiled code of this function under Windows 7 x64:
- unsigned int __fastcall KeCompactServiceTable(
- char *KiServiceTablePtr,
- char *ArgumentTable,
- unsigned int limit,
- boolean a4)
- {
- size_t v5; // r10
- DWORD KiServiceTableBase; // ebx
- char *PKiServiceTablePtr; // rdx
- __int64 v8; // r11
- DWORD ValueOfServiceTable; // er8
- unsigned int result; // eax
- v5 = limit;
- KiServiceTableBase = (unsigned int)KiServiceTablePtr;
- PKiServiceTablePtr = KiServiceTablePtr;
- if ( limit )
- {
- v8 = limit;
- do
- {
- ValueOfServiceTable = *(_DWORD *)PKiServiceTablePtr;
- PKiServiceTablePtr += 8;
- result = (unsigned __int8)*ArgumentTable++ >> 2;
- *(_DWORD *)KiServiceTablePtr = result | (16 * (ValueOfServiceTable - KiServiceTableBase));
- KiServiceTablePtr += 4;
- --v8;
- }
- while ( v8 );
- }
- if ( a4 == 1 )
- return (unsigned int)memmove(KiServiceTablePtr, PKiServiceTablePtr, v5);
- return result;
- }
The above logic is described more concisely as follows:
- ULONG_PTR Index = 0, TableSize = 0, ServiceTableBase = 0, ArgumentTable = 0;
- TableSize = ServiceDescriptorTable->ntoskrnl.TableSize;
- ServiceTableBase = ServiceDescriptorTable->ntoskrnl.ServiceTableBase;
- ArgumentTable = ServiceDescriptorTable->ntoskrnl.ArgumentTable;
- for (Index = 0; Index < TableSize; ++Index)
- {
- UHALF_PTR FunctionCookie = 0;
- PUHALF_PTR Pointer = ServiceTableBase + Index * 4;
- UINT8 ArgumentCookie = *(PUCHAR)(ArgumentTable + Index);
- FunctionCookie = (UHALF_PTR)*(PUHALF_PTR)(ServiceTableBase + Index * 8) - (UHALF_PTR)ServiceTableBase;
- *Pointer = (16 * FunctionCookie) | (ArgumentCookie >> 2);
- }
One thing to note is: In the system version of Windows 10 x64 and above, there are 2 important differences:
- The original data at KiServiceTable has changed from 8 bytes to 4 bytes
- Introduced kernel loading base address for calculation
You can disassemble the code of KeCompactServiceTable by yourself. In short, after the reverse is completed, the 13th line of the above code should be (it may be slightly different, but the calculation result should be equivalent) under Windows 10 x64:
- FunctionCookie = (UHALF_PTR)*(PUHALF_PTR)(ServiceTableBase + Index * 4) \
- - (UHALF_PTR)ServiceTableBase + (UHALF_PTR)NewImageBase;
Real Function Address Calculation
x86
According to the analysis above, in the x86 environment, take out the value at the corresponding table, and directly add the ntoskrnl.exe module base address to get the original address of the table item, so the formula is:
FunctionAddress = NtoskrnlBase + *(PULONG_PTR)(ServiceTableBase + Index * 4)
x64
First of all, the overall formula, under the x64 system, regardless of the high and low, the calculation formula of the corresponding function of each table item in all versions of SSDT is:
FunctionAddress = HIWORD(KiServiceTableBase)<<32 + (UHALF_PTR)((UHALF_PTR)KiServiceTableBase + (UHALF_PTR)FunctionCookie)
Note that UHALF_PTR is 4 bytes long in x64, don’t bring the high bit when calculating. Next, let’s manually calculate the address with examples of Windows 7 x64 and Windows 10 x64.
Windows 7 x64 Calculation Example
First of all, Windows 7 x64, recycle a screenshot from the above text:

According to the above calculation formula (note that the data type is UHALF_PTR under 64-bit, which is 4 bytes):
- KiServiceTableBase = 0x40071B00
- FunctionCookie = 0x40482190 - 0x40071B00 = 0x410690
- FunctionAddress = 0x1`00000000 + 0x40071B00 + 0x410690 = 0x140482190
The result is the address of the NtMapUserPhysicalPagesScatter function.
Windows 10 x64 Calculation Example
Let’s take a look at the calculation under Windows 10 x64, and continue to recycle the screenshot above:

Here you need to note that each table item on Windows 10 x64 has changed to 4 bytes instead of 8 bytes on Windows 7 x64:
- KiServiceTableBase = 0x402EE800
- FunctionCookie = 0x000E54B4 - 0x402EE800 + 0x40000000 = 0xFFDF6CB4
- FunctionAddress = 0x1`00000000 + 0x402EE800 + 0xFFDF6CB4 = 0x1400e54b4
Let’s see what is at the address 0x1400e54b4 under Windows 10 x64:

The high bit addresses of the two functions on the left and right of the above picture are different, because the real kernel loaded in memory has undergone address relocation.
Hook Detection
In fact, knowing the calculation method of the real address, you only need to calculate the value at the original address according to the following ideas, and then compare it with the value at the same offset in the current kernel. If the value is different, it has been Hooked:
- Reload a kernel
- According to the logic of the nt!KeCompactServiceTable function, correct the value of each table item in the first step of KiServiceTable
- Compare the reloaded kernel’s KiServiceTable table items with the values at the same offset in the current kernel
- The different ones are the functions that have been Hooked
Here I won’t post the specific code, just give a screenshot after verification:
