-
-
[原创][原创][自学] RunOnceEx 注册表分组键的安全遍历
-
发表于: 5天前 1003
-
RunOnceEx 注册表分组键的安全遍历
RunOnceEx 的典型布局包含根键、一级分组子键和分组内的值。读取时要先确定根键与注册表视图,再分别枚举根键直接值和一级分组值。每条结果都要保留所在层级。来源和类型验证完成后,才能解释 Title、Flags 等数据。
一、对象关系和完整流程
RunOnceEx 的读取流程由注册表层级决定。根键可以直接保存值,也可以包含一级分组键。分组键中又可以保存自己的值。注册表 API 每次只枚举当前键的直接内容,不会自动进入下一层。
选择 HKLM 或 HKCU -> 选择 32 位或 64 位视图 -> 打开 RunOnceEx 根键 -> 枚举根键直接值 -> 枚举一级分组名 -> 逐个打开分组 -> 枚举分组内的值 -> 按类型解释原始字节
1. 注册表用键和值保存系统与用户设置
读取层级前要先分清键和值。Windows 注册表是由多个配置单元组成的层级数据库。HKEY_LOCAL_MACHINE(HKLM)保存计算机范围的设置。HKEY_CURRENT_USER(HKCU)保存当前登录用户的设置。每个键可以包含子键,也可以包含多个命名值。每个值由名称、类型和原始字节组成。
路径 HKLM\Software\Microsoft\Windows\CurrentVersion\RunOnceEx\0001 中,RunOnceEx 与 0001 都是键名。0001 键里的 Title、Flags 或自定义名称才是值名。读取程序先打开键,再从该键读取直接值。下一层对象不会自动出现在当前层的枚举结果中。
下表中的数据类型决定了读取程序应如何解释字节。类型信息必须和数据一起保存,脱离类型直接把缓冲区转换为字符串会损坏数值、二进制数据和多字符串数据。
| 类型 | 原始内容 | 合理的读取方式 |
|---|---|---|
REG_SZ |
一段 UTF-16 文本 | 按返回的字节数构造宽字符串,移除一个真实存在的结尾 NUL。 |
REG_EXPAND_SZ |
可能含 %SystemRoot% 等环境变量的 UTF-16 文本 |
先保留原文。需要展示展开结果时,再用环境变量展开 API 得到另一份文本。 |
REG_DWORD |
恰好 4 字节的无符号整数 | 检查长度为 4 后用 DWORD 读取,并按十进制或十六进制展示。 |
REG_MULTI_SZ |
多段 UTF-16 文本,以额外 NUL 结束 | 逐段解析,不能当作只有一条命令的普通字符串。 |
REG_BINARY |
没有统一语义的字节序列 | 保留长度与十六进制表示,不把它假设为文本。 |
2. RunOnceEx 是一类安装阶段使用过的分组配置位置
RunOnceEx 是带一级分组的配置结构。传统 Run 和 RunOnce 直接在一个键下用值名对应命令。RunOnceEx 还可以建立一级分组键,把标题、标志和多个条目放入同一组。不同 Windows 版本和安装程序可能使用不同的登录初始化方式。某个键存在,只能证明配置存在,不能证明当前系统会处理其中每一项。
RunOnceEx 的根键通常出现在两个范围。HKLM 中的键面向计算机级设置,读取时可能需要更高权限。HKCU 中的键面向当前用户,普通用户通常能读取自己的配置。两个位置要独立枚举。一个位置读取失败时,另一个位置仍要继续处理。
RunOnceEx 根键
根键直接值
ValueName = ValueData
0001 一级分组键
Title = "组件初始化"
Flags = DWORD
001 = "命令文本"
0002
...
这个层级关系决定了记录格式。每条结果至少要保存根配置单元、软件视图、根路径、分组名、值名、类型和原始长度。完整的位置字段可以准确区分 0001\001 与 0002\001。
3. 32 位和 64 位程序看到的软件视图可能不同
HKLM 软件键还要明确注册表视图。64 位 Windows 为部分 HKLM\Software 内容维护 32 位和 64 位视图。32 位程序默认打开 32 位视图,64 位程序默认打开 64 位视图。完整检查需要分别使用 KEY_WOW64_64KEY 和 KEY_WOW64_32KEY。只读取程序默认视图时,不要加入这两个标志。
KEY_WOW64_64KEY 与 KEY_WOW64_32KEY 只影响可重定向的注册表区域,不能用于推断系统位数,也不能对 HKCU 的所有路径强行套用。结果中标记“64 位视图”或“32 位视图”能够避免把两个不同对象误合并为同一项。
4. 根键直接值与分组值的读取顺序应独立保存
根层和分组层需要分别记录状态。打开 RunOnceEx 根键后,先枚举它的直接值,再枚举一级子键。每个一级子键都要独立打开,然后枚举其中的值。根键没有分组、某个分组无法打开、分组存在但没有值,都是不同的配置事实。
根键直接值可以与分组内的值同名。例如,根键的 Install 和 0001\Install 是两个位置不同的注册表值。记录应包含根配置单元、软件视图、根路径、可选分组名、值名、REG 类型、原始数据长度和读取状态。缺少其中任何一个位置字段,都可能让同名项相互覆盖。
5. 名称、数据和类型决定字符串解码是否安全
值解码前要先检查类型和长度。RegEnumValueW 返回的值名长度以 UTF-16 宽字符数计算,值数据长度以字节计算。名称缓冲区要额外预留一个 NUL,数据缓冲区则按字节分配。REG_SZ 与 REG_EXPAND_SZ 的数据长度能被 sizeof(wchar_t) 整除时,才能按 UTF-16 解码。结尾 NUL 只在真实存在时移除一个。
REG_DWORD 需要恰好四字节才能读取为数值。REG_MULTI_SZ 需要按单 NUL 分段,并以双 NUL 结束。REG_BINARY 没有通用文本语义。未知值名和未知类型同样属于枚举结果,也要保存类型和原始长度。
6. 并发写入会改变枚举容量和索引含义
注册表内容可能在枚举期间变化。RegQueryInfoKeyW 返回的最大名称和数据长度,只能作为当前时刻的初始容量。后续 RegEnumKeyExW 或 RegEnumValueW 返回 ERROR_MORE_DATA 时,要扩大对应缓冲区,再重试同一索引。只有 ERROR_NO_MORE_ITEMS 表示本轮枚举结束。访问拒绝和键删除要单独记录,并继续处理同级其它项。
索引只用于当前一轮 API 调用。比较两次读取结果时,要按完整键路径、分组名和值名配对,并保留每次的时间、视图与类型。分组改名、插入或删除后,原来的索引可能代表另一个对象。
7. HKEY、REGSAM 与访问失败的语义也应成为输出的一部分
HKEY 是成功打开注册表键后由调用方持有的资源引用,使用结束后要用 RegCloseKey 释放。REGSAM 是本次请求的访问掩码,决定能否查询值或枚举子键。只读遍历请求 KEY_QUERY_VALUE、KEY_ENUMERATE_SUB_KEYS 和明确的 WOW64 视图,不请求写入、创建或删除权限。
根键打开失败、分组打开失败、值缺失和缓冲区不足不能合并为一个空结果。每种失败都携带完整路径、视图、请求权限和 LSTATUS 返回值,读者才能知道是配置不存在、当前令牌不可读,还是注册表在读取过程中发生变化。
采样时间与调用账户也要记录。它们说明读取结论适用的系统时刻和权限环境。完整保留这些字段后,才能比较同一位置在不同时间的变化。
这一记录模型不依赖固定的分组名和值名,因此也适用于未知配置。后续审阅时,每条结果都能回到原始位置和读取状态。
二、先保留根键和分组键的边界
1. 一条值需要完整位置
层级识别要从 RunOnceEx 根键开始。常见根路径为:
HKLM\Software\Microsoft\Windows\CurrentVersion\RunOnceEx
HKCU\Software\Microsoft\Windows\CurrentVersion\RunOnceEx
分组值的位置还要包含一级分组名。根键中的 Install 与 RunOnceEx\0001 中的 Install 是不同值。完整位置应包含根键、32 位或 64 位视图、根路径、分组名和值名。
数字分组名不能直接解释为处理顺序。0001、0002 只是子键名称,注册表 API 返回的索引也只属于本次读取。没有对应规范时,不能靠字典序推导系统处理次序。
三、先打开根键,再读取直接值和一级分组
2. 打开键时只申请查询和子键枚举权限
打开键的宽字符 API 会返回一个新的 HKEY。返回 ERROR_SUCCESS 表示成功,KEY_QUERY_VALUE 允许读取直接值,KEY_ENUMERATE_SUB_KEYS 允许读取一级分组名。
// 意义:打开 hKey 下的一个现有键并返回新 HKEY。
// 返回:ERROR_SUCCESS 表示成功。其它 LSTATUS 表示键缺失、权限不足等错误。
LSTATUS RegOpenKeyExW(
HKEY hKey, LPCWSTR lpSubKey, DWORD ulOptions,
REGSAM samDesired, // 输入:KEY_QUERY_VALUE、KEY_ENUMERATE_SUB_KEYS 和 KEY_WOW64_*。
PHKEY phkResult // 输出:成功时得到的键句柄,使用后关闭。
);
// 意义:关闭成功打开的 HKEY。
// 返回:ERROR_SUCCESS 表示成功。一个打开句柄只能关闭一次。
LSTATUS RegCloseKey(HKEY hKey);
正确做法:显式指定 HKLM 软件视图。根键读取成功后,先处理直接值,再进入一级分组。某个分组失败时,仍要继续处理其它分组。
HKEY rootKey = nullptr;
const LSTATUS status = RegOpenKeyExW(
HKEY_LOCAL_MACHINE,
L"Software\\Microsoft\\Windows\\CurrentVersion\\RunOnceEx",
0,
KEY_QUERY_VALUE | KEY_ENUMERATE_SUB_KEYS | KEY_WOW64_64KEY,
&rootKey);
if (status == ERROR_SUCCESS) {
// 先枚举 rootKey 的直接值,再枚举 rootKey 的一级子键。
RegCloseKey(rootKey);
}
错误示例:只枚举根键值。根键的值枚举操作不会递归进入子键。下面的循环没有进入一级分组,因此无法读取 RunOnceEx\0001 内部的值。
// 错误:只处理 rootKey 的直接值,缺少“枚举一级分组并打开分组”的步骤。
for (DWORD index = 0; index < directValueCount; ++index) {
// 这里只会得到根键层的值。
}
3. 查询容量时分开处理子键名和值数据
查询键信息的宽字符 API 返回子键和值的最大长度。最长子键名与最长值名以宽字符个数计算。最长值数据以字节计算。两个名称长度都不包含结尾 NUL。
// 意义:读取 hKey 的直接子键和值的统计信息与容量上限。
// 返回:ERROR_SUCCESS 表示成功。其它 LSTATUS 表示查询失败。
LSTATUS RegQueryInfoKeyW(
HKEY hKey, LPWSTR lpClass, LPDWORD lpcchClass, LPDWORD lpReserved,
LPDWORD lpcSubKeys, // 输出:一级子键数量。
LPDWORD lpcMaxSubKeyLen, // 输出:最长一级子键名字符数,不含 NUL。
LPDWORD lpcMaxClassLen, LPDWORD lpcValues,
LPDWORD lpcMaxValueNameLen, // 输出:最长值名字符数,不含 NUL。
LPDWORD lpcMaxValueLen, // 输出:最长值数据字节数。
LPDWORD lpcbSecurityDescriptor, PFILETIME lpftLastWriteTime
);
正确做法:给名称多预留一个宽字符,数据则按字节分配。根键和每个分组键都需要单独查询上限。根键的最大值长度不能代替分组内的最大值长度。
DWORD maxGroupNameChars = 0;
const LSTATUS status = RegQueryInfoKeyW(
rootKey, nullptr, nullptr, nullptr,
nullptr, &maxGroupNameChars, nullptr,
nullptr, nullptr, nullptr, nullptr, nullptr);
if (status != ERROR_SUCCESS) {
return;
}
std::vector<wchar_t> groupName(maxGroupNameChars + 1, L'\0');
错误示例:遗漏名称终止符。最大长度分组名恰好填满 maxGroupNameChars 个字符时,下面的缓冲区没有位置保存 NUL。
// 错误:最长名称长度不包含 NUL。
std::vector<wchar_t> groupName(maxGroupNameChars);
四、枚举一级子键,再枚举每个子键中的值
4. 子键枚举 API 只返回一级名称
按索引读取子键名的宽字符 API 返回当前键的直接子键。返回 ERROR_NO_MORE_ITEMS 表示枚举结束,ERROR_MORE_DATA 表示名称缓冲区不足。
// 意义:读取 hKey 下索引为 dwIndex 的一个直接子键名。
// 返回:ERROR_SUCCESS 表示成功。ERROR_NO_MORE_ITEMS 表示正常结束。
// ERROR_MORE_DATA 表示容量不足。
LSTATUS RegEnumKeyExW(
HKEY hKey, DWORD dwIndex, LPWSTR lpName,
LPDWORD lpcchName, // 输入/输出:名称字符容量与实际长度,不含 NUL。
LPDWORD lpReserved, LPWSTR lpClass, LPDWORD lpcchClass,
PFILETIME lpftLastWriteTime
);
正确做法:用返回的分组名打开子键,再单独枚举其中的值。子键名使用显式长度构造 std::wstring,然后通过 c_str() 提供 NUL 终止路径。
DWORD groupChars = static_cast<DWORD>(groupName.size());
const LSTATUS enumStatus = RegEnumKeyExW(
rootKey, groupIndex, groupName.data(), &groupChars,
nullptr, nullptr, nullptr, nullptr);
if (enumStatus == ERROR_SUCCESS) {
const std::wstring groupPath(groupName.data(), groupChars);
HKEY groupKey = nullptr;
if (RegOpenKeyExW(rootKey, groupPath.c_str(), 0, KEY_QUERY_VALUE, &groupKey) == ERROR_SUCCESS) {
// 查询 groupKey 自己的值长度上限,再枚举 groupKey 内部的直接值。
RegCloseKey(groupKey);
}
}
错误示例:把分组名拼进固定数组。异常长分组名或截断路径会让后续打开的键偏离原始对象。固定的 wchar_t path[260] 和 wcscat 不适合处理未经长度验证的 API 返回数据。
五、按名称和类型识别分组元数据
5. Title 与 Flags 不能当成同一种文本
元数据要同时检查值名和类型。Title 常作为分组文本说明,Flags 常作为 REG_DWORD 数值。未知值也要保留原始名称、类型和字节,不能因为名称不在已知列表中就丢弃。
REG_DWORD 必须恰好为四字节。REG_BINARY 可能包含可显示片段,REG_MULTI_SZ 也由宽字符组成。这两种类型都不能按单个命令文本解释。
只有 REG_SZ 与 REG_EXPAND_SZ 可以按实际字节长度构造 UTF-16 文本。奇数字节长度无法形成完整宽字符。末尾 NUL 只在真实存在时移除一个。不要使用 wcslen 搜索缓冲区外的终止符。
遇到 ERROR_MORE_DATA 时,要重新查询根键或分组键的当前上限。重新分配缓冲区后,有限次数重试同一索引。连续失败时记录位置和返回码,再继续处理同级其它对象。
六、保持结论的时间边界
6. 枚举索引不等于系统处理顺序
索引只能作为本次读取的定位信息。子键和值可能在读取期间增加、删除或改名。索引不能跨两次读取作为稳定身份,也不能代表 RunOnceEx 的实际处理顺序。
注册表状态和实际活动需要分别判断。某个分组或值在本次读取中存在,只能证明它位于对应根键、视图和路径。它是否被系统处理,目标是否完成,需要独立的进程或事件证据。
完整可运行程序在附件。

[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。