[原创][Windows C++]LSA 认证、安全与通知包的注册表枚举

LSA 认证、安全与通知包的注册表枚举

本次检查的目标是:只读列出本机 LSA 的认证包、安全包、通知包和 OSConfig 安全包,并为每个包名保留来源、候选 DLL、PE 架构和嵌入签名状态。

要得到这组结果,分为四步:

  1. 定位四个 LSA 包列表的完整注册表来源
  2. 按返回字节长度读取并解析 REG_MULTI_SZ 包名
  3. 为每个包名构造 DLL 候选路径并确认文件状态
  4. 读取候选 DLL 的 PE Machine 字段并检查 Authenticode 签名

完整流程为:四个注册表来源 -> 原始注册表字节 -> 受边界限制的包名 -> 候选 DLL -> PE 架构与签名状态。

一、第一步:定位四个 LSA 包列表的完整注册表来源

第一步要确认每份配置由哪个 Windows 组件使用。Local Security Authority(LSA,本地安全机构)管理本机安全策略、登录身份验证相关协调和审核。lsass.exe 是承载 LSA 服务的重要系统进程。系统启动和用户登录时,LSA 会协调多类安全组件。

第一步还要区分包名和模块文件。包列表中的 msv1_0kerberos 一类条目是逻辑包名。包名来自注册表配置,模块路径、PE 架构、签名状态和运行时加载状态都属于后续信息。

四个包列表分别保存什么

三个主列表的职责不同。Authentication Packages 中的组件参与用户身份验证协议处理。Security Packages 中的组件为 SSPI(Security Support Provider Interface,安全支持提供者接口)等安全服务提供者接口提供实现。Notification Packages 中的组件接收 LSA 相关通知。

Lsa\OSConfig\Security Packages 是第四个独立来源。它与 LSA 主键下的 Security Packages 同名,完整注册表路径不同。记录结果时要同时保存完整键路径和值名,这样才能追溯某个包名来自哪一份配置。

text 复制代码
Lsa 主键
    Authentication Packages:身份验证协议相关组件
    Security Packages:安全支持提供者组件
    Notification Packages:LSA 通知相关组件
    OSConfig
        Security Packages:另一来源的安全包配置

逐份打开并记录来源

认证包、安全包和通知包位于 LSA 主键。Authentication PackagesSecurity PackagesNotification Packages 是三个独立值,Lsa\OSConfig 下的 Security Packages 也需要单独读取。

每个值的读取身份由"完整键路径 + 值名 + 包类别"组成。Security Packages 在两个键下同名,按值名合并会丢失配置来源。

打开注册表键的函数返回可查询的键句柄。固定包列表只需要 KEY_QUERY_VALUE 权限,成功后的句柄必须关闭。

cpp 复制代码
// 意义:打开一个注册表子键。
// 返回:ERROR_SUCCESS 表示成功。其它返回值是 Win32 错误码。
//        成功时 phkResult 接收 HKEY,调用方必须用 RegCloseKey 关闭。
LSTATUS RegOpenKeyExW(
    HKEY hKey,              // 预定义根键或已经打开的父键。
    LPCWSTR lpSubKey,       // 相对键路径。
    DWORD ulOptions,        // 保留,必须为 0。
    REGSAM samDesired,      // 所需权限,此处为 KEY_QUERY_VALUE。
    PHKEY phkResult         // 接收新键句柄。
);

// 意义:关闭由 RegOpenKeyExW 返回的键句柄。
// 返回:ERROR_SUCCESS 表示成功。预定义根键不需要关闭。
LSTATUS RegCloseKey(HKEY hKey);

正确读取时让每个值独立失败。OSConfig 子键或其中的值未设置时记录该状态,认证包、安全包和通知包仍继续读取。

cpp 复制代码
constexpr wchar_t kLsaKey[] = L"System\\CurrentControlSet\\Control\\Lsa";
HKEY lsaKey = nullptr;
const LSTATUS status = RegOpenKeyExW(
    HKEY_LOCAL_MACHINE, kLsaKey, 0, KEY_QUERY_VALUE, &lsaKey);

if (status == ERROR_SUCCESS) {
    // 依次读取 Authentication Packages、Security Packages、Notification Packages。
    RegCloseKey(lsaKey);
}
cpp 复制代码
// 错误示例:用同一个字符串字段保存所有值名。
// 两处 Security Packages 会被误认为同一份配置。
record.name = "Security Packages";

