开发技术讲究封装与模块化,安全技术强调底层安全性。安全技术需要打开封装、追根溯源!

《0day 安全:软件漏洞分析技术(第2版)》 第21章 探索 Ring0 笔记

Intel x86 系列处理器使用"环"的概念来实施访问控制,共有 4 个权限级别,由高到低分别为 Ring0、Ring1、Ring2、Ring3,其中 Ring0 权限最高,Ring3 权限最低。Windows(从 NT 开始)和 Linux 等多数操作系统在 Intel x86 处理器上只使用了 Ring0 和 Ring3,其中内核态对应着 Ring0,用户态对应着 Ring3。两个特权级足以实现操作系统的访问控制,况且之前支持的有些硬件体系结构(比如 Compaq Alpha 和 Silicon Graphics MIPS)只实现了两个特权级。本篇所讨论的内核程序漏洞特指 Ring0 程序中的能被利用的 bug 或缺陷。

一般地,操作系统的内核程序、驱动程序等都是在 Ring0 级别上运行的。此外,很多安全软件、游戏软件、工具软件等第三方驱动程序,也会通过系统服务的方式在 Ring0级别上运行。越来越多的病毒、木马、后门、恶意软件也有自己的驱动程序,想方设法的进入 Ring0,以求提高自身的运行权限,与安全软件进行对抗。

时至今日,Ring0 上运行的程序已经不再是单纯的系统内核,内核漏洞也不再是操作系统专属的问题,而是很多安全软件、游戏软件、工具软件等软件厂商共同需要面对的问题。

随着操作系统和安全软件的日益完善,在普通溢出漏洞难以奏效的情况下,容易被人忽略的内核漏洞往往可以作为突破安全防线的切入点。如果病毒木马加载了驱动或进入了 Ring0,是否还能够实施有效的防御呢?这是一个很有趣的问题,因为对抗的双方都处在系统最高权限,我们称之为"内核 PK",也许这种 PK 能成为今后的一个研究热点。

研究内核漏洞,需要首先掌握一些内核基础知识,例如内核驱动程序的开发、编译和运行,内核中重要的数据结构等,后面几节将对这些内容做简单的介绍。

驱动开发 helloworld

 /********************************************************************
created: 2010/12/06
filename: D:\0day\HelloWorld\helloworld.c
author: shineast
purpose: Hello world driver demo
*********************************************************************/
#include <ntddk.h>
#define DEVICE_NAME L"\\Device\\HelloWorld"
#define DEVICE_LINK L"\\DosDevices\\HelloWorld" PDEVICE_OBJECT g_DeviceObject; VOID DriverUnload( IN PDRIVER_OBJECT driverObject )
{
KdPrint(("DriverUnload: 88!\n"));
}
/**********************************************************************
驱动派遣例程函数
输入:驱动对象的指针,Irp指针
输出:NTSTATUS类型的结果
**********************************************************************/
NTSTATUS DrvDispatch(IN PDEVICE_OBJECT driverObject,IN PIRP pIrp)
{
KdPrint(("Enter DrvDispatch\n")); //设置IRP的完成状态
pIrp->IoStatus.Status=STATUS_SUCCESS; //设置IRP的操作字节数
pIrp->IoStatus.Information=; //完成IRP的处理
IoCompleteRequest(pIrp,IO_NO_INCREMENT);
return STATUS_SUCCESS;
} NTSTATUS DriverEntry( IN PDRIVER_OBJECT driverObject, IN PUNICODE_STRING registryPath )
{
NTSTATUS ntStatus;
UNICODE_STRING devName;
UNICODE_STRING symLinkName;
int i=;
//打印一句调试信息
KdPrint(("DriverEntry: Hello world driver demo!\n"));
//设置该驱动对象的卸载函数
driverObject->DriverUnload = DriverUnload;
//创建设备
RtlInitUnicodeString(&devName,DEVICE_NAME);
ntStatus = IoCreateDevice( driverObject, , &devName, FILE_DEVICE_UNKNOWN, , TRUE, &g_DeviceObject );
if (!NT_SUCCESS(ntStatus))
{
return ntStatus;
}
//创建符号链接
RtlInitUnicodeString(&symLinkName,DEVICE_LINK);
ntStatus = IoCreateSymbolicLink( &symLinkName,&devName );
if (!NT_SUCCESS(ntStatus))
{
IoDeleteDevice( g_DeviceObject );
return ntStatus;
}
//设置该驱动对象的派遣例程函数
for (i = ; i < IRP_MJ_MAXIMUM_FUNCTION; i++)
{
driverObject->MajorFunction[i] = DrvDispatch;
}
//返回成功结果
return STATUS_SUCCESS;
}

