12、注册表操作:打开与创建键、读写值、枚举子键、注册表回调监控

注册表操作,是驱动开发里绕不开的活儿。我刚开始做驱动时,总觉得注册表是应用层的事,后来被现实狠狠教育了一回——驱动里很多配置信息、启动参数、设备状态,都得靠注册表来存。说白了,注册表就是Windows的“神经中枢”,驱动和它打交道是家常便饭。

这一章,咱们就聊聊驱动里怎么操作注册表。包括打开键、创建键、读写值、枚举子键,还有那个比较高级的注册表回调监控。嗯,内容不少,但都是实战干货。

12.1 注册表操作的基础概念

在驱动开发中,注册表操作和用户态不太一样。用户态用RegOpenKeyEx、RegQueryValueEx这些API,驱动里则用Zw系列函数。为什么?因为驱动运行在内核态,需要直接和内核对象管理器打交道。

我个人习惯把注册表操作分为两类:

  • 自用配置:驱动自己的参数,比如日志级别、功能开关
  • 系统信息:读取硬件信息、系统设置等

不管哪一类,核心API就那么几个。咱们一个一个来。

12.2 打开与创建注册表键

先看怎么打开一个已有的键。最常用的函数是ZwOpenKey。它的原型长这样:

NTSTATUS ZwOpenKey(
    PHANDLE            KeyHandle,
    ACCESS_MASK        DesiredAccess,
    POBJECT_ATTRIBUTES ObjectAttributes
);

你看,它需要一个OBJECT_ATTRIBUTES结构体。这个结构体里要指定键的路径。路径怎么写?用InitializeObjectAttributes宏来初始化。

举个例子,打开\Registry\Machine\SYSTEM\CurrentControlSet\Services

HANDLE hKey = NULL;
OBJECT_ATTRIBUTES oa;
UNICODE_STRING usPath;

RtlInitUnicodeString(&usPath, L"\\Registry\\Machine\\SYSTEM\\CurrentControlSet\\Services");
InitializeObjectAttributes(&oa, &usPath, OBJ_KERNEL_HANDLE | OBJ_CASE_INSENSITIVE, NULL, NULL);

NTSTATUS status = ZwOpenKey(&hKey, KEY_READ, &oa);
if (NT_SUCCESS(status)) {
    // 操作成功,hKey就是打开的句柄
    // 用完记得 ZwClose(hKey);
} else {
    // 处理错误
}

这里有个坑,我踩过。路径前缀必须是\Registry\Machine\(对应HKEY_LOCAL_MACHINE)或\Registry\User\(对应HKEY_USERS)。别写成HKEY_LOCAL_MACHINE\...,那是用户态的写法。

注意:驱动里操作注册表,路径一定要用内核态格式。我曾经见过有人直接复制用户态的路径过来,结果ZwOpenKey一直返回STATUS_OBJECT_NAME_NOT_FOUND,排查了半天才发现是路径格式不对。

如果要创建新键,用ZwCreateKey。它比ZwOpenKey多了几个参数,可以指定键的创建方式:

NTSTATUS ZwCreateKey(
    PHANDLE            KeyHandle,
    ACCESS_MASK        DesiredAccess,
    POBJECT_ATTRIBUTES ObjectAttributes,
    ULONG              TitleIndex,
    PUNICODE_STRING    Class,
    ULONG              CreateOptions,
    PULONG             Disposition
);

CreateOptions可以填REG_OPTION_NON_VOLATILE(持久化键)或REG_OPTION_VOLATILE(内存键,重启消失)。Disposition会返回操作结果,告诉你键是新建的还是已存在的。

ULONG disposition;
status = ZwCreateKey(&hKey, KEY_WRITE, &oa, 0, NULL, 
                     REG_OPTION_NON_VOLATILE, &disposition);
if (NT_SUCCESS(status)) {
    if (disposition == REG_CREATED_NEW_KEY) {
        DbgPrint("新键创建成功\n");
    } else if (disposition == REG_OPENED_EXISTING_KEY) {
        DbgPrint("键已存在,直接打开\n");
    }
}

12.3 读写注册表值

打开键之后,就该读写值了。读值用ZwQueryValueKey,写值用ZwSetValueKey