完成第一步后,四份配置都有独立的完整键路径和值名。接下来要读取每个值返回的原始字节,并在明确边界内得到包名。

二、第二步:按返回字节长度读取并解析 REG_MULTI_SZ

注册表读取函数以字节数描述值数据。第一次调用传入空数据指针取得长度,第二次调用按该字节数读取原始数据。读取期间值发生变化时,ERROR_MORE_DATA 需要触发扩容重试。

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_MULTI_SZ 等类型。
    LPBYTE lpData,          // 接收原始字节。查询长度时传 nullptr。
    LPDWORD lpcbData        // 输入为容量、输出为实际长度,单位是字节。
);

正确读取先保存类型,再保存实际字节数。REG_MULTI_SZ 只有在字节数能被 sizeof(wchar_t) 整除时,才可以按 UTF-16 字符边界查看。

cpp 复制代码
DWORD type = REG_NONE;
DWORD byteCount = 0;
LSTATUS status = RegQueryValueExW(
    lsaKey, L"Authentication Packages", nullptr, &type, nullptr, &byteCount);

if (status == ERROR_SUCCESS) {
    std::vector<BYTE> bytes(byteCount);
    DWORD copiedBytes = byteCount;
    status = RegQueryValueExW(
        lsaKey, L"Authentication Packages", nullptr, &type,
        bytes.empty() ? nullptr : bytes.data(), &copiedBytes);
    if (status == ERROR_SUCCESS) {
        bytes.resize(copiedBytes);
    }
}

多字符串使用单个 NUL 分隔条目,使用双 NUL 结束列表。msv1_0\0kerberos\0\0 表示两个包名。最后两个 NUL 是结构边界,不能依赖 wcslen 从未知内存寻找它们。

cpp 复制代码
std::vector<std::wstring> packages;
const auto* characters = reinterpret_cast<const wchar_t*>(bytes.data());
const std::size_t characterCount = bytes.size() / sizeof(wchar_t);

std::size_t begin = 0;
while (begin < characterCount) {
    std::size_t end = begin;
    while (end < characterCount && characters[end] != L'\0') {
        ++end;
    }
    if (end == begin) {
        break; // 已抵达双 NUL 中的第一个终止字符。
    }
    packages.emplace_back(characters + begin, end - begin);
    if (end == characterCount) {
        break; // 缺少 NUL 时只保留 API 返回范围内的最后片段。
    }
    begin = end + 1;
}

缺少双 NUL 时仍可显示已返回的片段,同时记录终止缺失。长度受限的循环不访问 bytes 之外,损坏配置也不会变成越界读取。

cpp 复制代码
// 错误示例:假定注册表总会提供 NUL 结尾。
const wchar_t* text = reinterpret_cast<const wchar_t*>(bytes.data());
std::wcout << text; // 可能越过 bytes 的边界。

完成第二步后,已经得到每个列表项的来源和受长度限制的包名。接下来要把包名转换为可检查的文件候选,同时保留它只是候选这一事实。

三、第三步:为每个包名构造 DLL 候选路径并确认文件状态

包列表中的 msv1_0scecli 这类内容是包标识。它们没有路径和扩展名时,可以构造 系统目录 + 包名 + .dll 作为文件定位候选。候选路径不能替代 LSA 实际加载结果。

系统目录函数返回实际复制的字符数。返回值小于缓冲区容量时才表示完整路径已写入。返回值达到容量时需要扩大缓冲区。

cpp 复制代码
// 意义:取得系统目录,例如 C:\Windows\System32。
// 返回:成功时为复制的字符数,不包含结尾 NUL。0 表示失败。
//        缓冲区不足时返回所需容量,调用方必须扩大缓冲区。
UINT GetSystemDirectoryW(
    LPWSTR lpBuffer,        // 接收系统目录的输出缓冲区。
    UINT uSize              // 缓冲区容量,单位为 wchar_t 个数。
);

构造候选路径时保留原始包名。已有绝对路径时保留原路径。没有扩展名时添加 .dll。引号、内嵌 NUL、回车和制表符都作为无效包名状态显示,不把它们拼成文件路径。