DriverEntry() 是驱动的入口函数,执行在 System 进程中。DriverEntry() 的返回值类型是 NTSTATUS,如果是 STATUS_SUCCESS,表示驱动加载成功;否则表示驱动加载失败,具体失败原因由相应的返回值来表述。helloworld 驱动在 DriverEntry 入口函数中依次完成了:驱动卸载函数的设置,驱动设备的创建,符号链接的创建,驱动派遣例程函数的设置。

驱动卸载,是驱动程序在被卸载时要调用的函数,如上面代码所示,驱动卸载函数被设置为 DriverUnload(),在 DriverUnload() 中其实并没有做什么,只是打印了一句话,相当于一个空函数。当然驱动卸载函数并不是必须要设置的,如果没有设置,驱动程序就无法被卸载。

驱动设备的创建和符号链接的创建,是为了能够在Ring3打开该设备对象,并和驱动进行通信。

驱动派遣例程函数,是 Ring3 向驱动发出不同类型的 I/O 请求,经过系统的"派遣"后,最终会调用到对应的驱动派遣例程函数来。

helloworld.c 写好后,同目录下还需要两个文件:makefile 文件和 sources 文件。makefile 文件的内容通常是固定的,如下所示:

 !IF 0
Copyright (C) Microsoft Corporation, 1999 - 2002
Module Name:
makefile.
Notes:
DO NOT EDIT THIS FILE!!! Edit .\sources. if you want to add a new source
file to this component. This file merely indirects to the real make file
that is shared by all the components of Windows NT (DDK)
!ENDIF
!INCLUDE $(NTMAKEENV)\makefile.def

sources 文件:

 TARGETNAME=helloworld
TARGETTYPE=DRIVER
SOURCES=helloworld.c

接下来使用 Windows Driver Kits - WDK 7600.16385.1 - Build Environment - Windows XP - x86 Checked Build Environment 来 build 驱动:

C:\Users\master\Desktop\helloword.sys>build
BUILD: Compile and Link for x86
BUILD: Loading c:\winddk\7600.16385.\build.dat...
BUILD: Computing Include file dependencies:
BUILD: Start time: Thu Nov ::
BUILD: Examining c:\users\eli\desktop\helloword.sys directory for files to compile.
BUILD: Saving c:\winddk\7600.16385.\build.dat...
BUILD: Compiling and Linking c:\users\eli\desktop\helloword.sys directory Configuring OACR for 'root:x86chk' - <OACR on> _NT_TARGET_VERSION SET TO WINXP
Compiling - helloworld.c
Linking Executable - objchk_wxp_x86\i386\helloworld.sys
BUILD: Finish time: Thu Nov ::
BUILD: Done files compiled - Warning - LPS
executable built

编译出的 helloworld.sys 需要作为内核模块来加载和运行。可以在用户态使用服务管理器创建一个服务,将 helloworld.sys 和服务关联在一起,通过启动服务向内核加载 helloworld.sys。这个过程也可以通过工具来完成,类似的驱动加载工具有很多,这里推荐 OSR Online 上的 OSRLOADER。

加载和启动 helloworld.sys 的过程中,可以使用 Sysinternals Suite 里的 DbgView(需要打开 Capture Kernel 开关)来查看 Kernel Debug 函数 KdPrint() 输出的信息。

派遣例程与 IRP 结构

IRP(I/O Request Package),输入/输出请求包。IRP的数据结构非常复杂,如果全部展示出来恐怕需要好几页的篇幅,这里只抄重点。

