分类 Windows内核编程 下的文章

理解内核机制有助于我们调试以及从整体上理解系统的运作

image-20211102202235933

6.1 中断请求级别(IRQL)

当要处理的线程多于可用处理器的数量时,就会考虑到线程的优先级。同时硬件设备需要去通知系统来进行进程调度。比如:由磁盘驱动器执行的I/O操作,操作完成后磁盘驱动器会通过请求中断来通知系统操作已经完成。该请求中断连接到中断控制器硬件设备,然后把请求发送到处理器进行处理。现在有一个问题就是哪个线程来执行中断服务程序(ISR Interrupt Service Routine)呢

每个物理硬件中断都与一个优先级有关,叫做IRQL(Interrupt Request Level)中断请求级别,由HAL(硬件抽象层)来决定IRQL为多少。每个处理器的上下文都有自己的IRQL,就像每个处理器有自己的寄存器一样,可以像对待CPU的寄存器一样来对待IRQL。

对于IRQL来说基本规则就是:处理器会执行IRQL级别更高的对应的程序(ISR)。例如:当前处理器的IRQL为0,这时有一个IRQL为5的中断进来,处理器就会在当前线程的内核栈中保存上下文状态,然后将处理器的IRQL提升为5,然后执行中断服务程序(ISR Interrupt Service Routine)。一旦执行结束,IRQL就会回到原来的环境。另一方面如果在中断的IRQL==5的时候又有新中断来了也是一样的,先判断IRQL的大小,如果大于5则调用新中断如果小就等待。

image-20211102212104048

image-20211102212117847

通过以上两张图我们可以知道,所有的ISR(Interrupt Service Routine中断服务程序)都是在被中断的线程中完成的。Windows没有专门的线程来处理中断而是由当前在中断处理器上运行的线程来处理。

在用户态的代码执行时,IRQL总是等于0,所以在用户态开发的时候我们也无需关注IRQL。大部分的内核代码也是在IRQL等于0的环境下运行,在内核态下可以通过内核API提高IRQL。

image-20211102212552120

当处理器的IRQL提升到大于等于2以上时,执行的代码就会有很多限制

  1. 访问不存在物理内存的内存会导致系统崩溃,这意味着从非分页池访问数据总是安全的,而从分页池或用户提供的缓冲区访问数据是不安全的,必须避免。
  2. 等待任何调度程序内核对象(例如互斥锁或事件)会导致系统崩溃,除非将等待超时时间设置为零。

产生限制的原因:因为调度程序是在IRQL(2)上运行,所以当处理器的IRQL大于等于2,调度程序就无法在处理器上运行,因此就不会发生线程上下文切换(用该CPU上的另一个线程替换该线程)。只有更高级别的中断才能临时将代码转移到关联的ISR,但是它仍然是同一个线程里,不会发生线程上下文的切换。总的来说,以上两种状态需要通过调度程序进行线程切换,但是调度程序的IRQL为2无法在当前处理器IRQL大于等于2的时候运行。

提高和降低IRQL

在用户态是不能修改IRQL的,只有内核态可以。IRQL可以被KeRaiseIrql函数提升和被KeLowerIrql函数降低。这里提供一个代码片段来方便理解:

//假设当前IRQL<=DISPATCH_LEVEL 也就是IRQL(2)
KIRQL oldIrql;    //KIRQL是对UCHAR的一种typedef重命名
KeRaiseIrql(DISPATCH_LEVEL,&oldIrql);

NT_ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL);

// do some work

KeLowerIrql(oldIrql);

如果提高了IRQL,请确保在相同的函数中降低它,只提升了原来的却不降低是非常危险的。用了KeRaiseIrql来提高务必在同一函数用KeLowerIrql来降低

线程优先级和IRQL的异同

IRQL是处理器的一个属性,线程优先级是线程的一个属性,线程优先级只有在IRQL<2时才有意义。

任务管理器用一个叫做System interrupt的伪进程来描述CPU在IRQL>=2的情况下花费的时间,在Process Explorer用interrupt来描述:

image-20211102214255187

6.2 延迟过程调用(DPC)

image-20211102215451245

上图显示了客户端调用I/O操作时的经典事件序列:用户层下的线程打开某个文件句柄,然后调用ReadFile发起一个读操作。由于线程可以异步调用,它几乎马上就可以重新获得控制权并可以做其他工作。收到ReadFile的读取请求的驱动程序会调用文件系统驱动程序(例如 ntfs.sys),它可能会一直往下调用直到磁盘驱动程序,最后磁盘驱动程序对磁盘进行操作。