cpp 复制代码
std::wstring candidate;
std::wstring fileName(packageName);
if (!HasFileExtension(fileName)) {
    fileName += L".dll";
}
if (IsAbsolutePath(fileName)) {
    candidate = fileName;
} else {
    candidate = systemDirectory + L"\\" + fileName;
}

候选路径构造完成后还要确认它代表的是可读取文件。GetFileAttributesW 返回文件或目录的属性位,返回 INVALID_FILE_ATTRIBUTES 时表示路径不存在或当前账户无法读取属性,此时应立即读取 GetLastError。属性中带有 FILE_ATTRIBUTE_DIRECTORY 时,路径指向目录,不能继续按 DLL 读取。

cpp 复制代码
// 意义:取得指定路径的文件系统属性。
// 返回:成功时返回属性位。失败时返回 INVALID_FILE_ATTRIBUTES,随后用 GetLastError 取得原因。
DWORD GetFileAttributesW(
    LPCWSTR lpFileName        // 输入,UTF-16 的 NUL 结尾路径。不能为 nullptr。
);
cpp 复制代码
const DWORD attributes = GetFileAttributesW(candidate.c_str());
if (attributes == INVALID_FILE_ATTRIBUTES) {
    const DWORD error = GetLastError();
    // 记录候选路径不可检查及 error,仍保留前面读取到的来源与包名。
} else if ((attributes & FILE_ATTRIBUTE_DIRECTORY) != 0) {
    // 该候选路径是目录,不能把它交给 PE 解析或签名检查。
} else {
    // 只有普通文件候选才进入下一步。
}

当前系统目录只是一个可验证的文件位置。包名、候选路径、文件存在性和签名检查处于不同阶段,任意一个阶段失败都不应覆盖前一阶段已经读取到的信息。

完成第三步后,已经为每个有效包名保留了候选路径和文件状态。接下来只对可读取的文件检查 PE 头和嵌入签名。

四、第四步:读取候选 DLL 的 PE Machine 字段并检查 Authenticode 签名

DLL 的架构检查需要读取 PE 头。GetBinaryTypeW 面向可执行映像,某些 DLL 会返回 ERROR_BAD_EXE_FORMAT。读取 IMAGE_FILE_HEADER::Machine 可以直接取得 x86、x64、ARM64 等机器类型。

文件打开函数返回 INVALID_HANDLE_VALUE 时失败。读取 PE 头只申请 GENERIC_READ,并允许其它进程读、写、删除文件,读取结束后关闭句柄。

cpp 复制代码
// 意义:打开已有文件。
// 返回:成功时返回 HANDLE。失败时返回 INVALID_HANDLE_VALUE,错误原因由 GetLastError 返回。
HANDLE CreateFileW(
    LPCWSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode,
    LPSECURITY_ATTRIBUTES lpSecurityAttributes, DWORD dwCreationDisposition,
    DWORD dwFlagsAndAttributes, HANDLE hTemplateFile
);

// 意义:取得文件长度。
// 返回:非零表示成功。lpFileSize 接收有符号 64 位字节数。
BOOL GetFileSizeEx(HANDLE hFile, PLARGE_INTEGER lpFileSize);

// 意义:移动文件读写位置。
// 返回:非零表示成功。liDistanceToMove 是 64 位字节偏移。
BOOL SetFilePointerEx(
    HANDLE hFile, LARGE_INTEGER liDistanceToMove,
    PLARGE_INTEGER lpNewFilePointer, DWORD dwMoveMethod
);

// 意义:从当前文件偏移读取字节。
// 返回:非零只表示请求已完成。lpNumberOfBytesRead 仍必须等于请求字节数。
BOOL ReadFile(
    HANDLE hFile, LPVOID lpBuffer, DWORD nNumberOfBytesToRead,
    LPDWORD lpNumberOfBytesRead, LPOVERLAPPED lpOverlapped
);

// 意义:关闭 CreateFileW 返回的普通句柄。
// 返回:非零表示成功。
BOOL CloseHandle(HANDLE hObject);

正确解析先验证 DOS 签名和 e_lfanew 范围,再验证 PE 签名,然后读取文件头。每一个偏移都限制在真实文件大小内,截断文件直接报告格式错误。

cpp 复制代码
IMAGE_DOS_HEADER dos{};
ReadExact(file, &dos, sizeof(dos));
if (dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew < 0) {
    // ERROR_BAD_EXE_FORMAT
}

