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_DIRECTMETHOD_OUT_DIRECT,则系统将输入缓冲在pIrp->Association.SystemBuffer中,并将输出缓冲区锁定(使用pIrp->MdlAddress描述这段内存),然后在内核模式下重新映射一段地址(驱动程序通过MmGetSystemAddressForMdlSafe将其映射到OutpubBuffer),这也是比较安全的;

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

METHOD_BUFFERED可称为“缓冲方式”,指Ring3的输入、输出缓冲区的读写都经过系统缓冲。其流程如下:

METHOD_NEITHERMETHOD_BUFFERED刚好相反,在驱动中直接使用Ring3的输入输出地址:

METHOD_IN_DIRECTMETHOD_OUT_DIRECT指系统依然对Ring3的输入缓冲区缓冲,但对其输出缓冲区不缓冲,而是在内核中锁定。这样Ring3输出缓冲区在驱动完成I/O之前都是无法访问的:

METHOD_IN_DIRECTMETHOD_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命令可以分析蓝屏后的转储文件。

内核漏洞概述

作者整理了好多内核漏洞。下面我们谈一谈内核漏洞分类:

按照严重程度:

  • 远程拒绝服务
  • 本地拒绝服务
  • 远程任意代码执行
  • 本地权限提升

按照漏洞利用原理:

  • 拒绝服务
  • 缓冲区溢出
  • 内存篡改
  • 任意地址写任意数据
  • 任意地址写固定数据
  • 固定地址写任意数据
  • 设计缺陷

对于初学者来说,内核漏洞的学习过程可以总结为四个环节:

  1. 漏洞重现
  2. 漏洞分析
  3. 漏洞利用
  4. 漏洞总结

这样看来,自己以前往往仅仅做了“漏洞重现”就浅尝辄止了,这是不够的。

内核漏洞挖掘方法论:

编写安全的驱动程序

从开发者角度来说,内核漏洞原因可以归结为:

  • 未验证输入输出
  • 未验证调用者
  • 代码逻辑错误
  • 系统设计存在安全缺陷

作者在这里举了一个ReactOS中对缓冲区是否可写检查的例子,我就不再详述了。总之,要尽力对以上可能存在的薄弱点进行检查和避免。

总结

本章是内核漏洞学习的序章。做了介绍、搭建了环境,并给出了一些方法论。

不知不觉,已经走了很远。

终于等到你,还好我没放弃。

Per Aspera Ad Astra