Ring3 通过 DeviceIoControl 等函数向驱动发出 I/O 请求后,在内核中由操作系统将其转化为 IRP 的数据结构,并"派遣"到对应驱动的派遣函数中。

Ring3 程序调用 kernel32.dll 导出的 DeviceIoControl() 函数后,会调用到 ntdll.dll 导出的 NtDeviceIoControlFile(),进而调用到系统内核模块提供的服务函数 NtDeviceIoControlFile(),该函数会将 I/O 请求转化为 IRP 包,并发送到对应驱动的派遣例程函数中。对于其他 I/O 相关函数,如 CreateFile、ReadFile、WriteFile、GetFileSize、SetFileSize、CloseHandle 等也是如此。

OD: Ring0 &amp; Kernel-LMLPHP

一个 IRP 包该发往驱动的哪个派遣例程函数,是由 IRP 结构中的 MajorFunction 属性决定的,MajorFunction属性的值是一系列宏,如下所示:

 #define IRP_MJ_CREATE                   0x00
#define IRP_MJ_CREATE_NAMED_PIPE 0x01
#define IRP_MJ_CLOSE 0x02
#define IRP_MJ_READ 0x03
#define IRP_MJ_WRITE 0x04
#define IRP_MJ_QUERY_INFORMATION 0x05
#define IRP_MJ_SET_INFORMATION 0x06
#define IRP_MJ_QUERY_EA 0x07
#define IRP_MJ_SET_EA 0x08
#define IRP_MJ_FLUSH_BUFFERS 0x09
#define IRP_MJ_QUERY_VOLUME_INFORMATION 0x0a
#define IRP_MJ_SET_VOLUME_INFORMATION 0x0b
#define IRP_MJ_DIRECTORY_CONTROL 0x0c
#define IRP_MJ_FILE_SYSTEM_CONTROL 0x0d
#define IRP_MJ_DEVICE_CONTROL 0x0e
#define IRP_MJ_INTERNAL_DEVICE_CONTROL 0x0f
#define IRP_MJ_SHUTDOWN 0x10
#define IRP_MJ_LOCK_CONTROL 0x11
#define IRP_MJ_CLEANUP 0x12
#define IRP_MJ_CREATE_MAILSLOT 0x13
#define IRP_MJ_QUERY_SECURITY 0x14
#define IRP_MJ_SET_SECURITY 0x15
#define IRP_MJ_POWER 0x16
#define IRP_MJ_SYSTEM_CONTROL 0x17
#define IRP_MJ_DEVICE_CHANGE 0x18
#define IRP_MJ_QUERY_QUOTA 0x19
#define IRP_MJ_SET_QUOTA 0x1a
#define IRP_MJ_PNP 0x1b
#define IRP_MJ_PNP_POWER IRP_MJ_PNP // Obsolete....
#define IRP_MJ_MAXIMUM_FUNCTION 0x1b

注意 IRP_MJ_MAXIMUM_FUNCTION = 0x1B = 27,也就是说驱动中最多可以设置 27 个不同的派遣例程函数。

关于 IRP 的更多内容建议通过 WDK Help 中的"WDK Documentation"文档来学习,该文档会重点介绍驱动程序中用到的一些 IRP 成员的含义和使用方法,另外文档末尾还有一段 Comments,也是非常有价值的内容。

WDK 文档中省略了 IRP 结构中的某些成员(Undocumented members),如果阅读了文档中 IRP 的 Comments 后,就会知道这些 Undocumented members 之所以被保留,是因为只有 I/O manager 或 FSDs 才能使用这些成员。为了更全面地了解 IRP 的数据结构,更直接的办法是找到 WDK 中定义 IRP 的头文件,阅读其中的注释,头文件的路径是 ***\WINDDK\7600.16385.1\inc\ddk\wdm.h。

为了灵活地查阅内核数据结构信息,还可以使用一些 PDB 辅助工具。一般地,内核数据结构大多定义在内核模块中。有了内核模块,还需要得到对应的 PDB 符号文件,推荐使用 SymbolTypeViewer 免费工具来下载符号文件。