LARGE_INTEGER offset{};
offset.QuadPart = dos.e_lfanew;
SetFilePointerEx(file, offset, nullptr, FILE_BEGIN);

DWORD signature = 0;
IMAGE_FILE_HEADER header{};
ReadExact(file, &signature, sizeof(signature));
ReadExact(file, &header, sizeof(header));
if (signature == IMAGE_NT_SIGNATURE) {
    // header.Machine 可与 IMAGE_FILE_MACHINE_AMD64、IMAGE_FILE_MACHINE_I386 等比较。
}

仅检查 MZ 不足以确认 PE 文件。e_lfanew 可能指向文件外部,PE 签名也可能错误,读取前的文件大小与偏移校验缺一不可。

读取到 PE Machine 后,已经知道候选文件声明的目标架构。接下来把签名结果单独记录,文件格式、架构和信任状态就不会互相覆盖。

将文件签名状态作为独立结果

文件存在且 PE 头有效后可以检查嵌入签名。信任检查关闭界面并限制为本地缓存,结果仍受本机证书和缓存内容影响,返回状态码应原样保留。

cpp 复制代码
// 意义:按指定信任操作验证文件签名。
// 返回:ERROR_SUCCESS 表示信任检查成功。其它 LONG 值为信任提供程序状态码。
LONG WinVerifyTrust(
    HWND hwnd,              // 无 UI 检查时传 nullptr。
    GUID* pgActionID,       // 指向 WINTRUST_ACTION_GENERIC_VERIFY_V2。
    LPVOID pWVTData         // 指向已初始化的 WINTRUST_DATA。
);

验证完成后要调用一次 WTD_STATEACTION_CLOSE。这个调用关闭信任提供程序的状态数据,成功和失败路径都需要执行。

cpp 复制代码
WINTRUST_FILE_INFO fileInfo{};
fileInfo.cbStruct = sizeof(fileInfo);
fileInfo.pcwszFilePath = candidate.c_str();

WINTRUST_DATA data{};
data.cbStruct = sizeof(data);
data.dwUIChoice = WTD_UI_NONE;
data.fdwRevocationChecks = WTD_REVOKE_NONE;
data.dwUnionChoice = WTD_CHOICE_FILE;
data.pFile = &fileInfo;
data.dwStateAction = WTD_STATEACTION_VERIFY;
data.dwProvFlags = WTD_CACHE_ONLY_URL_RETRIEVAL;

GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2;
LONG status = WinVerifyTrust(nullptr, &action, &data);
data.dwStateAction = WTD_STATEACTION_CLOSE;
WinVerifyTrust(nullptr, &action, &data);

完整可运行程序在附件

https://wangweicm.lanzouu.com/iMFYM3xrmete

相关推荐
*.✧屠苏隐遥(ノ◕ヮ◕)ノ*.✧3 小时前
C++学习:基础知识的掌握
c++·visualstudio
江畔柳前堤4 小时前
HBM:大语言模型时代的「算力血液」——从内存墙到带宽革命的深度拆解
服务器·人工智能·windows·目标检测·语言模型·自然语言处理·软件工程
猎嘤一号8 小时前
博弈论(Game Theory)的理论、算法与工程
人工智能·算法·安全·博弈论
C++ 老炮儿的技术栈9 小时前
从 Qt Designer 属性编辑器的层级可以看到继承链
c语言·数据库·c++·qt·sqlite·visual studio
天天进步20159 小时前
UI-TARS 源码解析 #22:二次开发实战:用 UI-TARS 做一个简单的 Windows 自动操作 Agent
windows·ui
啊哈一半醒9 小时前
Windows 虚拟机搭建 CentOS 全过程(2026 最新版 + 踩坑解决)
linux·windows·centos
new_zhou9 小时前
C++ 项目 AI 协作指南(Windows / MSVC 环境)
c++·人工智能·windows
观远数据10 小时前
当ChatBI遇上数据合规:AI+BI规模化落地的安全边界如何划定
大数据·人工智能·安全
YYJ-F11 小时前
Anthropic——AI安全人工智能研究公司,核心产品Claude Code辅助编程
人工智能·安全
水饺编程11 小时前
AT&T 汇编语言学习笔记开篇语
c语言·汇编·c++