当硬件完成读操作的时候,会发出一个中断。该中断会引起与之关联的中断服务程序(ISR)在硬件设备的IRQL处执行。一个典型的ISR会访问设备硬件以得到操作的结果,最后完成(CompleteRequest)请求。

如前文所说完成一个请求通常是通过调用IoCompleteRequest函数来完成的,但是该函数的文档说只能在IRQL<=DISPATCH_LEVEL(2)时才能使用。

允许ISR调用IoCompleteRequest(和类似的函数)的机制被称为DPC(Derferred Procedure Call)

注册了ISR的驱动程序需要从非分页池内存中分配KDPC结构体,并用KeInitializeDpc来初始化给后面DPC做调用准备。当ISR被调用时,在退出ISR调用之前,ISR调用KeInsertQueueDpc函数将此DPC插入队列,当DPC函数执行时,就会调用IoCompleteRequest函数了。这是一种调用DPC的折中方案。它在IRQL=DISPATCH_LEVEL状态上运行,这表示它也不能进行调度和访问分页内存。

系统中每一个处理器都有自己的DPC队列,在默认的情况下KeInsertQueueDpc函数将DPC插入当前处理器的DPC队列里。当ISR返回前,再IRQL降回0之前,会检测处理器的队列里面是否还有PDC,如果有处理器降低IRQL等级为DISPATH_LEVEL(2)然后以先进先出(队列的方式)来处理队列里的DPC,直到队列清空,处理器的IRQL等级才降为0,并恢复中断时的环境。

也可以通过这两个函数KeSetImportantceDpc,KeSetTargetProcessorDpc自己定制DPC

image-20211102222117898

Using DPC with a Timer

DPC最初是为了给ISR使用而创建的,但是也有别的机制可以使用DPC。DPC可以和内核时钟绑定一起使用。

KTIMER结构体表示内核时钟(Kernel Timer)允许通过相对或者绝对时间来设置一个时钟。时钟(Timer)是一个调度对象(dispatcher object),可以用KeWaitForSingleObject等函数来等待,但是不太方便。更简单常用的办法是在内核时钟(kernel timer)中使用回调函数(DPC)。

用一个例子来方便理解:

KTIMER Timer; KDPC TimerDpc;
void InitializeAndStartTimer(ULONG msec) { 
KeInitializeTimer(&Timer); 
KeInitializeDpc(&TimerDpc,                                                                                     OnTimerExpired,// callback function 
                  nullptr); // passed to callback as "context"
    
    // relative interval is in 100nsec units (and must be negative)
    // convert to msec by multiplying by 10000
                                          
    LARGE_INTEGER interval;
    interval.QuadPart = -10000LL * msec; 
    KeSetTimer(&Timer, interval, &TimerDpc);
}

void OnTimerExpired(KDPC* Dpc, PVOID context, PVOID, PVOID) { 
    UNREFERENCED_PARAMETER(Dpc); 
    UNREFERENCED_PARAMETER(context);
    NT_ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL); 
        // handle timer expiration
}

这段代码表示当内核时钟到期时,DPC会被插入到CPU中的DPC队列中并尽快执行。使用DPC比普通基于IRQL(0)的回调更快,因为它级别比较高,所以能够保证在用户态代码和大多数内核代码之前执行。

6.3 异步过程调用(APC)

DPC被封装成函数会在IRQL等于DISPATCH_LEVEL的时候被调用。异步过程调用(APC)也是被封装成函数来调用,但是和DPC不同,APC是专门给某个特定线程使用,而DPC是和处理器有关。这意味着每个线程都有一个APC队列,每个处理器有DPC队列。

image-20211104150036221

用户模式下可以调用适当的API来使用APC。例如,调用 ReadFileEx 或 WriteFileEx 开始异步 I/O 操作。操作完成后,用户模式 APC 会附加到调用线程。如前文所述,该 APC 当线程进入警报状态的时候执行。在用户模式下显式插入 APC 的另一个API函数是QueueUserAPC。

关键区和警戒区

关键区禁止用户态和普通内核APC执行。线程使用KeEnterCriticalRegion函数来进入关键区,使用KeLeaveCriticalRegion来离开关键区。内核编程中的某些功能函数需要位于关键区(Critical Regions)内。尤其是在处理执行体资源(executive resources)时;警戒区(Guarded Regions)阻止所有APC执行。KeEnterGuardedRegion和KeleaveGuardedRegion必须成套出现不然很危险。

6.4 结构化异常(SEH)

