内核驱动程序有一种强大的机制:在某些重要事件发生的时候得到通知
8.1 进程通知
驱动可以实时监测进程的创建与销毁,还能向调用者返回一个错误,阻止进程的创建
用于注册进程通知的 API:PsSetCreateProcessNotifyRoutineEx
NTSTATUS PsSetCreateProcessNotifyRoutineEx(
[in] PCREATE_PROCESS_NOTIFY_ROUTINE_EX NotifyRoutine, // 驱动程序通知回调例程
[in] BOOLEAN Remove // 指明注册(FALSE)还是取消注册(TRUE)
);
Windows10 新增了:PsSetCreateProcessNotifyRoutineEx2,可支持子系统微进程的监控
NTSTATUS PsSetCreateProcessNotifyRoutineEx2(
[in] PSCREATEPROCESSNOTIFYTYPE NotifyType,
[in] PVOID NotifyInformation,
[in] BOOLEAN Remove
);
系统限制最多注册 64 个回调,理论上注册函数可能失败
第一个参数是注册通知回调例程:
PCREATE_PROCESS_NOTIFY_ROUTINE_EX PcreateProcessNotifyRoutineEx;
void PcreateProcessNotifyRoutineEx(
[_Inout_] PEPROCESS Process, // 进程结构体
[in] HANDLE ProcessId, // 进程ID
[in, out, optional] PPS_CREATE_NOTIFY_INFO CreateInfo // 进程创建信息,进程销毁时为NULL
)
{...}
对于创建进程的监控,回调例程由正在被创建的线程执行
对于销毁进程的监控,回调例程由退出该进程的线程执行
回调例程都在关键区内部进行执行(禁用普通 APC)
用到回调的驱动程序需要在 PE 头中指定 IMAGE_DLLCHARACTERISTICS_FORCE_INTEGRITY
标志,可通过链接器命令行参数处指定:/integritycheck
能从进程获取到的信息 CreateInfo:
typedef struct _PS_CREATE_NOTIFY_INFO {
SIZE_T Size;
union {
ULONG Flags;
struct {
ULONG FileOpenNameAvailable : 1; // 影响ImageFileName字段
ULONG IsSubsystemProcess : 1; // 是否时微进程
ULONG Reserved : 30;
};
};
HANDLE ParentProcessId; // 父进程ID
CLIENT_ID CreatingThreadId; // 进程ID和线程ID
struct _FILE_OBJECT *FileObject;
PCUNICODE_STRING ImageFileName; // 进程映像名称,FileOpenNameAvailable被设置时有效
PCUNICODE_STRING CommandLine; // 进程创建的命令行
NTSTATUS CreationStatus; // 返回给调用者的状态,返回STATUS_ACCESS_DENIED可阻止进程创建
} PS_CREATE_NOTIFY_INFO, *PPS_CREATE_NOTIFY_INFO;
在访问每一个指针前务必检测值是否为 NULL
8.2 实现进程通知
从这里开始往下就是写代码的部分了,这里只介绍学到的新知识,完整代码发到 github 上了:
实现功能简介
本章实现的代码功能是进程线程映像加载的通知,会通过注册通知回调来获取事件信息,这里将获取到的信息发送给用户层程序,因为事件是不停产生的,在给用户程序之前,应该先存起来,然后等用户来进行接收,因为连续的内存很珍稀,所以这里采用链表来将事件信息存起来,等用户来取的时候,就把事件都给发出去
实现中的收获
这里使用了 c++11 的 scoped enum 类型作为通知类型的判断:这种类型有着强作用域的效果,比普通 enum 类型安全
enum class ItemType :short {
None,
ProcessCreate,
ProcessExit,
ThreadCreate,
ThreadExit,
ImageLoad
};
然后对于每一种通知事件,先抽象出了每个事件都共有的部分,然后再通过继承去编写每个事件独特的部分:
// 记录事件公有信息
struct ItemHeader {
ItemType Type; // 事件类型
USHORT Size; // 事件大小(用户能看到的大小)
LARGE_INTEGER Time; // 事件时间
};
//------用户模式能看到的数据信息--------
// 退出进程事件信息
struct ProcessExitInfo :ItemHeader {
ULONG ProcessId;
};
// 创建进程事件信息
struct ProcessCreateInfo :ItemHeader {
ULONG ProcessId; // 进程ID
ULONG ParentProcessId; // 父进程ID
USHORT CommandLineLength; // 命令行长度
USHORT CommandLineOffset; // 命令行所在偏移量
USHORT ImageFileNameLength; // 映像名长度
USHORT ImageFileNameOffset; // 映像名所在偏移量
};
如果使用 C 语言,可以模拟继承操作:
struct ProcessExitInfo :ItemHeader {
ItemHeader Header;
ULONG ProcessId;
};
因为这些数据是要存到链表上的,所以数据结构里需要有链表结构,但又因为用户模式也需要这个数据结构的声明,所以需要给这个结构再封装一下,作为内核使用的部分:使用泛型编程来减少代码冗余
template<typename T>
struct FullItem {
LIST_ENTRY Entry;
T Data;
};
链表中的成员结构定义好了,现在的问题就是把链表放在哪里了,这里定义了一个结构来作为链表头节点进行链表信息统计,在驱动里定义为全局变量使用即可:
// 保存驱动全部状态
struct Globals {
LIST_ENTRY ItemsHead; // 驱动链表头部
int ItemCount; // 项数
FastMutex Mutex; // 快速互斥体
};
因为会同时有大量的线程来创建链表,所以需要给插入链表的函数用互斥量进行封装一下,因为快速互斥量更快,所以就用快速互斥量了:这里的快速互斥量的用法来自第六章的 C++ RAII 包装
void PushItem(PLIST_ENTRY entry) {
// 拿到互斥量,函数结束自动释放
AutoLock<FastMutex> lock(g_Globals.Mutex);
// 链表模拟队列
// 如果数据量过大,就每增加一条就删掉一条旧数据
if (g_Globals.ItemCount > 1024) {
// 移除第一个成员
auto head = RemoveHeadList(&g_Globals.ItemsHead);
g_Globals.ItemCount--;
// 获取该成员首地址,进行内存释放
auto item = CONTAINING_RECORD(head, FullItem<ItemHeader>, Entry);
ExFreePool(item);
}
// 插入数据到链表尾部
InsertTailList(&g_Globals.ItemsHead, entry);
g_Globals.ItemCount++;
}
注册通知回调和注销回调都需要通过 API 进行(本章三种注册注销回调的 API 文末总结),DriverEntry 里注册,DriverUnload 里注销
DriverEntry 里除了初始化 Globals 结构的链表和快速互斥体以外,就是常规的创建驱动设备、创建符号链接,然后就是注册回调函数,设置分发例程了,没啥好说的,具体去看完整代码吧。
DriverUnload 里除了注销通知回调函数,删除符号链接和设备对象以外,还需要处理一下链表:
EXTERN_C
VOID
DriverUnload(
_In_ struct _DRIVER_OBJECT* pDriverObject
) {
PsSetCreateProcessNotifyRoutineEx(OnProcessNotify, TRUE);
PsRemoveCreateThreadNotifyRoutine(OnThreadNotify);
PsRemoveLoadImageNotifyRoutine(OnImageNotify);
UNICODE_STRING symLinkName = RTL_CONSTANT_STRING(SYMBOL_LINK_NAME);
IoDeleteSymbolicLink(&symLinkName);
IoDeleteDevice(pDriverObject->DeviceObject);
while (!IsListEmpty(&g_Globals.ItemsHead)) {
auto entry = RemoveHeadList(&g_Globals.ItemsHead);
ExFreePool(CONTAINING_RECORD(entry, FullItem<ItemHeader>, Entry));
}
}
进程通知回调函数的实现
进程通知回调函数参数里的 CreateInfo 的有无可判断是进程创建(有值)还是进程退出(NULL),不管是哪个,操作基本都一样:
- 计算申请内存大小(取决于事件结构大小),申请分页内存
- 向对应的事件结构填充数据
- 插入到链表
这里比较有趣的是,因为结构里有动态大小的值(进程命令行),这里通过结构里记录命令行的长度和偏移,然后多申请命令行长度的内存,把命令行字符串复制到整个事件结构体后面,用偏移标识该参数举例结构首地址的偏移来提供访问:
EXTERN_C
VOID
OnProcessNotify(
_Inout_ PEPROCESS Process,
_In_ HANDLE ProcessId,
_Inout_opt_ PPS_CREATE_NOTIFY_INFO CreateInfo
) {
if (CreateInfo) { // 进程创建时
// 获取数据结构大小
USHORT allocSize = sizeof(FullItem<ProcessCreateInfo>);
// 获取命令行+数据结构大小
USHORT CommandLineSize = 0;
if (CreateInfo->CommandLine) {
CommandLineSize = CreateInfo->CommandLine->Length +2 ;
allocSize += CommandLineSize;
}
// 获取映像名信息加到申请内存大小里
USHORT ImageFileNameSize = 0;
if (CreateInfo->ImageFileName) {
ImageFileNameSize = CreateInfo->ImageFileName->Length + 2;
allocSize += ImageFileNameSize;
}
// 申请内存
auto info = (FullItem<ProcessCreateInfo>*)ExAllocatePoolWithTag(PagedPool, allocSize, DRIVER_TAG);
if (info == nullptr) {
KdPrint((DRIVER_PREFIX "failed allocation!\r\n"));
return;
}
// 填充固定数据
auto& item = info->Data;
KeQuerySystemTimePrecise(&item.Time);
ExSystemTimeToLocalTime(&item.Time, &item.Time);
item.Type = ItemType::ProcessCreate;
item.Size = sizeof(FullItem<ProcessCreateInfo>) + CommandLineSize + ImageFileNameSize;
item.ProcessId = HandleToULong(ProcessId);
item.ParentProcessId = HandleToULong(CreateInfo->ParentProcessId);
// 填充命令行相关信息
if (CommandLineSize > 0) {
// 如果有命令行,就复制到结构的最后,填充相关信息
::memcpy((UCHAR*)&item + sizeof(item), CreateInfo->CommandLine->Buffer, CommandLineSize);
item.CommandLineLength = CommandLineSize / sizeof(WCHAR);
item.CommandLineOffset = sizeof(item);
}
else {
// 如果没命令行,就赋值0
item.CommandLineLength = 0;
item.CommandLineOffset = 0;
}
// 填充映像名相关信息
if (ImageFileNameSize > 0) {
::memcpy((UCHAR*)&item + sizeof(item) + CommandLineSize, CreateInfo->ImageFileName->Buffer,ImageFileNameSize);
item.ImageFileNameLength = ImageFileNameSize / sizeof(WCHAR);
item.ImageFileNameOffset = sizeof(item) + CommandLineSize;
}
else {
item.ImageFileNameLength = 0;
item.ImageFileNameOffset = 0;
}
// 插入到链表中
PushItem(&info->Entry);
}
else { // 进程退出时
// 申请空间
auto info = (FullItem<ProcessExitInfo>*)ExAllocatePoolWithTag(PagedPool, sizeof(FullItem<ProcessExitInfo>),DRIVER_TAG);
if (!info) {
KdPrint((DRIVER_PREFIX "failed allocation!\r\n"));
return;
}
// 数据填充
auto& item = info->Data;
KeQuerySystemTimePrecise(&item.Time);
ExSystemTimeToLocalTime(&item.Time, &item.Time);
item.Type = ItemType::ProcessExit;
item.ProcessId = HandleToULong(ProcessId);
item.Size = sizeof(ProcessExitInfo);
// 插入到链表中
PushItem(&info->Entry);
}
}
8.3 将数据提供给用户模式
这里只需要用户过来取数据,所以只需要一个读的操作即可,注册读的 IRP 派遣函数
这里呢拿到缓冲区、获取缓冲区大小,根据缓冲区大小,尽可能多的把存储在链表中的事件取出来,都复制到缓冲区给用户
EXTERN_C
NTSTATUS
SysMonRead(
_In_ struct _DEVICE_OBJECT* DeviceObject,
_Inout_ struct _IRP* Irp
) {
auto stack = IoGetCurrentIrpStackLocation(Irp);
auto len = stack->Parameters.Read.Length;
auto status = STATUS_SUCCESS;
auto count = 0;
NT_ASSERT(Irp->MdlAddress); // 这里使用的是直接I/O
// 拿到缓冲区
auto buffer = (PUCHAR)MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority);
if (!buffer) {
status = STATUS_INSUFFICIENT_RESOURCES;
}
else {
AutoLock<FastMutex> lock(g_Globals.Mutex);// 要是没拿到互斥量会咋样?
while (true) {
// 链表空了就跳出
if (IsListEmpty(&g_Globals.ItemsHead))
break;
// 从链表中拿走一项
auto entry = RemoveHeadList(&g_Globals.ItemsHead);
auto info = CONTAINING_RECORD(entry, FullItem<ItemHeader>, Entry); // 为啥是这个结构
auto size = info->Data.Size;
if (len < size) {
// 满了,再插进去,不拿了,跳出
InsertHeadList(&g_Globals.ItemsHead, entry);
break;
}
g_Globals.ItemCount--;
// 把拿走的那一项复制到缓冲区
::memcpy(buffer, &info->Data, size);
// 计算剩余空间
len -= size;
// 缓冲区往后移动
buffer += size;
// 计算拿走的数量
count +=size;
ExFreePool(info);
}
}
Irp->IoStatus.Status = status;
Irp->IoStatus.Information = count;
IoCompleteRequest(Irp, 0);
return status;
}
用户模式对获取到的缓冲区的处理则是:获取到缓冲区,和缓冲区填入数据的大小(驱动函数返回的那个 information),然后遍历缓冲区中的事件结构体,每处理完一个事件的信息,就从结构体中取出事件结构的大小,然后向后移动缓冲区指针,同时计算缓冲区剩余大小,当大小为 0 时,表示遍历结束
void DisplayTime(const LARGE_INTEGER& time) {
SYSTEMTIME st;
::FileTimeToSystemTime((PFILETIME)&time, &st);
printf("%02d:%02d:%02d:%03d ",st.wHour,st.wMinute,st.wSecond,st.wMilliseconds);
}
void DisplayInfo(PBYTE buffer, DWORD size) {
auto count = size;
while (count >0) {
auto header = (ItemHeader*)buffer;
switch (header->Type) {
case ItemType::ProcessExit: {
DisplayTime(header->Time);
auto info = (ProcessExitInfo*)buffer;
printf("【Process】【Exit】Process %06d Exited\r\n",info->ProcessId);
break;
}
case ItemType::ProcessCreate: {
DisplayTime(header->Time);
auto info = (ProcessCreateInfo*)buffer;
printf("【Process】【Create】Process %06d Created! ImageFileName: %ws CommandLine: %ws\n",info->ProcessId,(PWCHAR)(info->ImageFileNameOffset + buffer),(PWCHAR)(info->CommandLineOffset+buffer));
break;
}
default:
break;
}
buffer += header->Size; // 向后移动指针
count -= header->Size; // 计算剩余大小
}
}
8.4 线程通知
线程通知在刚刚进程通知的基础之上,主要是新增线程通知相关结构体、注册卸载线程通知的 API,以及线程通知回调函数即可:线程通知回调函数的处理和进程通知基本一致,就是填充结构体,不同的大概就是回调函数提供的参数不同了吧
EXTERN_C
VOID
OnThreadNotify(
_In_ HANDLE ProcessId,
_In_ HANDLE ThreadId,
_In_ BOOLEAN Create
) {
auto size = sizeof(FullItem<ThreadCreateExitInfo>);
PEPROCESS pEprocess = NULL;
PUCHAR ProcessImageFileName = NULL;
auto status = PsLookupProcessByProcessId(ProcessId,&pEprocess);
auto ProcessImageFileNameLength = 0;
if (NT_SUCCESS(status)) {
ProcessImageFileName = PsGetProcessImageFileName(pEprocess);
if (ProcessImageFileName) {
ProcessImageFileNameLength = strlen((const char*)ProcessImageFileName) +1;
size += ProcessImageFileNameLength;
}
}
auto info = (FullItem<ThreadCreateExitInfo>*)ExAllocatePoolWithTag(PagedPool, size, DRIVER_TAG);
if (!info) {
KdPrint((DRIVER_PREFIX "failed allocation!\r\n"));
return;
}
auto& item = info->Data;
KeQuerySystemTimePrecise(&item.Time);
ExSystemTimeToLocalTime(&item.Time, &item.Time);
item.Size = size;
item.Type = Create ? ItemType::ThreadCreate : ItemType::ThreadExit;
item.ProcessId = HandleToULong(ProcessId);
item.ThreadId = HandleToULong(ThreadId);
if (ProcessImageFileNameLength > 0) {
::memcpy((UCHAR*)&item + sizeof(item),ProcessImageFileName,ProcessImageFileNameLength);
item.ProcessImageFileNameLength = ProcessImageFileNameLength;
item.ProcessImageFileNameOffset = sizeof(item);
}
else {
item.ProcessImageFileNameLength = 0;
item.ProcessImageFileNameOffset = 0;
}
PushItem(&info->Entry);
}
8.5 映像载入通知
映像载入有通知回调机制,卸载却没有
具体操作类似线程通知:
EXTERN_C
VOID
OnImageNotify(
_In_opt_ PUNICODE_STRING FullImageName,
_In_ HANDLE ProcessId, // pid into which image is being mapped
_In_ PIMAGE_INFO ImageInfo
) {
auto size = sizeof(FullItem<ImageLoadInfo>);
auto ImageNameSize = 0;
if (FullImageName) {
ImageNameSize = FullImageName->Length + 2;
size += ImageNameSize;
}
auto info = (FullItem<ImageLoadInfo>*)ExAllocatePoolWithTag(PagedPool, size, DRIVER_TAG);
if (!info) {
KdPrint((DRIVER_PREFIX "failed allocation!\r\n"));
return;
}
auto& item = info->Data;
KeQuerySystemTimePrecise(&item.Time);
ExSystemTimeToLocalTime(&item.Time, &item.Time);
item.Size = size;
item.Type = ItemType::ImageLoad;
item.ProcessId = HandleToULong(ProcessId);
item.ImageBase = (ULONG)(ImageInfo->ImageBase);
if (ImageNameSize > 0) {
::memcpy((UCHAR*)&item + sizeof(item), FullImageName->Buffer, ImageNameSize);
item.ImageLoadPathLength = ImageNameSize;
item.ImageLoadPathOffset = sizeof(item);
}
else {
item.ImageLoadPathLength = 0;
item.ImageLoadPathOffset = 0;
}
PushItem(&info->Entry);
}
运行截图演示
8.6 总结
完整代码地址:ReadingNotesCode/WindowsKernelProgrammingBook/ch8_SysMon at main · kn0sky/ReadingNotesCode
作业就不在本文内容里写了,本章介绍了进程、线程、映像载入三种回调机制,三种回调的注册和卸载通过 API 进行:
注册函数 | 卸载函数 | |
---|---|---|
进程通知 | PsSetCreateProcessNotifyRoutineEx(OnProcessNotify, FALSE); | PsSetCreateProcessNotifyRoutineEx(OnProcessNotify, TRUE); |
线程通知 | PsSetCreateThreadNotifyRoutine(OnThreadNotify); | PsRemoveCreateThreadNotifyRoutine(OnThreadNotify); |
映像通知 | PsSetLoadImageNotifyRoutine(OnImageNotify); | PsRemoveLoadImageNotifyRoutine(OnImageNotify); |
注册了通知机制的回调函数,在事件发生时,就会从发生事件的线程上下文里进入对应的回调函数,进行处理,本章的处理仅仅是记录下来事件的内容,插入链表里供通信的时候发给用户模式
这里处理通知的收获以外,学到了 C++ RAII 的用法,对事件结构体的继承用法,可变长结构的使用,以及链表的使用。