首页
社区
课程
招聘
[原创]KnownDlls 与 KnownDLLs32:系统 DLL 映射表的双架构审计
发表于: 19小时前 130

[原创]KnownDlls 与 KnownDLLs32:系统 DLL 映射表的双架构审计

19小时前
130

KnownDlls 与 KnownDLLs32:系统 DLL 映射表的双架构审计

本篇要完成的工作是:只读列出 KnownDllsKnownDlls32 在 64 位、32 位注册表视图中的全部映射值,并为每项保留键名、视图、值名、值类型和原始数据。结果只说明当前注册表中的映射配置,不推断某个进程是否已经加载对应模块。

Known DLL 是 Windows 加载器为常见系统模块维护的名称映射。Windows 加载器负责把可执行文件需要的 DLL 映射到进程地址空间。进程请求 DLL 时,加载器会先处理已加载模块、API Set 重定向和 Known DLL 等特殊规则,再按适用规则处理普通 DLL 搜索。KnownDllsKnownDlls32 保存不同架构环境可用的映射记录。

要完成这项读取任务,分为四步:

  1. 确定两个映射表与两种注册表视图
  2. 只读打开每个来源并取得枚举边界
  3. 按索引读取全部值并处理配置变化
  4. 按类型安全解释数据并输出带来源的记录

完整流程如下:

确定 KnownDlls 与 KnownDlls32 的 32/64 位来源 -> 打开键并查询值数与最大长度 -> 逐项读取值名、类型和原始字节 -> 仅解码满足条件的文本或数值并保留来源

一、第一步:确定映射表、映射字段与四个读取来源

第一步要明确每条记录从哪里来,以及每个字段表示什么。注册表映射、磁盘文件、内核节对象和进程模块属于不同观察对象,本篇读取的是第一类对象。

DLL 加载需要先解析模块名称,再确定映像来源

Known DLL 需要放回 Windows DLL 加载过程理解。应用导入 DLL 时,Windows 加载器会根据模块名称、已加载模块、系统目录、应用所在目录和安全加载规则等信息寻找合适映像。DLL 名称并不天然等于某个唯一磁盘路径,同名文件也可能在不同目录出现。

Known DLL 是系统维护的一组特殊模块映射。对处于该映射表中的常见系统 DLL,加载器可使用系统预先准备的映像节对象,以减少重复加载并维持系统组件的一致性。注册表项提供的是“映射别名与 DLL 文件名”的配置线索,具体加载器行为仍由系统版本和内核对象状态决定。

这个机制解释了为什么不能把 KnownDlls 当作启动项。它不定义登录时要启动哪个进程,也不保存带参数的命令行。每一项是一个模块名称映射记录。

值名和 DLL 名称分别表达逻辑别名与文件名

一条映射记录由两个字段组成。KnownDlls 键下的值名常是模块别名,例如 kernel32。值数据常是文件名,例如 kernel32.dll。两者配合形成映射关系,不能只保存其中一侧,也不应把它们拼成当前工作目录下的相对路径。

文件名没有目录时,合理的下一步是以文档化的系统目录规则形成“候选文件位置”,并把候选来源写明。候选路径、文件存在性、PE 架构和签名都是后续验证结果,不能覆盖原始别名或注册表值数据。

KnownDlls 与 KnownDLLs32 处理不同架构环境

32 位与 64 位进程不能共享同一 DLL 映像。64 位进程需要 64 位模块,32 位进程需要 32 位模块。KnownDllsKnownDlls32 是用于区分相应架构映射的键名。不同系统版本上键是否存在、内容是否相同都应通过实际读取得出。

注册表视图又增加了一层身份。读取时应对每个键名分别使用 64 位和 32 位访问标志,结果至少包含键名、视图、别名和值数据。即使值文本相同,也不能在输出时合并,因为它们来自不同的配置位置。

注册表配置与当前加载模块需要独立观察

读取结果需要限制结论范围。读取到 KnownDlls 值说明当前配置中存在该映射条目。它不能单独证明特定进程已经加载相应 DLL,也不能说明内存中的模块文件来自哪个映像节对象。运行时模块列表、系统句柄或进程调试信息属于另一类观察来源。

安全的读取记录可包含读取时间、完整注册表来源、原始类型、原始字节数和可安全解码的文本。这样能够保留配置事实,也能说明结果的边界。

映像节对象与磁盘文件不是同一个观察对象

磁盘路径与内存映射需要分别理解。磁盘上的 DLL 文件是一个文件系统对象。加载器使用的映像节对象是内核管理的内存映射对象。进程模块列表中的记录又是特定进程地址空间中的观察结果。Known DLL 机制与映像节对象有关,因此“系统目录中找到同名文件”无法直接说明某个进程如何得到它。