异常是由于某条指令执行某些导致处理器引发错误的操作而发生的事件。异常的常见例子包括:除零,断点,页错误,堆栈溢出和无效指令等。如果发生异常内核会捕获它并在可能的情况下运行代码来处理异常,这种机制称为结构化异常处理(Structured Exception Handling SEH),可以用于用户和内核层,异常也是断点实现的基本原理。

内核异常处理程序由IDT(Interrupt Dispatch Table 中断描述符表)来调用,IDT与中断和ISR之间的映射相同,一一对应。对于Windbg来说,可以使用!idt命令来查看系统IDT表的所有映射。image-20211105102737011

一些常见的异常:

image-20211105103354241

一旦程序发生了异常,内核会在发生异常的函数中搜索处理程序(除了一些透明处理的异常,例如断点(3)),如果没有找到就会向上搜索调用堆栈,直到找到异常处理程序,如果堆栈耗尽,那么系统崩溃。

Windows提供了四个C语言关键字来让开发者完成异常处理:

image-20211105105611346

关键字的有效组合是 _try/except和 _try/finally,这些关键字在用户态和内核态都可以使用。

6.5 系统奔溃

系统奔溃我们简单的理解就是系统蓝屏了(BSOD),系统蓝屏是一种保护机制,因为如果代码再往下执行就有可能造成毁灭性打击,就所以直接蓝屏不让系统继续执行了。

如果崩溃的系统连接到了一个内核的调试器的话,会在调试器中产生一个中断,可以让你在调试器里面对系统的状态进行检查。可以在Windows里面进行配置使得当出现蓝屏时保存一个dump文件,这个dump文件会保存系统蓝屏的环境。

image-20211105170424660

Dump转储类型决定了什么样的数据会被写入,具体的选项如下

类型描述
小内存转储非常小,仅包含基本的系统信息和引起崩溃的线程信息
核心内存转储捕获所有的内核内存但不包括用户内存,一般来说这个是足够的,因为用户代码一般不会整出蓝屏
完整内存转储提供了全部内存的转储,文件大小偏大
自动内存转储(Windows8+)等同于核心内存转储,在启动时自动调整页面文件大小,来保证有一个合适的大小来存储内核内存转储文件。
活跃内存转储(Windows10+)类似与完整内存转储,除了崩溃的系统有文件,否则是不会有的。有助于减小服务器系统的转储文件大小。

具体如何分析Dump文件可以参考此前的Windbg的使用

6.6 线程同步

一个驱动程序可以被多个应用程序调用,所以就难免会出现线程调度的问题,比如说一个在改一个在读,这样就可能造成不安全访问,这也被称为数据竞争。这种情况下最简单安全的办法就是当一个线程访问某个内容时,其他线程都不能访问,只能等待,这样就不会导致不安全的情况了。Windows提供了一些原子操作来实现线程同步。

6.6.1 互锁操作

一些驱动程序可用的互锁函数

函数描述
InterlockedIncrement/InterlockedIncrement16/InterlockedIncrement64对32/16/64位的整数原子化加一
InterlockedDecrement/16/64对32/16/64位的整数原子化减一
InterlockedAdd/InterlockedAdd64原子化的将32/64位数加到一个变量上
InterlockedExchange/8/16/64原子化的交换32/8/16/64位整数
InterlockedCompareExchange/64/128原子化地比较一个变量与一个值,如果相等则将提供的值交换到变量中并返回TRUE;否则,将当前的值放入变量中并返回FALSE

6.6.2 分发器对象

分发器对象也叫可等待对象。这些对象有着有信号和无信号两种状态,之所以被称为可等待对象是因为线程可以等待该对象从无信号到有信号然后再使用。这个在用户态下被称为信号对象。

用于等待的主要函数是KeWaitForSingleObjectKeWaitForMultipleObject 函数:

返回值有两种:STATUS_SUCCESS 等待完成有信号; STATUS_TIMEOUT:等待完成超时。

注意返回值用NT_SUCCESS宏都是返回真,不能直接用返回值为真来判断是否等待成功。

6.6.3 互斥量

很经典的一种对象,用于解决多个线程的某个线程在任何时候访问共享资源的标准问题。

互斥量Mutex在自由的时候是信号态,一旦被调用这个互斥量就变成无信号态,别的线程就无法调用它了。调用它的线程就被称为拥有者。对于Mutex来说拥有关系很重要。因为:
如果某个线程拥有了它,该线程就是唯一可以释放该互斥量的线程
一个互斥量能多次被同一线程获取,需要注意的是使用完之后必须释放掉,不然别的线程将无法获取。