启动 SymbolTypeViewer 后,单击"File"按钮,选择本机的内核模块文件(如 ntkrnlpa.exe),然后单击"Symbol Path"按钮,选择要保存符号文件的路径,再单击"Server"按钮,选择默认的微软链接,最后单击"Get Symbols"按钮,就开始下载符号了。点击左侧树形控件中的符号项,右侧的"Info"窗口中就会列出该符号的相关信息。

OD: Ring0 &amp; Kernel-LMLPHP

下一步就是浏览下载到的 PDB 文件,虽然 SymbolTypeViewer 工具也支持对内部符号的浏览,但是没有链接功能,不太方便。这里推荐使用另一个专门浏览 PDB 符号信息的免费工具 PDB_Explorer

启动 PDB Explorer 后,单击"打开"按钮,选择前面下载的 PDB 文件,然后在搜索框中输入"_IRP",在选择列出的第一个匹配项"_IRP",在右侧的内容区就可以看到所示的符号信息。

可以看到 PDB Explorer 是支持前进后退的,即展示出的结构体中,如果有类似 union 或子 struct 等成员时,还可以进去浏览更多信息。

以上是一些学习内核数据结构的方法,通过这些方法能逐渐理解 IRP 结构中每个成员的含义和用法。

Ring3 打开驱动设备

Ring3 访问设备时要求创建符号链接。符号链接名称的格式为"\DosDevices\DosDevice Name",其中 DosDeviceName 是任意指定的。

在驱动程序中可以通过 IoCreateSymbolicLink() 创建符号链接。

Ring3 可以通过 CreateFile() 打开设备。需要注意的是,为 CreateFile() 输入的文件名不同于前面的符号链接名称,应该是"\\.\DosDeviceName"的格式。其中"\\.\"前缀是一个设备访问的名称空间(the device namespace ),而不是一般文件访问的名称空间(the file namespace)。

通过以下代码就可以打开 helloworld 的驱动设备:

 HANDLE hDevice=
CreateFile(
"\\\\.\\HelloWorld",
GENERIC_READ|GENERIC_WRITE,
, //不共享
NULL, //不使用安全描述符
OPEN_EXISTING, //仅存在时打开
FILE_ATTRIBUTE_NORMAL,
NULL //不使用模板
);

DeviceIoControl() 与 IoControlCode

打开驱动设备后,Ring3 还要和驱动进行通讯或调用驱动的派遣例程,这需要用到一个非常重要的函数:DeviceIoControl()

 BOOL DeviceIoControl(
HANDLE hDevice, //设备句柄
DWORD dwIoControlCode, //Io控制号
LPVOID lpInBuffer, //输入缓冲区指针
DWORD nInBufferSize, //输入缓冲区字节数
LPVOID lpOutBuffer, //输出缓冲区指针
DWORD nOutBufferSize, //输出缓冲区字节数
LPDWORD lpBytesReturned, //返回输出字节数
LPOVERLAPPED lpOverlapped //异步调用时指向的OVERLAPPED指针
);

第二个参数 IoControlCode 尤为重要,由宏 CTL_CODE 构造而成:

#define CTL_CODE( DeviceType, Function, Method, Access ) ( \
((DeviceType) << ) | ((Access) << ) | ((Function) << ) | (Method) )

OD: Ring0 &amp; Kernel-LMLPHP

DeviceType      设备类型;
Access 对设备的访问权限
Function 设备 IoControl 的功能号,~0x7FF 为微软保留,0x800~0xFFF由程序员自己定义
Method Ring3/Ring0 的通信中的内存访问方式,有四种方式: #define METHOD_BUFFERED 0
#define METHOD_IN_DIRECT 1
#define METHOD_OUT_DIRECT 2
#define METHOD_NEITHER 3

最值得关注的也就是 Method,如果使用了 METHOD_BUFFERED,表示系统将用户的输入输出都经过 pIrp->AssociatedIrp.SystemBuffer 来缓冲,因此这种方式的通信比较安全。

如果使用了 METHOD_IN_DIRECT 或 METHOD_OUT_DIRECT 方式,表示系统会将输入缓冲在 pIrp->AssociatedIrp.SystemBuffer 中,并将输出缓冲区锁定,然后在内核模式下重新映射一段地址,这样也是比较安全的。