只读取注册表时,结果应表述为映射配置。文件信息需要文件系统和签名查询,运行时加载信息需要进程模块查询。三类信息来自不同 API 或系统对象,不能省略来源标签后合成一句结论。

Known DLL 处理发生在普通路径搜索之前

Known DLL 映射位于加载器的特殊规则阶段。进程请求 kernel32.dll 一类模块时,加载器会先处理已加载模块、API Set 重定向、Known DLL 映射等特殊规则,再在适用情形下进入普通 DLL 搜索路径。普通搜索路径涉及应用所在目录、系统目录和安全加载设置。Known DLL 命中则使用系统维护的映像对象,不以应用当前目录为候选来源。

因此,检查某目录里是否存在同名 DLL 不能改变 Known DLL 映射的含义。应用所在目录中有 kernel32.dll,并不等于加载器会对标准系统模块采用该文件。同样,KnownDlls 键中有映射文本,也不能替代对文件系统、签名策略或运行时模块的独立观察。

映射项、对象管理器目录和节对象属于不同层次

注册表、对象管理器和进程地址空间属于不同层次。注册表 KnownDlls 键保存可读配置文本。对象管理器是内核管理命名对象与引用计数的组件,Known DLL 相关的节对象在其命名空间中有自己的对象身份。节对象描述一段可映射到进程地址空间的字节范围。进程加载模块时,内存管理器和加载器根据节对象建立映像视图。

这三个层次可以这样表示:

KnownDlls 注册表值 -> 别名与 DLL 文件名 -> 对象管理器中的系统映像节对象 -> 某个进程地址空间中的模块视图

每层都需要不同的查询方法和权限。注册表值读取失败不代表节对象不存在。进程模块枚举失败不代表注册表映射无效。审计报告必须写出数据来源,防止读者把不同层次的空结果误当成相互矛盾。

WOW64 让同名模块的架构判定更重要

WOW64 的职责决定 32 位映射的边界。WOW64 是 Windows 在 64 位系统上运行 32 位用户态应用的兼容层。32 位进程拥有自己的用户态地址空间和模块架构,不能把 64 位 DLL 直接映射进去。KnownDlls32 与 32 位注册表视图正是为了让这类进程拥有适用的系统模块映射。

读取结果应把“键名”“注册表视图”“值名”和“值数据”记录为四个字段。例如,KnownDlls32 的一个 REG_SZ 值说明 32 位映射配置。PE Machine 值需要文件检查才能说明文件架构,进程模块列表需要进程检查才能说明某次采样的实际映射。各类结果都应依据实际读取,而不是从文件名猜测。

映射的安全意义来自受控加载路径,检查仍需分层

系统机制与应用自身的判断需要分开。Known DLL 映射为常见系统模块提供受控的加载路径,减少普通目录搜索对这些模块的影响。它解决的是加载器如何选择系统映像这一类问题,不能自动验证应用加载的所有第三方 DLL,也不能替代系统的代码完整性、签名、应用控制或进程保护机制。

读取映射表时,不应从映射存在推出“系统绝对安全”或“某个进程必然正常”。需要进行扩展检查时,可并列记录映射配置、文件版本和签名、进程模块地址范围、加载时间、相关系统策略以及采样失败状态。每一条记录只证明自己来源提供的事实,下一层证据用于缩小解释范围。

完成第一步后,读取方已经知道每一条记录的键名和视图身份,也知道值名与值数据不能互相替代。接下来需要用只读权限打开每个来源,并在枚举前取得长度边界。

二、第二步:只读打开每个来源并取得枚举边界

KnownDllsKnownDlls32 是两个不同的键名。每个键还可能在 64 位或 32 位注册表视图中出现,审计记录至少需要保存键名、视图、值名、值类型和值数据。

HKEY、DWORD、UTF-16 与 NUL 是读取时的基础词汇

打开和枚举 API 的单位与所有权需要先明确。HKEY 是当前进程打开注册表键后得到的系统资源引用,成功打开后由调用方 RegCloseKeyDWORD 是 32 位无符号整数。在值枚举中,一个 DWORD 可能保存 UTF-16 字符数量,也可能保存原始数据的字节数量,变量名应明确区分。

UTF-16 是 Windows 宽字符 API 常用的文本编码,wchar_t 在 Windows 上保存一个 UTF-16 代码单元。NUL 是数值为零的终止字符。注册表值数据不保证总有结尾 NUL,因此文本构造只能使用 API 返回的明确长度。REG_SZREG_EXPAND_SZ 才适合尝试按 UTF-16 解码,REG_BINARYREG_DWORD 和未知类型应保留原始表示。