要使用互斥量Mutex,需要从非分页池(non-paged pool)中分配一个KMUTEX结构。互斥量的API包含了如下与KMUTEX一起工作的函数:
KeInitializeMutex:必须被调用一次来初始化互斥量。
某一个等待函数需要将分配的KMUTEX结构体的地址作为参数传递给它
在某个线程是互斥量的拥有者时需要调用KeReleaseMutex释放互斥量
利用上述函数,这里有一个使用互斥量访问共享数据,使得一次只能有一个线程访问的例子:

KMUTEX MyMutex;
LIST_ENTRY DataHead;

void Init() { 
    KeInitializeMutex(&MyMutex, 0); 
}
void DoWork() {
    // wait for the mutex to be available
    KeWaitForSingleObject(&MyMutex, Executive, KernelMode, FALSE, nullptr); 
    // access DataHead freely
    
    // once done, release the mutex 
    KeReleaseMutex(&MyMutex, FALSE);
}

重要的是,无论怎样都要释放互斥量,因此最好使用前文提到的__try/ \_\_finally 以保证在任何情况下都能释放互斥量:

void DoWork() { 
    // wait for the mutex to be available
    KeWaitForSingleObject(&MyMutex, Executive, KernelMode, FALSE, nullptr);
    __try {
        // access DataHead freely
    }
    __finally {
        // once done, release the mutex
        KeReleaseMutex(&MyMutex, FALSE); 
    } 
}

6.6.4 快速互斥量

快速互斥量是传统互斥量的一种替代,提供了更好的性能有着自己的一套API,和传统互斥量有者以下特点:
不能递归获取,不然会造成死锁
被获取后,CPU的IRQL会提高到APC_LEVEL(1),会阻止线程上的APC传递
只能无限等待,无法指定超时时间
只能用于驱动层

6.6.5 信号量

信号量的主要目的是用来限制某些东西,比如队列的长度。信号量的最大值和初始值(一般初始值等于最大值)用KeInitalizeSemaphore来确定,当信号量内部值大于零时,处于有信号态,等于零为无信号态。调用KeWaitForSingleObject时当信号值大于零会表示等待成功然后计数减一。KeReleaseSemaphore会释放信号量让计数加一

6.6.6 事件

事件封装了一个布尔值的标志,真为有信号,假为无信号。事件的主要目的是在某事发生时发出信号,提供执行流上的同步。事件有两种类型,类型在初始化事件的时候指定:
通知事件N(手动重置):该事件被触发后会释放所有正在等待的线程,并且状态一直保持为有信号,除非被显示重置。
同步事件(自动重置):被触发后最后释放一个线程。触发后回到无信号状态。

创建方法:从非分页池里创建一个KEVENT结构,指明事件类型和初始事件状态,然后调用KeInitalizeEvent初始化,,调用KeSetEvent设置事件为有信号,调用KeResetEvent或KeClearEvent重置。

6.6.7 执行体资源

内核提供了一种单写多读的线程同步原语,就是执行体资源。

6.7 高IRQL同步

自旋锁

实现CPU同步

6.8 工作项目

用来描述在系统线程池中排队的函数

标题所指的调试为利用WinDbg(Preview)对驱动/内核进行调试,书中介绍了本地内核调试和双机内核调试两种情况,本文主要侧重介绍双机内核调试,本地内核调试只会简单带过。

5.1 本地内核调试(LKD)

本地内核调试和双机内核调试的主要区别在于本地内核调试只能查看内核当前的状态无法设置断点,所以在执行命令的时候所看到的信息不一定可靠。

我们可以使用Sysinternals的LiveKd(64)工具,使用livekd(64) -w命令让LiveKd运行WinDbg

image-20211019211514256

5.2 双机内核调试

本节介绍的是VMWare的双机调试,和书中介绍使用Hyper-V虚拟机不太一样。