先看读值。这个函数有点特殊,它需要你先查询一次获取数据大小,再查询一次获取实际数据。为什么?因为驱动里不能假设数据长度,得动态分配内存。

NTSTATUS ReadRegistryValue(HANDLE hKey, PCWSTR ValueName, PULONG pData) {
    UNICODE_STRING usValueName;
    RtlInitUnicodeString(&usValueName, ValueName);
    
    // 第一步:查询数据大小
    KEY_VALUE_PARTIAL_INFORMATION kvpi;
    ULONG resultLength = 0;
    NTSTATUS status = ZwQueryValueKey(hKey, &usValueName, KeyValuePartialInformation,
                                      &kvpi, sizeof(kvpi), &resultLength);
    
    if (status == STATUS_BUFFER_OVERFLOW || status == STATUS_BUFFER_TOO_SMALL) {
        // 分配足够大的缓冲区
        ULONG bufferSize = resultLength;
        PKEY_VALUE_PARTIAL_INFORMATION pKvpi = (PKEY_VALUE_PARTIAL_INFORMATION)ExAllocatePoolWithTag(
            NonPagedPool, bufferSize, 'gReT');
        if (!pKvpi) return STATUS_INSUFFICIENT_RESOURCES;
        
        // 第二步:实际读取
        status = ZwQueryValueKey(hKey, &usValueName, KeyValuePartialInformation,
                                 pKvpi, bufferSize, &resultLength);
        if (NT_SUCCESS(status) && pKvpi->Type == REG_DWORD) {
            *pData = *(PULONG)pKvpi->Data;
        }
        
        ExFreePoolWithTag(pKvpi, 'gReT');
    }
    
    return status;
}

写值就简单多了:

ULONG data = 0x12345678;
UNICODE_STRING usValueName;
RtlInitUnicodeString(&usValueName, L"MyValue");

status = ZwSetValueKey(hKey, &usValueName, 0, REG_DWORD, &data, sizeof(data));
if (!NT_SUCCESS(status)) {
    DbgPrint("写值失败,状态码: 0x%X\n", status);
}
小技巧:写值前最好检查一下键的访问权限。如果你用KEY_READ打开的句柄,写值肯定会失败。我习惯在创建键时就申请KEY_READ | KEY_WRITE,省得后面还要重新打开。

12.4 枚举子键

枚举子键,说白了就是遍历一个键下面所有的子键。这个在驱动里用得不多,但偶尔会用到,比如遍历某个服务下的所有参数键。

枚举用ZwEnumerateKey。它按索引号来获取子键信息,从0开始,直到返回STATUS_NO_MORE_ENTRIES为止。

void EnumerateSubKeys(HANDLE hKey) {
    ULONG index = 0;
    NTSTATUS status;
    
    do {
        // 先查子键名长度
        KEY_BASIC_INFORMATION kbi;
        ULONG resultLength = 0;
        status = ZwEnumerateKey(hKey, index, KeyBasicInformation,
                                &kbi, sizeof(kbi), &resultLength);
        
        if (status == STATUS_BUFFER_OVERFLOW || status == STATUS_BUFFER_TOO_SMALL) {
            ULONG bufferSize = sizeof(KEY_BASIC_INFORMATION) + kbi.NameLength;
            PKEY_BASIC_INFORMATION pKbi = (PKEY_BASIC_INFORMATION)ExAllocatePoolWithTag(
                NonPagedPool, bufferSize, 'mReT');
            if (!pKbi) break;
            
            status = ZwEnumerateKey(hKey, index, KeyBasicInformation,
                                    pKbi, bufferSize, &resultLength);
            if (NT_SUCCESS(status)) {
                // 子键名在 pKbi->Name 中,长度是 pKbi->NameLength(字节)
                DbgPrint("子键[%d]: %wZ\n", index, &pKbi->Name);
            }
            
            ExFreePoolWithTag(pKbi, 'mReT');
            index++;
        } else {
            break;
        }
    } while (NT_SUCCESS(status));
}

枚举值项用ZwEnumerateValueKey,用法和枚举子键几乎一样,只是信息类型换成KeyValueBasicInformation。我就不重复贴代码了,你照着上面的思路改一下就行。

12.5 注册表回调监控

这个功能比较高级,也是我个人觉得最有意思的部分。注册表回调监控,就是当有进程对注册表进行增删改查操作时,驱动能收到通知,甚至可以拦截或修改操作。

