Windows Kernel Introspection (WKI)
Table of contents
Introduction
Over the last few years that I spent learning more and more about Microsoft Windows, it has been more and more apparent that studying the NT kernel is an incredibly deep and vast subject, nevertheless particularly interesting. A lot of research exists online and Windows Internals books are probably the best allies for this journey.
No matter how much I love and respect these books, from my perspective, they serve as references and cannot really be used as proper kernel programming books. Therefore, reading about the Memory Manager (MM) facility of the NT kernel and how the Virtual Address Descriptors (VADs) are abstractions of the Page Table Entries (PTEs) for address translation will never really click until put into application and practical experimentation have been conducted.
However, when it comes to experimenting with the NT kernel, there is a lot of frustrating things to face. One of them being the fact that a large portion of the kernel is not exposed to developers, because routines are not exported or structures not defined. In a way it does make sense, with the example of the Memory Manager, due to its criticality, allowing drivers to mess with it can be risky for the stability, performances and integrity of the whole system. Therefore, the headers and documentation provided by the Windows Driver Kit (WDK) will not cover all sets of routines, global variables, callbacks, structures and more.
One question arise - It is possible to experiment with information provided by the various research and books when they are not provided with the WDK? The answer is of course “yes”, otherwise this blog post would be pointless. The remaining of this post will try answer this question more in depth by providing a solution.
The Proof of Concept (PoC) code can be found at the following URL:
If you wish to try the code, bear in mind that this is a PoC and that it is highly encouraged to read the code and test the driver within a virtual machine.
The following diagram shows the overall idea:
User-Mode Application
One way to get pointers within the kernel of a routine or a global variable is to do memory scanning or signature scanning. This is how many rootkit or game cheat-engine do it. The downside of that is that it can lead to a lot of issues related to memory access violation and that signatures can change from one version to another of the NT kernel. As a result, this cannot really be used for a reliable and robust solution.
Fortunately, Microsoft is kind enough to provide Program Database (PDB) files for the various version of the NT kernel (i.e., ntoskrnl.exe
) and therefore they can be download from their symbol server and then parsed to extract whatever is required.
In the PoC proposed, the Microsoft Debug Interface Access (DIA) SDK is used in order to automatise the retrieval and the parsing of the PDB. The process is fairly simple.
First, the symsrv.dll
module will be used to get the hash of the file and to download from the symbol server the appropriate PDB file.
Second, once the PDB file is on disk, other MS DIA APIs will be used to parse all the public symbols in order to retrieve the desired symbols and their relevant data (i.e., Relative Virtual Address, offset, segment and name). Note that, for easier identification of the name of the symbol in the kernel, the name of the symbol will be hashed instead of being stored as-is.
// Initialise COM runtime and get IDiaDataSource interface.
wprintf(L"[*] Initialise MSDIA \r\n");
EXIT_ON_FAILURE(DiaInitialise(L"msdia140.dll"));
// Load the data from the PDB
wprintf(L"[*] Open PDB from EXE: C:\\WINDOWS\\System32\\ntoskrnl.exe\r\n");
EXIT_ON_FAILURE(DiaLoadDataFromPdb(L"C:\\WINDOWS\\System32\\ntoskrnl.exe"));
// Parse all public symbols
PUBLIC_SYMBOL Symbols[] = {
// Memory Manager variables
ADD_TABLE_ENTRY(L"ExPoolTagTables"),
ADD_TABLE_ENTRY(L"PoolTrackTableSize"),
ADD_TABLE_ENTRY(L"ExpPoolBlockShift"),
ADD_TABLE_ENTRY(L"PoolTrackTableExpansion"),
ADD_TABLE_ENTRY(L"PoolTrackTableExpansionSize"),
// General kernel info
ADD_TABLE_ENTRY(L"KeNumberProcessors")
};
EXIT_ON_FAILURE(DiaFindPublicSymbols(Symbols, _ARRAYSIZE(Symbols)));
Finally, data collected will be stored within the Windows Registry. The reason is because it is one of the easiest way to store information that can later easily accessible to the kernel driver.
_Use_decl_annotations_
HRESULT STDMETHODCALLTYPE RegistryAddSymbols(
_In_ PUBLIC_SYMBOL Symbols[],
_In_ DWORD Entries
) {
if (g_Symbols == INVALID_HANDLE_VALUE || Entries == 0x00 || Symbols == NULL)
return E_FAIL;
// Parse all the entries
DWORD ValidEntries = 0x00;
for (DWORD cx = 0x00; cx < Entries; cx++) {
// If symbol has not been resolved, go to next entry
if (Symbols[cx].dwRVA == 0x00)
continue;
ValidEntries++;
// Create new key
HKEY CurrentKey = INVALID_HANDLE_VALUE;
LSTATUS Status = ERROR_SUCCESS;
RtlCreateOrOpenKey(Status, g_Symbols, Symbols[cx].Name, &CurrentKey);
wprintf(L"\\HKLM\\%s\\%s\\%s\\%s\r\n", REGISTRY_BASE_KEY, g_VersionString, REGISTRY_SYMBOLS_KEY, Symbols[cx].Name);
// Add information
RegSetKeyValueW(CurrentKey, NULL, L"RVA", REG_DWORD, &Symbols[cx].dwRVA, sizeof(DWORD));
RegSetKeyValueW(CurrentKey, NULL, L"OFF", REG_DWORD, &Symbols[cx].dwOff, sizeof(DWORD));
RegSetKeyValueW(CurrentKey, NULL, L"SEG", REG_DWORD, &Symbols[cx].dwSeg, sizeof(DWORD));
// Get the hash of the name
DWORD Hash = RtlGetHash(Symbols[cx].Name);
RegSetKeyValueW(CurrentKey, NULL, L"DJB", REG_DWORD, &Hash, sizeof(DWORD));
RegCloseKey(CurrentKey);
}
// Set the total number of entries
RegSetKeyValueW(g_BuilKey, NULL, L"NumberOfSymbols", REG_DWORD, &ValidEntries, sizeof(DWORD));
return S_OK;
}
Example of execution of the user-mode PoC on a Microsoft Windows 20H2 OS.
Example of the data stored within the Windows Registry.
Kernel-Mode Driver
This solution relies mostly on the user-mode application that will download parse and store the information from the PDB. The kernel driver has only two little steps in order to be operational.
First, the driver needs to find the current version of the system and open an handle to the Symbol
registry key, in order to parse it and retrieved all the data. Information will then be stored internally in a double-linked list for ease of usage.
Second, because of Address Space Layout Randomisation (ASLR) and more specifically Kernel ASLR, the relative virtual addresses (RVA) and the offset of each symbol are - by nature - relative and thus meaningless without the base address of the section (for offsets) and base address of the kernel driver (for RVA). To retrieve such information, one kernel shim routine set (i.e., aux_lib.h
) can be used to query list of all modules (aka drivers) loaded in the kernel memory pool, and, find the base address from the extended information provided by the same routines.
_Use_decl_annotations_
EXTERN_C NTSTATUS WkipGetSystemImageBase(
VOID
) {
// Ensure current IRQL allow paging.
PAGED_CODE();
// Initialise auxiliary kernel library
AuxKlibInitialize();
ULONG BufferSize = 0x00;
NTSTATUS Status = AuxKlibQueryModuleInformation(&BufferSize, sizeof(AUX_MODULE_EXTENDED_INFO), NULL);
if (NT_ERROR(Status) || BufferSize == 0x00)
return STATUS_UNSUCCESSFUL;
PAUX_MODULE_EXTENDED_INFO ExtendedInfo = ExAllocatePool2(POOL_FLAG_PAGED, BufferSize, WKI_MM_TAG);
if (ExtendedInfo == NULL)
return STATUS_NO_MEMORY;
Status = AuxKlibQueryModuleInformation(&BufferSize, sizeof(AUX_MODULE_EXTENDED_INFO), (PVOID)ExtendedInfo);
// Parse all modules
for (UINT32 cx = 0x00; cx < (BufferSize / sizeof(AUX_MODULE_EXTENDED_INFO)); cx++) {
if (strcmp("\\SystemRoot\\system32\\ntoskrnl.exe", (CONST PCHAR)ExtendedInfo[cx].FullPathName) == 0x00) {
WkiGlobal.KernelBase = (UINT64)ExtendedInfo[cx].BasicInfo.ImageBase;
break;
}
}
NT_ASSERT(WkiGlobal.KernelBase != 0x00);
ExFreePoolWithTag((PVOID)ExtendedInfo, WKI_MM_TAG);
return STATUS_SUCCESS;
}
Example of usage within the driver to retrieve two symbols and validation via WinDBG:
WkiInitialise()
PVOID MiState = WkiGetSymbol("MiState")
PVOID MmPteBase = WkiGetSymbol("MmPteBase");
WkiUninitialise();
Example: Listing Kernel Memory Pool Tag
Above sections are related to the overall architecture of the solution. In this section a concrete use case will be shown, in this case by listing kernel memory pool tags.
My personal approach with such things, is to look at both Windows Internals books and into what WinDBG offers. WinDBG comes with a lot of extensions and utility tools that can be used for both user-mode and kernel-mode debugging. In this case, reverse engineering with IDA the kernel extensions (i.e., kdexts.dll
) can provide valuable information regarding the various routines, structures and global variables used to do something.
By looking at the export table, it is fairly easy to see that names of functions available within WinDBG shell are also names of the exports.
When looking further at the code it is possible to understand that WinDBG is actually applying the same logic. It leverages PDBs and symbols to get information that would normally not be accessible to developers.
Routines like ExtensionApis.lpGetExpressionRoutine
will search the symbol from the PDB to get the pointer. The other routines like ReadPointer
, GetUlongValue
are fairly self-explanatory and are used in conjunction with the previous routine to get values from pointers.
For the sake of brevity, and also because this is not the primarily objective of this blog post, the full analysis of the poolused
extension will not be provided. However, by reverse engineering this routine, it was possible to find all the symbols required in order to list the kernel memory pools and how these symbols should be used to collect various information.
For example, to get the number of processors, the following code can be used in the kernel driver:
// Get the number of processors on the system.
PVOID XxKeNumberProcessors = WkiGetSymbol("KeNumberProcessors");
if (XxKeNumberProcessors == NULL) {
KiDebug(("Error \"KeNumberProcessors\" symbol not found.\r\n"));
return;
}
UINT64 PoolTableEntries = 0x01;
PoolTableEntries = WkiReadValue(XxKeNumberProcessors, sizeof(UINT32));
After putting everything together it is possible to reliably and safely leverage symbols to complete the task:
Final Thoughts
First of all, my apologies to Tim Misiak’s team for the blatant plagiarism of the output and logic of kernel memory pool tag example.
Second of all, this is a proof of concept and it is mostly likely certain that a better and more dynamic approach could be implemented. However, by lack of time this is what I decided to publish for now.
Third of all, an internet connection is required in order to be able to download the PDB and extract the desired symbols. For restricted environment this may be an issue and an offline approach would be preferable. In the future I may update the code to support this approach by feeding the driver with a file containing the offsets for different NT kernel versions.
Finally, even if a much larger set of data is available when using PDB files it does not mean that everything is available. In this example, the PoC was related to ntoskrnl, which contains most of the kernel executive subsystem. If the interest it towards functionalities within a different device driver, it may not have a PDB available and consequently this solution will not be helpful.