// 意义:打开一个注册表子键。
// 返回:ERROR_SUCCESS 表示成功。其它返回值是 Win32 错误码。
//        成功时 phkResult 接收 HKEY,调用方必须关闭。
LSTATUS RegOpenKeyExW(
    HKEY hKey, LPCWSTR lpSubKey, DWORD ulOptions,
    REGSAM samDesired, PHKEY phkResult
);

// 意义:关闭由注册表打开函数返回的键句柄。
// 返回:ERROR_SUCCESS 表示成功。
LSTATUS RegCloseKey(HKEY hKey);
const struct {
    const wchar_t* path;
    REGSAM view;
} sources[] = {
    { L"System\\CurrentControlSet\\Control\\Session Manager\\KnownDlls", KEY_WOW64_64KEY },
    { L"System\\CurrentControlSet\\Control\\Session Manager\\KnownDlls", KEY_WOW64_32KEY },
    { L"System\\CurrentControlSet\\Control\\Session Manager\\KnownDlls32", KEY_WOW64_64KEY },
    { L"System\\CurrentControlSet\\Control\\Session Manager\\KnownDlls32", KEY_WOW64_32KEY },
};

键不存在是可见状态。部分系统没有 KnownDlls32,这个状态不能与 KnownDlls 的空值表或读取失败合并。

打开成功后还需要取得本次枚举的容量参考。RegQueryInfoKeyW 返回当前值数、最大值名长度和最大值数据长度。值名长度的单位是 UTF-16 字符,值数据长度的单位是字节,两者必须保存到不同变量中。

// 意义:取得键在本次查询时的值数和缓冲区长度参考。
// 返回:ERROR_SUCCESS 表示成功。lpcValues、lpcchMaxValueNameLen
//        和 lpcbMaxValueLen 都是输出参数,可为 nullptr 时表示调用方不需要该字段。
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
);
DWORD valueCount = 0;
DWORD maxNameChars = 0;
DWORD maxDataBytes = 0;
const LSTATUS infoStatus = RegQueryInfoKeyW(
    knownDlls,  // 输入:已打开的只读 HKEY,调用方仍拥有关闭责任。
    nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
    &valueCount,       // 输出:当前值数。
    &maxNameChars,     // 输出:最大值名长度,单位为 UTF-16 字符,不含结尾 NUL。
    &maxDataBytes,     // 输出:最大值数据长度,单位为字节。
    nullptr, nullptr);
if (infoStatus != ERROR_SUCCESS) {
    // 记录错误并停止读取当前键。不能使用未初始化的长度继续分配缓冲区。
}

完成第二步后,读取方已经拥有明确的键句柄、来源标签和本次查询的长度参考。注册表内容可能在下一次调用前变化,第三步仍要按 API 返回的实际长度调整缓冲区。

三、第三步:按索引读取全部值并处理配置变化

映射表使用值枚举结构。值名通常是模块别名,值数据通常是 DLL 文件名。两者具有不同角色,任何一个都不等于完整文件路径。

映射表枚举需要处理配置变化和异常类型

映射表可能在读取期间变化。RegQueryInfoKeyW 给出的最大值名和数据长度只适用于本次查询时刻。下一次 RegEnumValueW 可能返回更长的名称或数据,ERROR_MORE_DATA 表示应扩大相应缓冲区并重试同一索引。

值数据通常是字符串,仍需要检查类型与 UTF-16 字节对齐。遇到数值、二进制、未知类型或无结尾 NUL 时,记录类型、长度和安全的原始表示即可。读取目标是完整反映注册表内容,不把每个值强制转换成可用 DLL 名称。

标准枚举流程先保证资源,再解释文本

资源管理要先于文本解释。RegOpenKeyExW 成功后交出 HKEY,调用方必须 RegCloseKeyRegQueryInfoKeyW 返回的最大长度分别以字符和字节计。RegEnumValueW 返回 ERROR_NO_MORE_ITEMS 时才可结束。只有在类型为 REG_SZREG_EXPAND_SZ 且数据长度是 sizeof(wchar_t) 的整数倍时,值数据才可按 UTF-16 文本解码。

正确代码先保存原始值名、类型和字节数,再生成可选文本字段。出现 ERROR_MORE_DATA 时重新分配并重试当前索引,出现 ERROR_ACCESS_DENIED 时保留来源和错误,出现未知类型时输出安全的长度和十六进制表示。这样做能够保证配置中任何异常项都不会越界访问,也不会被悄悄丢弃。

// 意义:按索引读取一个映射值。
// 返回: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
);

完整正确示范要同时处理两个长度单位和关闭责任

下面的流程只展示一个值的安全读取要点。先以 KEY_QUERY_VALUE 打开键,成功后读取一个索引。nameCapacityCharsdataCapacityBytes 不复用。ERROR_NO_MORE_ITEMS 是正常结束,ERROR_MORE_DATA 是容量不足。任一成功打开的键都会在离开作用域前关闭。

