|Informative Information for the Uninformed
The use of function pointers to indirectly transfer control of execution from one location to another is used extensively by the Windows kernel. Like the function prologue overwrite described in 2.1.1, the act of hooking a function by altering a function pointer is an easy way to intercept future calls to a given function. The difference, however, is that hooking a function by altering a function pointer will only intercept indirect calls made to the hooked function through the function pointer. Though this may seem like a fairly significant limitation, even these restrictions do not drastically limit the set of function pointers that can be abused to provide a kernel-mode backdoor.
The concept itself should be simple enough. All that's necessary is to modify the contents of a given function pointer to point at untrusted code. When the function is invoked through the function pointer, the untrusted code is executed instead. If the untrusted code wishes to be able to call the function that is being hooked, it can save the address that is stored in the function pointer prior to overwriting it. When possible, hooking a function through a function pointer is a simple and elegant solution that should have very little impact on the stability of the system (with obvious exception to the quality of the replacement function).
Regardless of what approach is taken to hook a function, an obvious question is where the backdoor code associated with a given hook function should be placed. There are really only two general memory locations that the code can be stored. It can either stored in user-mode, which would generally make it specific to a given process, or kernel-mode, which would make it visible system wide. Deciding which of the two locations to use is a matter of determining the contextual restrictions of the function pointer being leveraged. For example, if the function pointer is called through at a raised IRQL, such as DISPATCH, then it is not possible to store the hook function's code in pageable memory. Another example of a restriction is the process context in which the function pointer is used. If a function pointer may be called through in any process context, then there are only a finite number of locations that the code could be placed in user-mode. It's important to understand some of the specific locations that code may be stored in
Perhaps the most obvious location that can be used to store code that is to execute in kernel-mode is the kernel pools, such as the PagedPool and NonPagedPool, which are used to store dynamically allocated memory. In some circumstances, it may also be possible to store code in regions of memory that contain code or data associated with device drivers. While these few examples illustrate that there is certainly no shortage of locations in which to store code, there are a few locations in particular that are worth calling out.
One such location is composed of a single physical page that is shared between user-mode and kernel-mode. This physical page is known as SharedUserData and it is mapped into user-mode as read-only and kernel-mode as read-write. The virtual address that this physical page is mapped at is static in both user-mode (0x7ffe0000) and kernel-mode (0xffdf0000) on all versions of Windows NT+6. There is also plenty of unused memory within the page that is allocated for SharedUserData. The fact that the mapping address is static makes it a useful location to store small amounts of code without needing to allocate additional storage from the paged or non-paged pool.
Though the SharedUserData mapping is quite useful, there is actually an alternative location that can be used to store code that is arguably more covert. This approach involves overwriting a function pointer with the address of some code from the virtual mapping of the native DLL, ntdll.dll. The native DLL is special in that it is the only DLL that is guaranteed to be mapped into the context of every process, including the System process. It is also mapped at the same base address in every process due to assumptions made by the Windows kernel. While these are useful qualities, the best reason for using the ntdll.dll mapping to store code is that doing so makes it possible to store code in a process-relative fashion. Understanding how this works in practice requires some additional explanation.
The native DLL, ntdll.dll, is mapped into the address space of the System process and subsequent processes during kernel and process initialization, respectively. This mapping is performed in kernel-mode by nt!PspMapSystemDll. One can observe the presence of this mapping in the context of the System process through a debugger as shown below. These same basic steps can be taken to confirm that ntdll.dll is mapped into other processes as well7 :
kd> !process 0 0 System PROCESS 81291660 SessionId: none Cid: 0004 Peb: 00000000 ParentCid: 0000 DirBase: 00039000 ObjectTable: e1000a68 HandleCount: 256. Image: System kd> !process 81291660 PROCESS 81291660 SessionId: none Cid: 0004 Peb: 00000000 ParentCid: 0000 DirBase: 00039000 ObjectTable: e1000a68 HandleCount: 256. Image: System VadRoot 8128f288 Vads 4 ... kd> !vad 8128f288 VAD level start end commit ... 81207d98 ( 1) 7c900 7c9af 5 Mapped Exe kd> dS poi(poi(81207d98+0x18)+0x24)+0x30 e13591a8 "\WINDOWS\system32\ntdll.dll"
To make use of the ntdll.dll mapping as a location in which to store code, one must understand the implications of altering the contents of the mapping itself. Like all other image mappings, the code pages associated with ntdll.dll are marked as Copy-on-Write (COW) and are initially shared between all processes. When data is written to a page that has been marked with COW, the kernel allocates a new physical page and copies the contents of the shared page into the newly allocated page. This new physical page is then associated with the virtual page that is being written to. Any changes made to the new page are observed only within the context of the process that is making them. This behavior is why altering the contents of a mapping associated with an image file do not lead to changes appearing in all process contexts.
Based on the ability to make process-relative changes to the ntdll.dll mapping, one is able to store code that will only be used when a function pointer is called through in the context of a specific process. When not called in a specific process context, whatever code exists in the default mapping of ntdll.dll will be executed. In order to better understand how this may work, it makes sense to walk through a concrete example.
In this example, a rootkit has opted to create a backdoor by overwriting the function pointer that is used when dispatching IRPs using the IRP_MJ_FLUSH_BUFFERS major function for a specific device object. The prototype for the function that handles IRP_MJ_FLUSH_BUFFERS IRPs is shown below:
NTSTATUS DispatchFlushBuffers( IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp);
In order to create a context-specific backdoor, the rootkit has chosen to overwrite the function pointer described above with an address that resides within ntdll.dll. By default, the rootkit wants all processes except those that are aware of the backdoor to simply have a no-operation occur when IRP_MJ_FLUSH_BUFFERS is sent to the device object. For processes that are aware of the backdoor, the rootkit wants arbitrary code execution to occur in kernel-mode. To accomplish this, the function pointer should be overwritten with an address that resides in ntdll.dll that contains a ret 0x8 instruction. This will simply cause invocations of IRP_MJ_FLUSH_BUFFERS to return (without completing the IRP). The location of this ret 0x8 should be in a portion of code that is rarely executed in user-mode. For processes that wish to execute arbitrary code in kernel-mode, it's as simple as altering the code that exists at the address of the ret 0x8 instruction. After altering the code, the process only needs to issue an IRP_MJ_FLUSH_BUFFERS through the FlushFileBuffers function on the affected device object. The context-dependent execution of code is made possible by the fact that, in most cases, IRPs are processed in the context of the requesting process.
The remainder of this subsection will describe specific function pointers that may be useful targets for use as backdoors. The authors have tried to cover some of the more intriguing examples of function pointers that may be hooked. Still, it goes without saying that there are many more that have not been explicitly described. The authors would be interested to hear about additional function pointers that have unique and useful properties in the context of a local kernel-mode backdoor.