但是如果使用了 METHOD_NEITHER 方式,虽然通信的效率提高了,但是不够安全。驱动的派遣函数中可以通过 I/O 堆栈(IO_STACK_LOCATION)的 stack->Parameters.DeviceIoControl.Type3InputBuffer 得到。输出缓冲区可以通过 pIrp->UserBuffer 得到。由于驱动中的派遣函数不能保证传递进来的用户输入和输出地址,因此最好不要直接去读写这些地址的缓冲区。应该在读写前使用 ProbeForRead 和 ProbeForWrite 函数探测地址是否可读和可写。

Ring3/Ring0 的四种通信方式

METHOD_BUFFERED 为"缓冲方式",是指 Ring3 指定的输入、输出缓冲区的内存读和写都是经过系统的"缓冲"。

这种方式下,首先系统会将 Ring3 下指定的输入缓冲区(UserInputBuffer)数据,按指定的输入长度(InputBufferLen)复制到 Ring0 中事先分配好的缓冲内存(SystemBuffer,通过 pIrp->AssociatedIrp.SystemBuffer 得到)中。驱动程序就可以将 SystemBuffer 视为输入数据进行读取,当然也可以将 SystemBuffer 视为输出数据的缓冲区,也就是说 SystemBuffer 既可以读也可以写。驱动程序处理完后,系统会按照 pIrp->IoStatus->Information 指定的字节数,将 SystemBuffer 上的数据复制到 Ring3 指定的输出缓冲区(UserOutputBuffer)中。可见这个过程是比较安全的,避免了驱动程序在内核态直接操作用户态内存地址的问题,推荐使用这种方式:

OD: Ring0 &amp; Kernel-LMLPHP

METHOD_NEITHER 可称为"其他方式",这种方式与 METHOD_BUFFERED 方式正好相反。METHOD_BUFFERED 方式相当于对 Ring3 的输入输出都进行了缓冲,而 METHOD_ NEITHER 方式是不进行缓冲的,在驱动中可以直接使用Ring3的输入输出内存地址:

OD: Ring0 &amp; Kernel-LMLPHP

驱动程序可以通过 pIrpStack->Parameters.DeviceIoControl.Type3InputBuffer 得到 Ring3 的输入缓冲区地址(其中 pIrpStack 是 IoGetCurrentIrpStackLocation(pIrp) 的返回);通过 pIrp->UserBuffer 得到 Ring3 的输出缓冲区地址。

由于 METHOD_NEITHER 方式并不安全,因此最好对 Type3InputBuffer 读取之前使用 ProbeForRead 函数进行探测,对 UserBuffer 写入之前使用 ProbeForWrite 函数进行探测,当没有发生异常时,再进行读取和写入操作。

METHOD_IN_DIRECT 和 METHOD_OUT_DIRECT 可称为"直接方式",是指系统依然对 Ring3 的输入缓冲区进行缓冲,但是对 Ring3 的输出缓冲区并没有缓冲,而是在内核中进行了锁定。这样 Ring3 输出缓冲区在驱动程序完成I/O请求之前,都是无法访问的,从一定程度上保障了安全性。

这两种方式,对于 Ring3 的输入缓冲区和 METHOD_BUFFERED 方式是一致的。对于 Ring3 的输出缓冲区,首先由系统锁定,并使用 pIrp->MdlAddress 来描述这段内存,驱动程序需要使用 MmGetSystemAddressForMdlSafe 函数将这段内存映射到内核内存地址(OutputBuffer),然后可以直接写入 OutputBuffer 地址,最终在驱动派遣例程返回后,由系统解除这段内存的锁定。

OD: Ring0 &amp; Kernel-LMLPHP

METHOD_IN_DIRECT 和 METHOD_OUT_DIRECT 方式的区别,仅在于打开设备的权限上,当以只读权限打开设备时,METHOD_IN_DIRECT 方式的 IoControl 将会成功,而 METHOD_OUT_DIRECT 方式将会失败。如果以读写权限打开设备,两种方式都会成功。

04-15 19:40