首先介绍一个双机调试的神器 VirtualKD-Redux ,将target32(64)放到对应位数的虚拟机操作系统中,运行vminstall.exe(请勿在主机运行

image-20211019214133428

image-20211019214938603

重启后选择下面启动项,按F8选择禁用驱动程序强制签名

image-20211019215101037

并在实体机打开vmmon64.exe,如果是在微软商城安装的WinDbg Preview 默认就会配置好路径我们不用额外设置,如果没有可以手动设置WinDbg.exe所在路径一般在以下路径

  • x86: C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\WinDbg.exe
  • x64: C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\WinDbg.exe

image-20211019215355493

当选择进入系统并且配置好WinDbg路径,在实体机上会自动弹出WinDbg调试窗口

image-20211019215513818

5.3 WinDbg调试指令

可以参考软件调试第30章,张银奎老师讲的非常好我不再献丑了。

4.1 简介

在应用层利用SetThreadPriority等API设置线程优先级是受到进程优先级限制的,所以本节就计划通过编写一个驱动程序和应用程序相结合突破这种限制。本节代码放在了Github上访问此链接

image-20211016211020631

4.2 驱动程序初始化

先按照上一节的介绍来了解一下创建一个驱动程序的基本流程:入口函数,卸载函数,驱动支持的派遣函数,设备对象,指向设备对象的符号链接。

4.2.1 入口函数和卸载函数

void PriorityBoosterUnload(_In_ PDRIVER_OBJECT DriverObject);

// DriverEntry
extern "C" NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) 
{ 
    DriverObject->DriverUnload = PriorityBoosterUnload;
    return STATUS_SUCCESS; 
}

void PriorityBoosterUnload(_In_ PDRIVER_OBJECT DriverObject) 
{ 
    
}

每个驱动程序都需要支持IRP_MJ_CREATE 和 IRP_MJ_CLOSE ,所以我们需要在DriverEntry中添加IRP对应的派遣函数,在本节例子中Create和Close指向的派遣函数(PriorityBoosterCreateClose)只是做了批准请求的操作。

NTSTATUS PriorityBoosterCreateClose(_In_ PDEVICE_OBJECT DeviceObject, _In_ PIRP Irp)
{
    UNREFERENCED_PARAMETER(DeviceObject);

    Irp->IoStatus.Status = STATUS_SUCCESS; 
    Irp->IoStatus.Information = 0; 
    IoCompleteRequest(Irp, IO_NO_INCREMENT); 

    return STATUS_SUCCESS;
}

DriverObject->MajorFunction[IRP_MJ_CREATE] = PriorityBoosterCreateClose;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = PriorityBoosterCreateClose;

4.2.2 创建设备对象

典型的软件驱动只需要一个设备对象,并用一个暴露到应用层的符号链接指向它,这样用户模式客户程序就能得到驱动程序设备对象的句柄。

UNICODE_STRING devName = RTL_CONSTANT_STRING(L"\\Device\\PriorityBooster");
PDEVICE_OBJECT DeviceObject; 
NTSTATUS status = IoCreateDevice(
        DriverObject,            // our driver object,
        0,                        // no need for extra bytes,
        &devName,                // the device name,
        FILE_DEVICE_UNKNOWN,    // device type,
        0,                        // characteristics flags,
        FALSE,                    // not exclusive,
        &DeviceObject            // the resulting pointer
    );
    if (!NT_SUCCESS(status)) 
    {
        KdPrint(("Failed to create device object (0x%08X)\n", status));
        return status;
    }

    UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\??\\PriorityBooster");
    status = IoCreateSymbolicLink(&symLink, &devName); 
    if (!NT_SUCCESS(status)) 
    {
        KdPrint(("Failed to create symbolic link (0x%08X)\n", status));
        IoDeleteDevice(DeviceObject); 
        return status;
    }

4.3 Create和Close派遣函数

IRP是半文档化的结构用来表示一个请求,它通常来自执行体中的管理器之一:I/O管理器,即插即用(PnP)管理器和电源管理器。IRP从不单独到来,它总会有一个或多个IO_STACK_LOCATION类型结构相伴,在我们的例子中只有一个IO_STACK_LOCATION,在更加复杂的环境中当前驱动程序的上面或者下面会有过滤驱动程序,会存在多个IO_STACK_LOCATION实例,总的来说就是设备栈的每层都包含一个该实例。

我们先设置IRP的IoStatus(IO_STACK_LOCATION)image-20211016230228640

然后调用IoCompleteRequest函数去完成IRP,他会把IRP传回它的创建者(通常是I/O管理器),然后管理器通知客户程序操作已经完成。

NTSTATUS PriorityBoosterCreateClose(_In_ PDEVICE_OBJECT DeviceObject, _In_ PIRP Irp)
{
    UNREFERENCED_PARAMETER(DeviceObject);

    Irp->IoStatus.Status = STATUS_SUCCESS; 
    Irp->IoStatus.Information = 0; 
    IoCompleteRequest(Irp, IO_NO_INCREMENT); 

    return STATUS_SUCCESS;
}

4.4 客户端程序

4.4.1 信息传递给驱动程序