HKEY knownDlls = nullptr;
const LSTATUS openStatus = RegOpenKeyExW(
    HKEY_LOCAL_MACHINE,   // 输入:机器范围根键。系统所有,调用方不关闭。
    knownDllsPath,        // 输入:NUL 结尾 UTF-16 相对路径。
    0,                    // 输入:保留,必须为 0。
    KEY_QUERY_VALUE,      // 输入:只枚举值所需最低权限。
    &knownDlls);          // 输出:成功时得到 HKEY,本段负责关闭。
if (openStatus == ERROR_SUCCESS) {
    std::vector<wchar_t> name(256, L'\0');
    std::vector<BYTE> data(1024);
    DWORD nameCapacityChars = static_cast<DWORD>(name.size());
    DWORD dataCapacityBytes = static_cast<DWORD>(data.size());
    DWORD type = REG_NONE;
    const LSTATUS enumStatus = RegEnumValueW(
        knownDlls, 0, name.data(), &nameCapacityChars, nullptr, &type,
        data.data(), &dataCapacityBytes);
    if (enumStatus == ERROR_SUCCESS) {
        // 记录 nameCapacityChars 个名称字符、type 和 dataCapacityBytes 个原始数据字节。
    } else if (enumStatus == ERROR_MORE_DATA) {
        // 正确:分别扩大两个缓冲区,重试索引 0。不能当作没有值。
    }
    RegCloseKey(knownDlls); // 释放调用方拥有的 HKEY。此后句柄失效。
}

正确枚举时为名称和数据使用独立缓冲区。读取期间映射表变化时分别扩大容量,不能让数据长度覆盖值名长度变量。

std::vector<wchar_t> name(maxNameChars + 1, L'\0');
std::vector<BYTE> data(maxDataBytes);
DWORD nameChars = static_cast<DWORD>(name.size());
DWORD dataBytes = static_cast<DWORD>(data.size());
DWORD type = REG_NONE;

LSTATUS status = RegEnumValueW(
    key, index, name.data(), &nameChars, nullptr,
    &type, data.empty() ? nullptr : data.data(), &dataBytes);
if (status == ERROR_SUCCESS) {
    std::wstring alias(name.data(), nameChars);
    data.resize(dataBytes);
}

下面的错误复用一个长度变量,第一次 API 可能把它改为数据字节数,第二次又被当作名称字符数使用。单位错位会造成名称截断或缓冲区管理错误。

DWORD length = 260;
RegEnumValueW(knownDlls, index, name, &length, nullptr, &type,
              data, &length); // 错误:同一个 length 同时承担字符数与字节数。

字符串值仅在 REG_SZREG_EXPAND_SZ 且字节数符合 UTF-16 对齐时解码。其它类型保留类型和原始字节数,避免把任意二进制值解释成 DLL 名称。

// 错误示例:把值数据直接拼接到当前目录。
std::wstring path = L".\\" + decodedValue;
// Known DLL 值数据可能只是 kernel32.dll 这类映射名称。

完成第三步后,读取方已经取得每个值的名称、类型和精确字节数,也已把键不存在、权限错误、容量不足和枚举结束区分开。最后一步只对类型和长度允许的数据进行解释,异常类型继续保留原始信息。

四、第四步:按类型安全解释数据并输出带来源的记录

第四步要控制文本解释的边界。值名如 kernel32 与值数据如 kernel32.dll 共同构成映射记录。REG_SZREG_EXPAND_SZ 只有在字节数能够被 sizeof(wchar_t) 整除时,才可以按 UTF-16 文本读取。末尾出现一个 NUL 时可以去掉该终止符,内部 NUL、回车和换行仍要在输出前转义,避免内容改变控制台记录的结构。

REG_DWORD 只有在数据长度恰好为 4 字节时,才可以复制到 DWORD 后输出十进制数值。REG_BINARYREG_MULTI_SZ、长度异常的字符串和未知类型都要保留类型名与原始字节数,不能强行当作 DLL 文件名。这样能让错误配置和未来的未知数据类型继续可见。

每条输出都应带上完整注册表路径与 32 位或 64 位视图标签。相同文本来自不同键或不同视图时,仍是不同来源的记录。值数据没有目录时,也不能按命令行、服务路径或 Run 键规则切分。文件系统路径、PE 架构、签名和进程加载状态需要额外查询,不能从映射值自动得出。

完整可运行程序在附件
图片描述

4d4K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4j5h3&6Y4N6$3g2A6j5$3#2Q4x3X3g2D9j5h3&6*7L8%4g2#2i4K6u0W2j5$3!0E0i4K6u0r3K9f1q4i4x3K6M7K6P5h3y4D9P5i4N6Z5


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

上传的附件:
收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回