-
-
[原创][Windows C ]# AppInit_DLLs 的 32 与 64 位解析器
-
发表于: 1天前 515
-
AppInit_DLLs 的 32 与 64 位解析器
目标是完整读取 32 位和 64 位 AppInit_DLLs 配置,保留每个候选 DLL 的来源,并给出路径、PE 类型和本机签名检查结果。AppInit_DLLs 位于 HKLM\Software\Microsoft\Windows NT\CurrentVersion\Windows,同一台 64 位 Windows 计算机可以为两个架构保存不同的值。
要完成这项读取工作,分为四步:
- 打开 64 位和 32 位注册表视图。
- 按原始字节读取 DLL 列表和两个关联开关。
- 保留原文,解析条目并展开环境变量。
- 定位每个候选文件,检查文件属性、PE 类型和签名。
一、先建立配置、进程架构和检查结果之间的关系
AppInit 配置由 DLL 列表、加载开关与签名要求共同组成。符合条件的进程加载 User32.dll 时,Windows 才可能依据这些配置处理候选 DLL。32 位与 64 位配置位于不同注册表视图,并分别对应同位数的 User32 进程和 DLL。
读取 64 位或 32 位 AppInit 配置 -> 检查 LoadAppInit_DLLs 与 RequireSignedAppInit_DLLs -> 进程加载对应位数的 User32.dll -> 系统按安全与兼容条件处理 DLL 列表 -> 运行时结果由独立证据确认
这四步从配置来源开始,逐步形成可以复查的文件检查记录。整个读取过程只查询注册表和文件系统,不加载 DLL。
打开指定注册表视图 -> 读取 DLL 列表与两个开关 -> 保留原始文本 -> 展开环境变量 -> 切分候选条目 -> 检查文件、PE 类型和签名 -> 记录每项状态
AppInit DLL 是依赖 User32 加载过程的历史机制
AppInit 影响的进程范围需要先明确。AppInit 是 Windows 提供过的一种 DLL 加载配置机制。符合条件的进程加载 User32.dll 时,系统可以根据 AppInit 配置处理 DLL 列表。进程是否加载 User32、系统版本、策略和安全设置都会影响实际结果。
AppInit_DLLs 保存候选 DLL 的文本列表,LoadAppInit_DLLs 是与之配套的加载开关,RequireSignedAppInit_DLLs 用于表达签名要求。三者应作为一组读取,因为只有 DLL 列表无法说明列表是否被启用,也无法说明签名限制是否参与判断。
配置读取只能证明注册表中存在这些值。实际加载还取决于进程架构、DLL 是否能解析、依赖库是否可用、代码完整性策略、系统安全功能以及进程是否进入相关加载路径。输出中应把“配置文本”“派生文件检查”“运行时观察”分别标记。
DLL 与加载它的进程必须处于匹配的架构环境
32 位与 64 位的边界需要分别处理。64 位进程只能加载 64 位 DLL,32 位进程只能加载 32 位 DLL。Windows 对部分软件注册表路径维护不同视图,使 32 位应用和 64 位应用可以保存各自适用的 AppInit 设置。
从 64 位进程查询 HKLM 软件键时,默认一般落在 64 位视图。从 32 位进程查询时,默认一般落在 32 位视图。读取过程若只依赖默认值,结果会随调用方架构改变。显式使用 KEY_WOW64_64KEY 与 KEY_WOW64_32KEY 并在结果中记录来源视图,才能完整回答两个架构各自保存了什么。
64 位 Windows 上的关系
64 位 AppInit 配置 -> 64 位 User32 进程 -> 64 位 DLL
32 位 AppInit 配置 -> 32 位 User32 进程 -> 32 位 DLL
配置文本相同也不能消除架构差异。一个路径可能在两个视图中都出现,而目标文件只提供一种 PE 架构。另一个路径可能通过文件系统重定向解析到不同对象。输出应保留注册表视图、原始条目、展开后文本和 GetBinaryTypeW 返回的实际二进制类型。
原始条目与展开后路径代表两个不同事实
注册表保存的原文需要完整保留。REG_EXPAND_SZ 可能包含 %SystemRoot%、%ProgramFiles% 等环境变量标记,例如 %SystemRoot%\System32\Example.dll。原文可说明配置使用了哪个变量。调用 ExpandEnvironmentStringsW 得到的文本则反映当前进程环境下的解析结果。
带空格的路径通常需要用双引号包围,例如 "C:\Program Files\Vendor\Module.dll"。分隔列表时需要识别引号范围,不能简单调用按空格切分的函数。未结束引号、空条目和引号后的额外字符都应产生明确的解析状态,防止把相邻条目合并成错误路径。
文件存在、PE 类型和签名验证解决不同问题
候选文件要按字段分别检查。GetFileAttributesW 只回答路径是否能访问以及它是不是目录。GetBinaryTypeW 用于识别可执行映像的架构类别。WinVerifyTrust 依据本机信任状态验证签名。三个结果不能互相替代。
例如,路径存在而签名验证失败时,文件仍是存在的,应显示路径、二进制类型和失败状态。路径不存在时,签名检查没有对象可验证,应记录文件查找失败。签名成功也不能改变该条目来自哪个注册表视图。分字段保存可以让每一个结论保持可复查性。
二、第一步:同时读取两个注册表视图
64 位系统上的 HKLM\Software 同时提供 64 位和 32 位视图。AppInit_DLLs、LoadAppInit_DLLs、RequireSignedAppInit_DLLs 必须从同一个明确视图读取,默认视图会随调用方位数变化。
打开键的函数通过 samDesired 指定查询权限和 WOW64 视图。返回值是 Win32 错误码,成功后的 HKEY 由调用方负责关闭。
// 意义:打开注册表子键并指定访问权限。
// 返回:ERROR_SUCCESS 表示成功。其它返回值是 Win32 错误码。
// 成功时 phkResult 接收 HKEY,调用方必须调用 RegCloseKey。
LSTATUS RegOpenKeyExW(
HKEY hKey, // 预定义根键或已打开的父键。
LPCWSTR lpSubKey, // 相对于 hKey 的键路径。
DWORD ulOptions, // 保留,必须为 0。
REGSAM samDesired, // KEY_QUERY_VALUE 与 KEY_WOW64_32KEY/KEY_WOW64_64KEY 的组合。
PHKEY phkResult // 接收已打开键句柄。
);
// 意义:关闭由注册表打开函数返回的键句柄。
// 返回:ERROR_SUCCESS 表示成功。预定义根键不需要关闭。
LSTATUS RegCloseKey(HKEY hKey);
64 位视图与 32 位视图使用两个独立调用。每次调用都只申请 KEY_QUERY_VALUE,读取完成后立即关闭键句柄。
constexpr wchar_t kWindowsKey[] =
L"Software\\Microsoft\\Windows NT\\CurrentVersion\\Windows";
HKEY key64 = nullptr;
LSTATUS status64 = RegOpenKeyExW(
HKEY_LOCAL_MACHINE, kWindowsKey, 0,
KEY_QUERY_VALUE | KEY_WOW64_64KEY, &key64);
HKEY key32 = nullptr;
LSTATUS status32 = RegOpenKeyExW(
HKEY_LOCAL_MACHINE, kWindowsKey, 0,
KEY_QUERY_VALUE | KEY_WOW64_32KEY, &key32);
if (status64 == ERROR_SUCCESS) RegCloseKey(key64);
if (status32 == ERROR_SUCCESS) RegCloseKey(key32);
把两个读取结果按字符串内容合并会丢失视图身份。即使两边文本相同,也应保留“64 位视图”和“32 位视图”标签,后续文件检查才能对应到正确的配置来源。
// 错误示例:x64 程序默认读取 64 位视图,结果没有任何 32 位视图信息。
RegOpenKeyExW(HKEY_LOCAL_MACHINE, kWindowsKey, 0, KEY_QUERY_VALUE, &key);
第一步完成后,64 位视图和 32 位视图已经有了各自独立的键句柄与来源标签。接下来需要从每个键读取同一组关联值,才能判断 DLL 列表和加载条件。
三、第二步:按字节长度读取三个关联值
读取注册表值的函数用字节数描述数据缓冲区。AppInit_DLLs 常为 REG_SZ 或 REG_EXPAND_SZ,两个开关通常为 REG_DWORD。类型和长度都要在解释数据前检查。
// 意义:读取一个注册表值的类型和原始数据。
// 返回: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、REG_DWORD 等类型。
LPBYTE lpData, // 接收原始字节。只查询长度时传 nullptr。
LPDWORD lpcbData // 输入为容量、输出为实际长度,单位始终是字节。
);
正确读取先查询长度,再按返回字节数分配缓冲区。读取期间若配置被修改,第二次调用可能返回 ERROR_MORE_DATA,此时扩大缓冲区并有限重试。
DWORD type = REG_NONE;
DWORD bytes = 0;
LSTATUS status = RegQueryValueExW(key, L"AppInit_DLLs", nullptr, &type, nullptr, &bytes);
if (status == ERROR_SUCCESS) {
std::vector<BYTE> raw(bytes);
DWORD copiedBytes = bytes;
status = RegQueryValueExW(
key, L"AppInit_DLLs", nullptr, &type,
raw.empty() ? nullptr : raw.data(), &copiedBytes);
if (status == ERROR_SUCCESS) {
raw.resize(copiedBytes);
}
}
AppInit_DLLs 的字符串只能在类型为 REG_SZ 或 REG_EXPAND_SZ 且字节数能被 sizeof(wchar_t) 整除时按 UTF-16 解码。返回数据可能没有结尾 NUL,字符串长度必须来自 API 返回的字节数。
std::wstring text;
if ((type == REG_SZ || type == REG_EXPAND_SZ) &&
raw.size() % sizeof(wchar_t) == 0) {
text.resize(raw.size() / sizeof(wchar_t));
std::memcpy(text.data(), raw.data(), raw.size());
if (!text.empty() && text.back() == L'\0') {
text.pop_back();
}
}
两个开关要单独保留。LoadAppInit_DLLs 控制该列表是否参与加载,RequireSignedAppInit_DLLs 记录签名要求。值缺失、值为 0、值为 1、类型错误都属于不同的读取结果。
DWORD enabled = 0;
if (type == REG_DWORD && raw.size() == sizeof(enabled)) {
std::memcpy(&enabled, raw.data(), sizeof(enabled));
// enabled 是实际 REG_DWORD 数值。原始字节和所属视图仍应一同保存。
}
固定长度缓冲区会截断较长列表。忽略 ERROR_MORE_DATA 后继续输出还会把未初始化字节当成文本。
// 错误示例:260 个 wchar_t 不是 AppInit_DLLs 的长度上限。
wchar_t text[260];
DWORD bytes = sizeof(text);
RegQueryValueExW(key, L"AppInit_DLLs", nullptr, nullptr,
reinterpret_cast<BYTE*>(text), &bytes);
std::wcout << text;
第二步完成后,DLL 列表的原始字节、注册表类型、实际长度和两个开关的值都已保留下来。接下来需要把符合字符串条件的原文拆成单个条目,才能安全地定位每个候选文件。
四、第三步:保留原始条目后再展开环境变量
DLL 列表中的条目通常由空白分隔。带空格的路径需要用双引号包围,解析时保留未展开条目,才能区分注册表原文与当前进程环境下的派生路径。
// 输入:L"%SystemRoot%\\System32\\a.dll \"C:\\Program Files\\Vendor\\b.dll\""
// 输出:两个 string_view,分别指向未展开的 a.dll 与 b.dll 条目。
std::vector<std::wstring_view> items = ParseDllList(originalText);
列表解析应保留未结束引号、结束引号后紧跟文本等格式状态。遇到这类文本时仍可显示已识别片段,不能把错误片段静默丢弃。
环境变量展开函数返回所需字符数,返回长度包含结尾 NUL。传给它的输入必须有结尾 NUL,因此长度受限视图需要先复制到独立的 std::wstring。
// 意义:展开文本中的 %变量名% 标记。
// 返回:成功时返回写入字符数,包含结尾 NUL。0 表示失败。
// nSize 与返回值的单位都是 wchar_t 个数。
DWORD ExpandEnvironmentStringsW(
LPCWSTR lpSrc, // NUL 结尾的输入文本。
LPWSTR lpDst, // 输出缓冲区。只查询长度时传 nullptr。
DWORD nSize // 输出缓冲区容量,单位为 wchar_t 个数。
);
正确展开先查询所需字符数,再创建刚好容纳结尾 NUL 的缓冲区。输出时移除结尾 NUL,原始条目和展开结果分别保存。
const std::wstring source(rawItem);
const DWORD required = ExpandEnvironmentStringsW(source.c_str(), nullptr, 0);
if (required != 0) {
std::wstring expanded(required, L'\0');
const DWORD copied = ExpandEnvironmentStringsW(
source.c_str(), expanded.data(), required);
if (copied != 0 && copied <= required) {
expanded.resize(copied - 1);
}
}
直接把 REG_EXPAND_SZ 当作已经展开的路径会得到 %SystemRoot%\System32\x.dll 这类原始文本。原文适合记录配置,展开结果适合后续文件查询,两个字段都需要保留。
第三步完成后,每个候选 DLL 都保留了来自哪个视图的原始条目,以及当前环境下的展开文本。接下来需要按条目查询文件,避免一个无效路径掩盖其它条目的状态。
五、第四步:对每个候选文件分别检查
文件检查从候选路径解析开始。SearchPathW 只查询系统搜索规则,不加载文件。成功时返回实际写入的字符数,缓冲区不足时返回所需容量。
// 意义:按指定或系统默认路径规则查找文件。
// 返回:成功时为输出路径字符数,不包含结尾 NUL。
// 缓冲区不足时为所需容量,包含结尾 NUL。0 表示失败。
DWORD SearchPathW(
LPCWSTR lpPath, // 搜索路径。nullptr 使用系统默认搜索规则。
LPCWSTR lpFileName, // 要查找的文件名或路径。
LPCWSTR lpExtension, // 可选扩展名。DLL 条目已有扩展名时传 nullptr。
DWORD nBufferLength, // lpBuffer 的容量,单位为 wchar_t 个数。
LPWSTR lpBuffer, // 接收解析后的完整路径。
LPWSTR* lpFilePart // 可选,接收输出路径中文件名部分的地址。
);
每个条目使用独立的路径缓冲区。文件名找不到时记录该条目的错误并继续检查列表中的下一项,不能因为第一项失败而跳过其它 DLL。
文件属性函数先区分不存在、访问失败和目录。目录没有可供 PE 或签名检查的文件内容,应在这一层结束该条目。
// 意义:读取文件或目录的属性位。
// 返回:成功时为 FILE_ATTRIBUTE_* 位组合。INVALID_FILE_ATTRIBUTES 表示失败,
// 失败原因由 GetLastError 返回。
DWORD GetFileAttributesW(LPCWSTR lpFileName);
PE 类型函数用于读取候选文件的二进制类别。SCS_32BIT_BINARY、SCS_64BIT_BINARY 等返回值要和原始条目、解析路径一起输出,避免把路径文本推断成文件架构。
// 意义:读取可执行映像或 DLL 的二进制类型。
// 返回:非零表示成功,lpBinaryType 接收 SCS_32BIT_BINARY、SCS_64BIT_BINARY 等常量。
// 0 表示失败,错误原因由 GetLastError 返回。
BOOL GetBinaryTypeW(
LPCWSTR lpApplicationName, // 要检查的已解析文件路径。
LPDWORD lpBinaryType // 接收二进制类型。
);
嵌入签名检查使用 WinVerifyTrust。调用时关闭用户界面、关闭联机吊销检查并限制为本地缓存,检查结果会受到本机证书和缓存状态影响,失败状态码需要原样保留。
// 意义:按指定信任操作验证文件签名。
// 返回:ERROR_SUCCESS 表示信任检查成功。其它 LONG 值是信任提供程序状态码。
LONG WinVerifyTrust(
HWND hwnd, // UI 宿主。无 UI 检查时传 nullptr。
GUID* pgActionID, // 指向 WINTRUST_ACTION_GENERIC_VERIFY_V2。
LPVOID pWVTData // 指向已初始化的 WINTRUST_DATA。
);
WINTRUST_FILE_INFO fileInfo{};
fileInfo.cbStruct = sizeof(fileInfo);
fileInfo.pcwszFilePath = resolvedPath.c_str();
WINTRUST_DATA trustData{};
trustData.cbStruct = sizeof(trustData);
trustData.dwUIChoice = WTD_UI_NONE;
trustData.fdwRevocationChecks = WTD_REVOKE_NONE;
trustData.dwUnionChoice = WTD_CHOICE_FILE;
trustData.pFile = &fileInfo;
trustData.dwStateAction = WTD_STATEACTION_VERIFY;
trustData.dwProvFlags = WTD_CACHE_ONLY_URL_RETRIEVAL;
GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2;
LONG status = WinVerifyTrust(nullptr, &action, &trustData);
trustData.dwStateAction = WTD_STATEACTION_CLOSE;
WinVerifyTrust(nullptr, &action, &trustData);
把签名失败直接当作文件不存在会混淆两类问题。文件存在且签名不受当前信任链认可时,路径、PE 类型和信任状态都应保留。无法找到文件时,不应继续对不存在的路径请求签名检查。
第四步完成后,每个候选条目都有来源视图、原始文本、展开结果和文件检查状态。接下来应把这些配置与独立的运行时观察并列,避免仅凭注册表文本推断 DLL 已被加载。
完整可运行程序在附件

ed6K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4j5h3&6Y4N6$3g2A6j5$3#2Q4x3X3g2D9j5h3&6*7L8%4g2#2i4K6u0W2j5$3!0E0i4K6u0r3K9h3R3@1x3s2f1K6P5r3&6X3x3U0q4A6
[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。