从应用程序的角度来说可以使用CreateFile打开驱动程序暴露的符号链接,并使用WriteFile, ReadFile 和DeviceIoControl与驱动程序通信。因为本例子主要是给驱动程序传递消息,所以我们现在可以在创建一个名为Booster的应用层项目,利用DeviceIoControl函数和符号链接与驱动通信。

应用程序和驱动程序之间通过DeviceIoControl的通讯需要一个控制代码和一个输入缓冲区,输入缓冲区需要规定驱动和应用程序都能理解(使用)的约定数据格式,本节例子需要线程ID和要设置的线程优先级,数据结构如下:

typedef struct _ThreadData {
    ULONG ThreadId;
    int Priority;
}ThreadData, * PThreadData;

下一步需要定义控制代码,控制代码需要使用CTL_CODE宏来定义。

#define PRIORITY_BOOSTER_DEVICE 0x8000

#define IOCTL_PRIORITY_BOOSTER_SET_PRIORITY CTL_CODE(PRIORITY_BOOSTER_DEVICE, 0x800, METHOD_NEITHER, FILE_ANY_ACCESS)

做完以上操作Booster应用程序就可以写出来了,主函数代码如下:

int main(int argc, const char* argv[])
{
    if (argc < 3)
    {
        printf("Usage: Booster <threadid> <priority>\n");
        return 0;
    }
    HANDLE hDevice = CreateFile(L"\\\\.\\PriorityBooster", GENERIC_WRITE, FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, 0, nullptr);
    if (hDevice == INVALID_HANDLE_VALUE) return
        Error("Failed to open device");
    ThreadData data; 
    data.ThreadId = atoi(argv[1]); // command line first argument
    data.Priority = atoi(argv[2]); // command line second argument

    DWORD returned; 
    BOOL success = DeviceIoControl(
        hDevice, 
        IOCTL_PRIORITY_BOOSTER_SET_PRIORITY, 
        &data, 
        sizeof(data), 
        nullptr, 
        0,
        &returned, 
        nullptr);
    if (success)
        printf("Priority change succeeded!\n");
    else
        Error("Priority change failed!"); 
    CloseHandle(hDevice);
}

4.5 DeviceIoControl派遣函数

目前本节例子中所有的驱动代码都导向这个派遣函数,它为给定的线程设置请求的优先级。我们先要检查控制代码,驱动程序通常会支持多个控制代码,一旦发现了未识别的控制代码,我们要立即停止请求:

NTSTATUS PriorityBoosterDeviceControl(_In_ PDEVICE_OBJECT, _In_ PIRP Irp)
{

    // get our IO_STACK_LOCATION 
    auto stack = IoGetCurrentIrpStackLocation(Irp); // IO_STACK_LOCATION* 
    auto status = STATUS_SUCCESS;

    switch (stack->Parameters.DeviceIoControl.IoControlCode)
    {
        case IOCTL_PRIORITY_BOOSTER_SET_PRIORITY:
        {
            // do the work 
        }
        default:
        {
            status = STATUS_INVALID_DEVICE_REQUEST;
            break;
        }
    }

    Irp->IoStatus.Status = status;
    Irp->IoStatus.Information = 0;
    IoCompleteRequest(Irp, IO_NO_INCREMENT); 
    return status;
}

下一步需要检查接收到的缓冲区是否足够大以及data是否为nullptr。

auto len = stack->Parameters.DeviceIoControl.InputBufferLength; 
if (len < sizeof(ThreadData))
{
    status = STATUS_BUFFER_TOO_SMALL;
    break;
}

auto data = (ThreadData*)stack->Parameters.DeviceIoControl.Type3InputBuffer; 
if (data == nullptr) 
{
    status = STATUS_INVALID_PARAMETER; 
    break;
}

然后检查优先级是否在1到31的合法范围内,如果不是就终止。

if (data->Priority < 1 || data->Priority > 31) 
{
    status = STATUS_INVALID_PARAMETER; 
    break;
}

利用ntifs.h的PsLookupThreadByThreadId函数将应用程序传进来的TID转换成指向KTHREAD对象的指针,并通过KeSetPriorityThread函数最终达到我们修改线程优先级的目的。

PETHREAD Thread; 
status = PsLookupThreadByThreadId(ULongToHandle(data->ThreadId), &Thread);
if (!NT_SUCCESS(status)) 
    break;

KeSetPriorityThread((PKTHREAD)Thread, data->Priority); 
ObDereferenceObject(Thread); 
KdPrint(("Thread Priority change for %d to %d succeeded!\n",
         data->ThreadId, data->Priority));

至此驱动程序和用户程序的大致流程都已经完成,完整的代码可以查看本学习笔记的Github仓库

