Winlogon 登录相关注册表值与 Notify 子键解析
相关内容已在KswordARK中实现,项目地址:github.com/KSwordDEV/K... 
本节要完成的目标是完整读取 Winlogon 登录相关的配置,并保留每项数据的键路径、名称、类型、实际长度和读取状态。要达到这个目标,分为四步。
- 确定机器级和当前用户 Winlogon 键中的固定值,以及
Notify子键的层级。 - 用最小只读权限打开目标键,并区分键缺失、值缺失和读取失败。
- 按实际字节长度和注册表类型读取固定值,并保留
Userinit的逗号边界。 - 逐层枚举
Notify的子键和值,记录每个条目的类型、长度和错误状态。
一、对象关系和完整流程
交互式登录由多个 Windows 组件协作完成。用户完成身份验证后,系统建立用户会话、加载用户配置、启动 Userinit,再由 Shell 提供桌面环境。Winlogon 位于这条路径中,并读取部分登录初始化相关配置。
text
用户输入凭据 -> 登录界面与身份验证组件 -> Winlogon 建立用户会话 -> Userinit 初始化用户环境 -> Shell 启动桌面外壳
读取从 Winlogon 键开始。固定值、逗号分隔的 Userinit 文本和每个 Notify 子键中的直接值需要按不同规则保存。
text
打开 Winlogon 键 -> 读取固定值 -> 保存 Shell 与 Userinit 原文 -> 打开 Notify -> 枚举一级子键 -> 枚举每个子键的值 -> 记录类型、长度和错误状态
1. 登录过程由多个系统组件依次参与
Winlogon 配置需要放回交互式登录流程理解。用户在锁屏界面或登录界面完成身份验证后,Windows 为该用户建立会话、加载用户配置单元、启动初始化程序,并启动桌面外壳。Winlogon 是参与这一路径的系统组件之一。它读取部分登录初始化配置,并协调用户会话中的后续组件。
下面的简化关系图只表达主要对象之间的职责,不表示每个 Windows 版本都严格按同一内部顺序调用。版本差异、组策略、凭据提供程序和系统安全功能都会影响具体行为。
Shell 与 Userinit 的值之所以重要,是因为它们分别描述桌面外壳文本和用户初始化命令文本。读取它们只能得到注册表当时保存的原文。实际执行是否成功还受到文件存在性、命令行语义、系统策略、会话状态和版本行为影响。
2. 键、值和 Notify 子键对应不同层级的配置
Winlogon 键的直接值与 Notify 子键属于不同层级。HKLM\Software\Microsoft\Windows NT\CurrentVersion\Winlogon 是一个注册表键。Shell、Userinit 和 Taskman 是该键的直接值。Notify 是 Winlogon 下的子键。Notify 内部还可能有多个命名子键,每个子键再保存独立值。
text
Winlogon(键)
Shell(值)
Userinit(值)
Taskman(值)
Notify(键)
子键 A
DLLName(值)
其它事件相关值
子键 B
...
层级差异决定 API 调用顺序。直接值由 RegQueryValueExW 或 RegEnumValueW 读取。子键名称由 RegEnumKeyExW 读取。得到子键名称后还要 RegOpenKeyExW 打开子键,才能访问它自己的值。把一个 Notify 子键名传给值查询函数不会自动进入该子键。
3. 某些历史值存在兼容性意义,需要保留版本边界
历史固定值需要保留版本边界。GinaDLL 关联早期图形化身份验证架构。现代 Windows 的身份验证扩展机制由 Credential Provider 等组件承担。Notify 也有明显的历史与版本差异。读取结果仍应展示已存在的值或子键,并说明它们的语义取决于系统版本和兼容性设置。
AppSetup、VmApplet、Taskman 等固定值也不应仅因名称存在就推导当前登录过程一定会使用它们。合理的输出同时包含键路径、值名、注册表类型、原始文本或字节、读取时间和适用系统上下文,方便与后续证据交叉比对。
二、第一步:确定要读取的层级
固定值位于 Winlogon 键的第一层。Shell、Userinit、Taskman、VmApplet、GinaDLL、AppSetup 都是值名。它们共享一个父键,读取方式相同,缺失的含义却不同。
Shell 保存用户登录后使用的 Shell 文本。常见内容是 explorer.exe,读取结果应保留原始字符串,不能把它直接当作已经验证过的文件路径。
Userinit 保存用户初始化命令文本。常见内容形如 C:\Windows\System32\userinit.exe,,最后逗号属于原始配置的一部分。拆分后会得到一个命令片段和一个空片段。
Taskman、VmApplet、GinaDLL、AppSetup 具有版本和兼容性差异。某个值未设置是可见的配置状态,读取程序应继续检查后续值,不能把它报告成父键读取失败。
当前用户位置也可能有 HKCU\Software\Microsoft\Windows NT\CurrentVersion\Winlogon 下的 Shell 和 Userinit。机器级与当前用户值需要分别保存根键和完整键路径,两个同名值不能按名称合并。
Notify 是 Winlogon 下的子键集合。每个一级子键本身是一个配置单元,DLLName 是其中常见的模块值,其它已有值也要逐个保留。仅仅读取 DLLName 会遗漏该子键内实际存在的事件配置值。
完成第一步后,已经知道固定值位于哪个键,也知道 Notify 需要按子键再按值读取。接下来需要打开这些位置,并把权限问题与配置缺失分开记录。
三、第二步:用只读权限打开 Winlogon 键
打开注册表键的函数接收根键、相对路径和访问权限,成功时返回一个需要关闭的键句柄。
cpp
// 意义:打开一个已有注册表子键。
// 返回:ERROR_SUCCESS 表示成功。其它返回值是 Win32 错误码。
// 成功时 phkResult 接收 HKEY,调用方随后必须调用 RegCloseKey。
LSTATUS RegOpenKeyExW(
HKEY hKey, // 预定义根键或已打开的父键。
LPCWSTR lpSubKey, // 相对键路径,可以为 nullptr。
DWORD ulOptions, // 保留,必须为 0。
REGSAM samDesired, // 所需权限,例如 KEY_QUERY_VALUE。
PHKEY phkResult // 接收新句柄的输出指针。
);
// 意义:关闭由 RegOpenKeyExW 等函数取得的注册表键句柄。
// 返回:ERROR_SUCCESS 表示成功。预定义根键不需要也不应在这里关闭。
LSTATUS RegCloseKey(HKEY hKey);
只读查询需要申请最小权限。固定值只需要 KEY_QUERY_VALUE。枚举 Notify 时再额外申请 KEY_ENUMERATE_SUB_KEYS,这样权限不足会准确落在对应操作上。
cpp
HKEY rawKey = nullptr;
const LSTATUS status = RegOpenKeyExW(
HKEY_LOCAL_MACHINE,
L"Software\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon",
0,
KEY_QUERY_VALUE | KEY_ENUMERATE_SUB_KEYS,
&rawKey);
if (status == ERROR_SUCCESS) {
// 这里只读取 rawKey,结束后由作用域对象或下方语句关闭。
RegCloseKey(rawKey);
}
固定值读取要区分三种状态。父键不存在时记录"键不存在"。父键存在而 Shell 等值不存在时记录"值未设置"。API 返回访问拒绝或其它错误时才记录错误码。
cpp
// 错误示例:把 ERROR_FILE_NOT_FOUND 一律视为同一种失败。
// 这样无法分辨父键缺失与其中一个固定值缺失。
if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, path, 0, KEY_QUERY_VALUE, &key) != ERROR_SUCCESS) {
return;
}
完成第二步后,已经获得带有查询权限的键句柄,也能准确保留键不存在、值未设置和读取失败这三种状态。接下来需要按 API 返回的真实长度读取数据,避免把字符串或二进制内容截断。
四、第三步:按字节长度读取固定值并保留 Userinit 边界
查询注册表值的函数使用字节数描述数据缓冲区。lpcbData 在第一次调用时接收所需字节数,在第二次调用时既提供缓冲区容量又返回实际字节数。
cpp
// 意义:读取注册表值的类型和原始数据。
// 返回:ERROR_SUCCESS 表示成功。ERROR_FILE_NOT_FOUND 表示值名不存在。
// ERROR_MORE_DATA 表示提供的 lpData 缓冲区容量不足。
LSTATUS RegQueryValueExW(
HKEY hKey, // 已打开且具有 KEY_QUERY_VALUE 权限的键。
LPCWSTR lpValueName, // 值名。nullptr 或空字符串表示默认值。
LPDWORD lpReserved, // 保留,必须为 nullptr。
LPDWORD lpType, // 接收 REG_SZ、REG_EXPAND_SZ 等类型。
LPBYTE lpData, // 接收原始字节。只查询长度时传 nullptr。
LPDWORD lpcbData // 输入为容量、输出为实际字节数,单位始终是字节。
);
正确读取的关键是先查询字节数,再分配准确容量。REG_SZ 和 REG_EXPAND_SZ 的字符内容通常是 UTF-16,字节数仍然不能直接当作字符数使用。
cpp
DWORD type = REG_NONE;
DWORD byteCount = 0;
LSTATUS status = RegQueryValueExW(key, L"Userinit", nullptr, &type, nullptr, &byteCount);
if (status == ERROR_SUCCESS) {
std::vector<BYTE> bytes(byteCount);
DWORD copiedBytes = byteCount;
status = RegQueryValueExW(
key,
L"Userinit",
nullptr,
&type,
bytes.empty() ? nullptr : bytes.data(),
&copiedBytes);
if (status == ERROR_SUCCESS) {
bytes.resize(copiedBytes);
// 仅当 type 为 REG_SZ 或 REG_EXPAND_SZ,且 copiedBytes 能被 sizeof(wchar_t) 整除时,
// 才按 UTF-16 解释 bytes。
}
}
字符串解码需要使用返回长度构造文本。结尾 NUL 出现时可以移除一个。缺少结尾 NUL 时仍只使用已经返回的字符范围,不能交给 wcslen 向缓冲区外继续搜索。
cpp
std::wstring text;
if ((type == REG_SZ || type == REG_EXPAND_SZ) &&
bytes.size() % sizeof(wchar_t) == 0) {
text.resize(bytes.size() / sizeof(wchar_t));
std::memcpy(text.data(), bytes.data(), bytes.size());
if (!text.empty() && text.back() == L'\0') {
text.pop_back();
}
}
固定数组读取会同时丢失长度校验和类型校验。长度超过数组时,RegQueryValueExW 返回 ERROR_MORE_DATA。忽略返回值后再输出缓冲区,会得到截断文本或未初始化数据。
cpp
// 错误示例:260 个字符不是注册表字符串的通用上限,sizeof(buffer) 才是字节容量。
wchar_t buffer[260];
DWORD bytes = sizeof(buffer);
RegQueryValueExW(key, L"Userinit", nullptr, nullptr,
reinterpret_cast<BYTE*>(buffer), &bytes);
std::wcout << buffer; // 忽略状态、类型与实际字节数。
REG_EXPAND_SZ 的原始文本应先保存。文本中可能出现 %SystemRoot%、%ProgramFiles% 等环境变量标记,展开结果取决于当前进程环境,属于另一份派生信息。
完成固定值的长度和类型校验后,已经拿到受边界保护的原始文本。Userinit 还要保留逗号的位置,下一部分继续处理这项文本的内部边界。
1. 保留 Userinit 的逗号边界
Userinit 的每一段都要按原始逗号位置切分。空段、连续逗号和最后逗号能反映原始配置,解析过程不能自动去空白或删除空项。
cpp
std::vector<std::wstring_view> SplitUserinit(const std::wstring_view text)
{
std::vector<std::wstring_view> parts;
std::size_t begin = 0;
while (begin <= text.size()) {
const std::size_t comma = text.find(L',', begin);
if (comma == std::wstring_view::npos) {
parts.push_back(text.substr(begin));
break;
}
parts.push_back(text.substr(begin, comma - begin));
begin = comma + 1;
}
return parts;
}
输入 L"A,,B," 时,上面的结果依次为 A、空字符串、B、空字符串。尾随逗号对应的空字符串用于保留原始格式。
wcstok 适合需要原地修改的普通分词场景。它会改写输入文本,并且调用方拿不到尾随分隔符产生的空项。这里应使用不修改原始字符串的长度视图切分方式。
cpp
// 错误示例:结果中没有尾随逗号对应的空片段,source 也被写入 NUL。
wchar_t source[] = L"A,,B,";
for (wchar_t* item = wcstok(source, L",", nullptr);
item != nullptr;
item = wcstok(nullptr, L",", nullptr)) {
// 空片段已经丢失。
}
完成第三步后,固定值已经带着真实类型和实际长度保存,Userinit 的空片段和尾随逗号也没有丢失。接下来需要进入 Notify,逐层读取每个配置单元中的所有值。
五、第四步:逐层遍历 Notify 子键和值
获取键的容量信息可以避免把子键名和值名限制在固定数组中。RegQueryInfoKeyW 返回的多个长度字段单位不同,子键名和值名长度以 wchar_t 个数计,值数据长度以字节计。
cpp
// 意义:查询键的子键数、值数和各类名称/数据的最大长度。
// 返回:ERROR_SUCCESS 表示成功。输出长度只适合作为本次枚举的初始容量,
// 注册表在读取期间变化时仍要处理 ERROR_MORE_DATA。
LSTATUS RegQueryInfoKeyW(
HKEY hKey, LPWSTR lpClass, LPDWORD lpcchClass, LPDWORD lpReserved,
LPDWORD lpcSubKeys, LPDWORD lpcbMaxSubKeyLen, LPDWORD lpcbMaxClassLen,
LPDWORD lpcValues, LPDWORD lpcbMaxValueNameLen, LPDWORD lpcbMaxValueLen,
LPDWORD lpcbSecurityDescriptor, PFILETIME lpftLastWriteTime
);
// 意义:按索引读取一个一级子键名。
// 返回:ERROR_SUCCESS 表示成功。ERROR_NO_MORE_ITEMS 表示枚举结束。
// ERROR_MORE_DATA 表示 lpcchName 指定的字符容量不足。
LSTATUS RegEnumKeyExW(
HKEY hKey, DWORD dwIndex, LPWSTR lpName, LPDWORD lpcchName,
LPDWORD lpReserved, LPWSTR lpClass, LPDWORD lpcchClass,
PFILETIME lpftLastWriteTime
);
// 意义:按索引读取键中的一个值名、类型和原始数据。
// 返回:ERROR_SUCCESS 表示成功。ERROR_NO_MORE_ITEMS 表示枚举结束。
// ERROR_MORE_DATA 表示值名或数据缓冲区不足。
LSTATUS RegEnumValueW(
HKEY hKey, DWORD dwIndex, LPWSTR lpValueName, LPDWORD lpcchValueName,
LPDWORD lpReserved, LPDWORD lpType, LPBYTE lpData, LPDWORD lpcbData
);
Notify 的遍历分为两层。第一层在 Winlogon\Notify 中用 RegEnumKeyExW 取出每个子键名。第二层打开该子键,再用 RegEnumValueW 读取其中每个值。
cpp
for (DWORD subKeyIndex = 0; ; ++subKeyIndex) {
// subKeyName 使用 RegQueryInfoKeyW 给出的最大长度分配,
// RegEnumKeyExW 返回 ERROR_MORE_DATA 时扩容后重试。
std::wstring subKeyName;
const LSTATUS enumStatus = ReadSubKeyName(notifyKey, subKeyIndex, subKeyName);
if (enumStatus == ERROR_NO_MORE_ITEMS) {
break;
}
if (enumStatus != ERROR_SUCCESS) {
continue; // 记录当前错误后继续下一个索引。
}
HKEY childKey = nullptr;
if (RegOpenKeyExW(notifyKey, subKeyName.c_str(), 0, KEY_QUERY_VALUE, &childKey) != ERROR_SUCCESS) {
continue;
}
// 对 childKey 的每个 RegEnumValueW 结果分别保存值名、类型和原始字节。
RegCloseKey(childKey);
}
值名为 DLLName 时可额外标记为模块字段。其它值仍按同一规则读取和显示,字符串、REG_DWORD、REG_MULTI_SZ、二进制数据都先保留真实类型,只有已验证的文本类型才转换为字符串。
注册表可以在枚举期间被其它进程修改。每个索引的读取遇到 ERROR_MORE_DATA 时应扩容并有限重试。遇到 ERROR_NO_MORE_ITEMS 时结束。遇到访问拒绝、损坏数据或连续变化时记录该项状态,继续其它已知位置。
cpp
// 错误示例:只读取假定存在的 DLLName,子键内其它值完全没有被检查。
RegQueryValueExW(childKey, L"DLLName", nullptr, &type, data, &dataBytes);
完成第四步后,固定值与 Notify 条目都能按原始层级、类型、长度和读取状态保存。得到的结果描述注册表中的配置快照,实际登录阶段是否使用某项仍需结合系统版本、文件状态和运行时证据判断。
完整可运行程序在附件,运行效果: 