Windows提供了CmRegisterCallbackEx来注册回调函数。这个函数在Vista之后引入,比老版的CmRegisterCallback更灵活。

先看怎么注册:

LARGE_INTEGER altitude;
RtlInitUnicodeString(&Altitude, L"320000");  // 海拔高度,决定回调顺序
NTSTATUS status = CmRegisterCallbackEx(
    RegistryCallback,       // 回调函数
    &Altitude,              // 海拔
    NULL,                   // 驱动对象
    NULL,                   // 上下文
    &g_Cookie,              // 返回的Cookie,注销时用
    NULL                    // 保留
);

回调函数的原型:

NTSTATUS RegistryCallback(
    PVOID           CallbackContext,
    PVOID           Argument1,    // 操作类型
    PVOID           Argument2     // 操作相关数据
) {
    REG_NOTIFY_CLASS notifyClass = (REG_NOTIFY_CLASS)Argument1;
    
    switch (notifyClass) {
        case RegNtPreCreateKeyEx:
        case RegNtPreOpenKeyEx: {
            // 可以在这里检查或阻止键的创建/打开
            PREG_CREATE_KEY_INFORMATION pInfo = (PREG_CREATE_KEY_INFORMATION)Argument2;
            // 检查路径,决定是否允许
            break;
        }
        case RegNtPreSetValueKey: {
            // 写值前的回调
            PREG_SET_VALUE_KEY_INFORMATION pInfo = (PREG_SET_VALUE_KEY_INFORMATION)Argument2;
            // 可以修改值数据,或者直接拒绝
            break;
        }
        case RegNtPostCreateKeyEx:
        case RegNtPostOpenKeyEx: {
            // 操作完成后的通知,不能阻止,只能观察
            break;
        }
    }
    
    return STATUS_SUCCESS;  // 返回STATUS_ACCESS_DENIED可以阻止操作
}
核心要点:Pre类回调(如RegNtPreCreateKeyEx)可以阻止操作,返回STATUS_ACCESS_DENIED即可。Post类回调只能观察,不能干预。这个区别很重要,别搞混了。

注销回调用CmUnRegisterCallback

if (g_Cookie) {
    CmUnRegisterCallback(g_Cookie);
    g_Cookie = NULL;
}

12.6 实战:注册表操作流程图

说了这么多,我画个图帮你理清思路。这张图展示了驱动操作注册表的完整流程:

驱动注册表操作流程图 开始注册表操作 键已存在? (ZwOpenKey / ZwCreateKey) ZwOpenKey 打开已有键 ZwCreateKey 创建新键 选择操作类型 ZwQueryValueKey 读取值 ZwSetValueKey 写入值 ZwEnumerateKey 枚举子键 关闭句柄 (ZwClose) 可选:CmRegisterCallbackEx 监控

12.7 避坑指南

最后,我把自己踩过的坑总结一下,你写代码时多留个心眼:

  • 路径格式:驱动里路径用\Registry\Machine\,不是HKEY_LOCAL_MACHINE\。这个我一开始就搞错过。
  • 内存泄漏:ZwQueryValueKey和ZwEnumerateKey需要先查大小再分配内存,用完一定要ExFreePool。我曾经漏过一次,结果驱动卸载后内存还在涨。
  • 回调海拔:注册回调时海拔高度不能随便填。微软有规定的海拔范围,不同功能用不同区间。乱填可能导致和其他驱动冲突。
  • Pre回调别睡:Pre类回调里不要做耗时操作,比如等待事件、分配大内存。这些回调在同步路径上,阻塞会影响系统性能。
  • 句柄类型:InitializeObjectAttributes时加上OBJ_KERNEL_HANDLE标志,这样句柄只在内核态有效,更安全。

嗯,注册表操作这块就聊到这儿。内容不少,但核心就是那几个Zw函数,加上回调监控这个进阶玩法。你写代码时多练练,很快就能上手。

一句话总结:驱动操作注册表,记住ZwOpenKey/ZwCreateKey打开键,ZwQueryValueKey/ZwSetValueKey读写值,ZwEnumerateKey枚举子键,CmRegisterCallbackEx做监控。路径用内核格式,内存记得释放,回调别干重活。


公众号:蓝海资料掘金营,微信deep3321