4.6 部署与测试

可以查看第二章开始内核开发的2.3小节,流程大致相同。

3.1. 内核编程一般准则

用户编程和内核编程之间的差别

image-20211005221546000

3.1.1 未处理的异常

在用户模式下如果程序出现未处理的异常,整个程序会直接中止;在内核模式下出现未处理的异常,会造成系统奔溃,出现BSOD(蓝屏)。所以内核代码得非常小心,编译时绝对不能跳过任何细节和错误检查。image-20211005222156268

3.1.2 终止

当用户进程终止时不管是否正常终止,系统内核都保证了不会造成资源泄漏;但如果是内核驱动程序在卸载之后它此前所申请的系统资源不会被自动释放,只有等操作系统重启该资源才会释放。所以在编写驱动程序的时候应该妥善做好资源清理工作,自己申请的资源应该要自己释放。image-20211006162419071

3.1.3 函数返回值

image-20211006164239342

3.1.4 IRQL

在用户模式代码正在运行时,IRQL(中断请求级别 Interrupt Request Level)永远是0,在内核模式下大多数时间依旧是0,但并非永远是0,具体不为0造成的影响将在第六章讨论。

3.1.5 内核中C++的用法

在内核中没有C++ Runtime所以一些C++的特性就没法使用:

  • 不支持new和delete操作符,使用它们会导致编译失败。
  • 全局变量的构造函数不是默认的构造函数,则该构造函数不会被调用(有疑问日后填坑image-20211006195258069
  • C++异常处理的关键字(try,catch,throw)无法通过编译,因为这些关键字的实现需要C++ Runtime,在内核中我们只能用内核的SEH(结构化异常处理)。
  • C++标准库不能在内核中使用

3.2 内核API

内核API大多数都在内核本身模块(NtOskrnl.exe)实现,还有一些在如HAL(hal.dll)的其他内核模块中实现。

image-20211006200730814

在Ring3层,Zw 和Nt 是同一个函数,都是stub函数不做实际功能只是系统调用的入口;而在Ring0层,俩个函数是不同的函数,Zw*函数很短,调用Zw*函数会将PreviousMode设置成KernelMode(0),使Nt*函数绕过一些安全性和缓冲区的检查。所以驱动程序最好是调用Zw* 函数。

3.3 函数和错误代码

内核中函数的返回状态为NTSTATUS,STATUS_SUCCESS(0)表示成功,负值表示某种错误。在某些情况下从系统函数返回的NTSTATUS值最终会返回到用户模式,在用户模式我们可以通过GetLastError函数获得这些错误信息。

3.4 字符串

在内核API中很多地方需要用到字符串,某些地方就是简单的Unicode指针,但大多数用到字符串的函数使用的是UNICODE_STRING结构。image-20211006213612884

其中Length和MaximumLength是按字节(BYTE)计算的字符串长度。

3.5 动态内存分配

内核的栈相当小,因此任何大块的内存都必须动态分配,内核为驱动程序提供了俩种通用的内存池。image-20211006215532072

除非必要驱动程序里要尽可能少的使用非分页池,POOL_TYPE这个枚举类型表示内存池的类型。

image-20211006220746128

常见的内核内存池分配函数

image-20211006222945867

3.6 链表

在内核中很多内部的数据结构都使用了环形双向链表,例如,系统中所有的进程使用EPROCESS结构进行管理,这些结构就用一个环形双向链表链接在一起,其中链表的头部保存在PsActiveProcessHead这个内核变量中。所有的链表都使用LIST_ENTRY结构相互链接。

image-20211006223717094

LIST_ENTRY结构都是包含在一个更大的结构中,如果我们想要通过LIST_ENTRY反推它所在的父结构可以使用CONTAINING_RECORD宏(根据结构体中的某成员的地址来推算出该结构体整体的地址

3.7 驱动程序对象

在驱动程序入口函数DriverEntry接收了两个参数,第一个DRIVER_OBJECT/_DRIVER_OBJECT)是该驱动程序的对象,这个结构由内核分配并进行了部分初始化。所以我们驱动编程人员需要进一步帮助它进行初始化,在第二章我们就设置了驱动程序Unload所需要的函数,此外一个驱动程序还需要在初始化时设置派遣函数(Dispatch Routines),它位于MajorFunction这个指针数组中,指明了驱动程序支持哪些操作。

image-20211007104037193

派遣函数的设置方式

image-20211007105400544

IRP 与 派遣函数

该部分可能不是很好理解,在下一章通过代码进一步学习。

3.8 设备对象

如果驱动想要和应用程序进行通信,首先必须要生成一个设备对象(DEVICE_OBJECT)。设备对象暴露给应用层,应用层可以像操作文件一样操作它。用于和应用程序通信的设备对象常是用来"控制"这个内核驱动,所以往往被称之为"控制设备对象"(Control Device Object, CDO)。

这个设备对象需要有一个名字,这样才会被暴露出来,供其他程序打开与之通信。但是,应用层是无法直接通过设备的名字来打开对象的,必须建立一个暴露给应用层的符号链接。符号链接是记录一个字符串对应到另一个字符串的简单结构,可以和文件系统的快捷方式类比。

image-20211007112920369

2.1. 开发环境

我们使用VS2019 + WDK进行驱动开发,他们可以在Visual Studio Installer中进行安装

image-20211003165238474

2.2. 第一个驱动项目

安装完之后VS2019中选择WDM Empty Driver 模板。

image-20211003170238282

项目创建完成后有一个Sample.inf,本次Demo我们还不需要它先将它删除。在Source File中创建一个Sample.cpp文件,并写入以下代码:

#include <ntddk.h>

void SampleUnload(_In_ PDRIVER_OBJECT DriverObject) {

    KdPrint(("SampleUnload........."));
    UNREFERENCED_PARAMETER(DriverObject);
}

EXTERN_C
NTSTATUS 
DriverEntry(
    _In_ PDRIVER_OBJECT  DriverObject,
    _In_ PUNICODE_STRING RegistryPath
) 
{
    UNREFERENCED_PARAMETER(RegistryPath);

    DriverObject->DriverUnload = SampleUnload;

    return STATUS_SUCCESS;
}

我们来较为详细的讲解一下这份代码,首先是#include <ntddk.h> 这个就和我们传统C语言程序的 #include <stdio.h> 引入头文件;DriverEntry类比与main函数,是驱动程序的入口;因为驱动程序执行在内核空间,如果发生内存泄露比在应用层的程序要麻烦的多,所以我们需要一个卸载函数供这个驱动函数在卸载的时候使用,在本例中SampleUnload就是承担这个角色,并在DriverEntry给DriverObject->DriverUnload赋值即可;关于UNREFERENCED_PARAMETER宏的使用是因为在驱动编程中警告等级是4 参数未使用会无法通过编译,使用这个宏可以达到“被引用”的效果;EXTERN_C是因为DriverEntry 函数必须具备C语言方式链接,而默认是以C++方式链接所以需要使用这个宏显示定义;KdPrint是一个宏它本质调用DbgPrint,可以类比成printf函数起到对外打印,用DbgView可以查看。

2.3. 部署驱动程序

在64位的操作系统中,在启动驱动程序的时候需要该驱动程序有对应的数字签名,或者将系统设置成测试签名模式才能正常运行,我们一般可以使用亚洲诚信对刚才生成的驱动程序进行签名。

image-20211003195103726

部署一个驱动程序一般进过四个阶段:安装服务 -> 启动 -> 停止 -> 移除服务,这个就对应传统驱动加载工具。

image-20211003195326353

我们可以调用CreateService创建服务或者使用sc.exe工具进行创建,可以在HKLM\System\CurrentControlSet\Services\ 看到我们创建的服务,sc start/stop sample 启动或停止程序。

image-20211003130607129

image-20211003130742960

2.4. 课后练习

image-20211003202712503

#include <ntddk.h>

void SampleUnload(_In_ PDRIVER_OBJECT DriverObject) {

    KdPrint(("SampleUnload........."));
    UNREFERENCED_PARAMETER(DriverObject);
}

EXTERN_C
NTSTATUS 
DriverEntry(
    _In_ PDRIVER_OBJECT  DriverObject,
    _In_ PUNICODE_STRING RegistryPath
) 
{
    UNREFERENCED_PARAMETER(RegistryPath);

    DriverObject->DriverUnload = SampleUnload;

    RTL_OSVERSIONINFOW osvi;
    osvi.dwOSVersionInfoSize = sizeof(RTL_OSVERSIONINFOW);
    RtlGetVersion(&osvi);

    DbgPrint("RtlGetVersion");
    DbgPrint("The osvi.dwMajorVersion is: %u", osvi.dwMajorVersion);
    DbgPrint("The osvi.dwBuildNumber is: %u", osvi.dwBuildNumber);
    DbgPrint("The osvi.dwMinorVersion is: %u", osvi.dwMinorVersion);

    return STATUS_SUCCESS;
}

image-20211003135942451