0day安全 | Chapter 21 探索ring0
启程
长夜尽处 我站在你的面前 你将看到我的伤痕 知道我曾经受伤 也曾经痊愈
这是《0day安全》的第四部分:操作系统内核安全。
内核基础
Intel x86使用ring来实施访问控制,从ring0到ring3权限依次降低。NT开始的Windows系列和Linux在Intel x86上只使用ring0(内核态)和ring3(用户态)。本章讨论的漏洞特指运行于ring0程序的缺陷。
操作系统的内核以及各种驱动程序运行在ring0。
我们先来学习一些内核基础知识。
编写驱动程序的Hello World
首先安装WDK(Windows Driver Kit)。
在同一目录下,驱动程序的build需要三个文件:
- helloworld.c
- Makefile
- sources
helloworld.c
#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"));
}
// 驱动派遣例程函数
NTSTATUS DrvDispatch(IN PDEVICE_OBJECT driverObject,IN PIRP pIrp)
{
KdPrint(("Enter DrvDispatch\n"));
// 设置IRP的完成状态
pIrp->IoStatus.Status=STATUS_SUCCESS;
// 设置IRP的操作字节数
pIrp->IoStatus.Information=0;
// 完成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=0;
// 打印hello world
KdPrint(("DriverEntry: Hello world driver demo!\n"));
// 设置卸载函数
driverObject->DriverUnload = DriverUnload;
// 创建设备
RtlInitUnicodeString(&devName,DEVICE_NAME);
ntStatus = IoCreateDevice( driverObject,
0,
&devName,
FILE_DEVICE_UNKNOWN,
0, 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 = 0; i < IRP_MJ_MAXIMUM_FUNCTION; i++)
{
driverObject->MajorFunction[i] = DrvDispatch;
}
return STATUS_SUCCESS;
}
结合Linux Rootkit 实验 0000 LKM 的基础编写&隐藏我们可以理解Entry函数和Unload函数的作用。Unload函数不是必要的,但是如果没有设置Unload函数,那么该驱动程序就无法被卸载。创建驱动设备和符号链接,是为了能够在ring3打开该设备对象,并和驱动进行通信。
ring3向驱动发出不同类型的I/O请求,经过系统的“派遣”,最终会调用相对应的驱动派遣历程函数。
Makefile
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
该文件比较重要,可以配置要编译的源文件、编译出的sys文件名等。我们这里的sources内容如下:
TARGETNAME=helloworld
TARGETTYPE=DRIVER
SOURCES=helloworld.c
准备好文件后在开始菜单的WDK中找到
编译及编译结果如下:
F:\driver_helloworld\objchk_wxp_x86\i386\helloworld.sys
是驱动文件。
驱动的加载模式为:在用户态使用服务管理器创建一个服务,将helloworld.sys与服务关联起来,通过启动服务向内核加载helloworld.sys。我们借助工具OSRLOADER来完成这一操作:
在加载前,我们先打开Sysinternal工具集中的DbgView监视,然后在OSRLOADER中点最左侧注册服务,接着点开始服务,然后是停止服务和注销服务:
DebugView将依次显示日志:
派遣例程与IRP结构
IRP即I/O Request Package
。ring3通过DeviceIoControl等函数向驱动发出I/O请求,这个请求将被系统转化为IRP结构,派遣到对应派遣例程中:
Kernel32.dll DeviceIoControl (ring3)
-> Ntdll.dll NtDeviceIoControlFile (ring 3)
-> Ntoskrnl.exe NtDeviceIoControlFile (ring0)
-> 对应驱动的派遣例程 (ring0)
一个IRP包该发往驱动的哪个派遣例程函数是由IRP结构中的MajorFunction
属性决定,它的值是一系列前缀为IRP_MJ_
的宏,具体可以参考IRP Major Function Codes。这些宏共有27个,所以一个驱动最多可以设置27个不同的派遣例程函数。我们的helloworld.c中为了简单,将所有IRP包都派遣到了DrvDispatch
:
for (i = 0; i < IRP_MJ_MAXIMUM_FUNCTION; i++)
{
driverObject->MajorFunction[i] = DrvDispatch;
}
我们可以借助WDK Help中的WDK Documentation
学习IRP结构:
文档结构类似于Linux上的man文件。文档最后指出IRP定义在wdm.h
中,因此我们可以到WDK根目录下找到它的定义:
// wdm.h
typedef struct DECLSPEC_ALIGN(MEMORY_ALLOCATION_ALIGNMENT) _IRP {
CSHORT Type;
USHORT Size;
// ...
Ring3打开驱动设备
Ring3访问设备时要求创建符号链接。符号链接名称格式为\DosDevices\DosDeviceName
,其中DosDeviceName
是任意指定的。
如我们的helloworld.c,在驱动程序中可以通过IoCreateSymbolicLink
创建符号链接。
Ring3可以通过CreateFile
函数打开设备。不过其文件名参数应为\\.\DosDeviceName
的格式。\\.\
是一个设备访问的命名空间,而不是一般文件访问的命名空间。
通过如下代码可以打开helloworld的驱动设备:
HANDLE hDevice =
CreateFile(
"\\\\.\\HelloWorld",
GENERIC_READ | GENERIC_WRITE,
0 // 不共享
NULL, // 不使用安全描述符
OPEN_EXISTING, // 仅存在时打开
FILE_ATTRIBUTE_NORMAL,
NULL);
DeviceIoControl函数与IoControlCode
打开驱动设备后,Ring3还要和驱动通信或调用派遣例程,这需要用到:
BOOL WINAPI DeviceIoControl(
_In_ HANDLE hDevice, // 设备句柄
_In_ DWORD dwIoControlCode, // IO控制号
_In_opt_ LPVOID lpInBuffer, // 输入缓冲区指针
_In_ DWORD nInBufferSize,
_Out_opt_ LPVOID lpOutBuffer, // 输出缓冲区指针
_In_ DWORD nOutBufferSize,
_Out_opt_ LPDWORD lpBytesReturned,
_Inout_opt_ LPOVERLAPPED lpOverlapped // 异步调用时指向的OVERLAPPED指针
其中IoControlCode很重要,其由宏CTL_CODE
构造而成:
#define CTL_CODE(DeviceType, Function, Method, Access) (
((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method)
)
// DeviceType 设备类型
// Access 访问权限
// Function 设备IoControl的功能号,0 ~ 0x7ff为微软保留,0x800 ~ 0xfff为程序员定义
// Method 内存访问方式,包括以下四种
#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->Association.SystemBuffer
中,并将输出缓冲区锁定(使用pIrp->MdlAddress
描述这段内存),然后在内核模式下重新映射一段地址(驱动程序通过MmGetSystemAddressForMdlSafe
将其映射到OutpubBuffer
),这也是比较安全的;
METHOD_NEITHER
使得通信效率提高,但不安全。输入可以通过I/O堆栈IO_STACK_LOCATION
的pIrpStack->Parameters.DeviceIoControl.Type3InputBuffer
得到(pIrpStack
由IoGetCurrentIrpStackLocation(pIrp)
得到),输出缓冲区可以通过pIrp->UserBuffer
得到。由于驱动中的派遣函数不能保证传递进来的用户输入和输出地址,因此最好不要直接去读写这些地址的缓冲区。应该在读写前用ProbeForRead
和ProbeForWrite
函数检测地址是否可读/写。
METHOD_BUFFERED
可称为“缓冲方式”,指Ring3的输入、输出缓冲区的读写都经过系统缓冲。其流程如下:
METHOD_NEITHER
与METHOD_BUFFERED
刚好相反,在驱动中直接使用Ring3的输入输出地址:
METHOD_IN_DIRECT
和METHOD_OUT_DIRECT
指系统依然对Ring3的输入缓冲区缓冲,但对其输出缓冲区不缓冲,而是在内核中锁定。这样Ring3输出缓冲区在驱动完成I/O之前都是无法访问的:
METHOD_IN_DIRECT
和METHOD_OUT_DIRECT
的区别是:以只读权限打开设备时,只有METHOD_IN_DIRECT
成功;以读写模式打开时,两者都会成功。
搭建内核调试环境
本节参考配置Windows内核调试环境-[Mac版]、Mac下双VM搭建Windows内核调试环境和Win7(WinDbg) + VMware(Win7) 双机调试环境搭建之五。
本节将讲解在Mac OSX上使用VMware Fusion搭建Windows双机调试环境的过程。
调试机: Windows XP
被调试机: Windows 7 32 bit
首先关闭这两台机器。
编辑调试机的vmx文件
首先删除里边已有的(如果有)serial0.*
选项,然后添加:
serial0.present = "TRUE"
serial0.fileType = "pipe"
serial0.startConnected = "TRUE"
serial0.fileName = "/Users/rambo/VMs/serial"
serial0.tryNoRxLoss = "FALSE"
serial0.pipe.endPoint = "client"
编辑调试机的vmx文件
同样,删除里边已有的(如果有)serial0.*
选项,然后添加:
serial0.present = "TRUE"
serial0.fileType = "pipe"
serial0.fileName = "/Users/rambo/VMs/serial"
serial0.tryNoRxLoss = "FALSE"
serial0.pipe.endPoint = "server"
serial0.yieldOnMsrRead = "TRUE"
开机配置调试机
打开调试机,在设备管理器中设置串口选项,将波特率设置为115200:
创建一个新的WinDbg快捷方式,其目标如下:
"C:\Program Files\Debugging Tools for Windows (x86)\windbg.exe" -b -k com:port=com1,baud=115200,pipe
开机配置被调试机
同样,在设备管理器中设置串口选项,将波特率设置为115200。
接着,用管理员命令行执行以下bcdedit命令:
bcdedit /copy {current} /d "Windows 7 normal"
bcdedit /debug ON
bcdedit /bootdebug ON
bcdedit /timeout 10
bcdedit /dbgsettings serial debugport:1 baudrate:115200
“运行”打开msconfig,在“引导”选项卡中点击高级选项配置如下:
开始调试
在调试机中双击我们创建的快捷方式,将显示:
Microsoft (R) Windows Debugger Version 6.12.0002.633 X86
Copyright (c) Microsoft Corporation. All rights reserved.
Opened \\.\com1
Waiting to reconnect...
重启被调试机器,将看到:
选择第一个。此时将看到调试机中WinDbg有中断:
说明调试环境配置成功。
调试内核经常会导致死机或蓝屏。可以先建立快照,然后再调试。
在WinDbg中使用!analyze -v
命令可以分析蓝屏后的转储文件。
内核漏洞概述
作者整理了好多内核漏洞。下面我们谈一谈内核漏洞分类:
按照严重程度:
- 远程拒绝服务
- 本地拒绝服务
- 远程任意代码执行
- 本地权限提升
按照漏洞利用原理:
- 拒绝服务
- 缓冲区溢出
- 内存篡改
- 任意地址写任意数据
- 任意地址写固定数据
- 固定地址写任意数据
- 设计缺陷
对于初学者来说,内核漏洞的学习过程可以总结为四个环节:
- 漏洞重现
- 漏洞分析
- 漏洞利用
- 漏洞总结
这样看来,自己以前往往仅仅做了“漏洞重现”就浅尝辄止了,这是不够的。
内核漏洞挖掘方法论:
编写安全的驱动程序
从开发者角度来说,内核漏洞原因可以归结为:
- 未验证输入输出
- 未验证调用者
- 代码逻辑错误
- 系统设计存在安全缺陷
作者在这里举了一个ReactOS中对缓冲区是否可写检查的例子,我就不再详述了。总之,要尽力对以上可能存在的薄弱点进行检查和避免。
总结
本章是内核漏洞学习的序章。做了介绍、搭建了环境,并给出了一些方法论。
不知不觉,已经走了很远。
终于等到你,还好我没放弃。