分类 信息安全 下的文章

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

本文为《Windows内核编程》(Windows Kernel Programming - Pavel Yosifovich)的学习笔记第一章Windows内部概览,主要内容参考本书中英文版本。

1.1 进程

进程拥有的内容:

  • 一个可执行程序
  • 一段虚拟地址空间
  • 一个主令牌
  • 一个存放执行对象的私有句柄表
  • 一个或多个执行线程
  • 一个唯一标识(PID)

image-20210927200914642

1.2 虚拟内存

地址空间从0开始(第一个64KB的地址是不能被分配和使用的),一直增长到进程“位数”和操作系统“位数”限制的最大值。

image-20210927202343690

每个进程都有自己的地址空间,这个地址空间中所得到地址都是虚拟地址,它是与进程密切相关的地址,而不是物理内存中的实际地址。进程中的一块地址空间可能被映射到物理内存中也可能临时存放在文件中(PageFile)。

image-20210927203803381

作为程序员我们没有必要知道,代码将要访问或者执行的地址空间是不是映射到物理内存上,如果是映射到物理内存中CPU则继续执行进行读取操作;如果存放在页面文件中则CPU会触发一个PageFault异常,内存管理器的PageFault处理程序会从适当的文件中读出数据拷贝到RAM中。

内存以页为单位进行管理。详见保护模式笔记中页的机制部分

1.2.1 页状态

虚拟内存中的每个页面处于以下三种状态之一:

  • 空闲:页面没有被分配。
  • 提交:已提交的页面通常被映射到RAM或者文件中。
  • 保留:页面未提交,但保留了地址范围供以后可能发生的提交操作使用。

image-20210927212916107

1.2.2 系统内存

系统内存空间与进程是无关的,此处对应本节开始对虚拟内存的介绍,从0到进程和操作系统限制的最大值之间的虚拟内存为用户空间,此外的空间为系统内存空间。系统空间中的任何地址都是绝对地址而不是相对地址,用户模式访问系统空间地址会导致access violation 异常。

image-20210928182907958

系统空间是操作系统内核所在之处,HAL和驱动程序在加载之后也在这段空间内。

1.3 线程

线程是执行代码的真正实体,线程位于进程之内。在线程中最重要的信息如下:

image-20210929201814396

线程常见的状态:

image-20210929202009517

1.3.1 线程栈

每个线程在执行的时候都会有一个线程栈空间用来存放局部变量,函数参数和函数返回地址。所有线程都至少拥有一个位于系统内核空间的栈。如果线程需要更多的栈空间,它会写到GUARD PAGE产生一个异常并被内存管理器处理,使需要的栈空间能够被获得,所以所需要的全部栈内存不用事先全部提交。

image-20210929210725545

1.4 系统服务(系统调用)

当处于用户态的应用程序需要分配内存,打开文件,创建线程等操作就需要用到系统服务了。在下图中的n为系统服务号,通过特殊的CPU指令(x64系统中为syscall,x86系统为sysenter)来实际跳转到内核模式,并跳转到系统服务分发器(system service dispatcher),在分发器中使用EAX的值作为SSDT(System Service Dispatch Table)的索引,跳转至对应的系统服务(系统调用)执行实际的分配内存,打开文件,创建线程等操作。

image-20210929211859298

1.5 系统总体框架

image-20210929212805960

各个模块介绍留坑,后续填

1.6 句柄和对象

Windows内核中提供了多种类型的对象供用户模式进程,内核本身和内核驱动程序使用。这些类型的对象是位于系统内核空间的数据结构,由对象管理器在用户模式还在内核模式代码请求创建。这些对象都位于系统内核空间中用户层代码不能直接访问,所以需要一种间接的访问机制:句柄对这些对象进行获取。

句柄是指向一个表格(句柄表)的入口索引,该表格在进程的基础上进行维护,逻辑上指向驻留在系统空间的内核对象。

img

句柄的值一般是4的倍数,第一个有效的句柄是4,0永远都不是有效的句柄值。所有的对象都是引用计数的,由对象管理器维护着句柄计数和对象引用总数,只有当引用计数为0的时候,对象才会真正从内存中释放出来。

1.6.1 对象名称

一些类型的对象可以有名称,可以通过Open*函数使用名称打开对象,但是比如进程和线程就没有名称,进程和线程则是靠进程/线程标识符去打开对应对象,需要注意的是,文件是无名称对象,文件的文件名不是 对象名称。名称空间的层次结构可以使用WinObj工具查看。

image-20211001201328436

当然通过WinObj能看到的并不是所有纯在的对象,而是所有使用名称创建的对象。

对于前文提到每个进程中都有一个私有的指向内核对象的句柄表,我们可以使用ProcessExplorer的ViewHandles功能查看对应进程的私有句柄表。

image-20211001202409975

程序节区重新映射时设置Section属性为SEC_NO_CHANGE,避免代码被inline hook和设置断点调试。

本方法主要参考 https://github.com/changeofpace/Self-Remapping-Code 这个GitHub项目,作者在19年重构了一版但是没有放到release上。

0x01 编译注意事项

  1. 因为在使用NtMapViewOfSection函数时基址或文件的偏移量参数需要和系统分配颗粒度对齐,这个颗粒度一般为0x10000,所以在项目配置中设置节区对齐大小 #pragma comment(linker, "/ALIGN:0x10000")
  2. 在x64环境下调用相关导入表函数多为 CALL + 偏移,而x86环境是直接CALL导入表地址,但是在Unmap原始IMAGE的节区空间之后原先导入表地址访问是不合法的,所以想要代码在x86环境下运行就需要做重定位,使用新VirtualAlloc后申请的地址内容。
  3. 在属性选项中需要关闭符合模式

image-20210916115817386

0x02 原理介绍

使用未文档化的SEC_NO_CHANGE属性调用NtCreateSection函数创建一个Section

image-20210916152810251

映射Section并复制原始模块代码至映射的Section空间

image-20210916152901621

然后使用NtUnmapViewOfSection把原始IMAGE的节区空间和映射的Section空间给Unmap掉

image-20210916152928554

最后在循环重新映射刚才被Unmap的Section空间到原始模块代码内存地址空间。

image-20210916153059925

0x03 效果

image-20210916155828713

0x04 参考链接

NTSTATUS Values

ZwUnmapViewOfSection function (wdm.h)

ZwMapViewOfSection function (wdm.h)

Self-Remapping Code

0x05 修改后的代码

https://github.com/Jevon101/Self-Remapping-Code