吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1095|回复: 7
上一主题 下一主题
收起左侧

[系统底层] [自学][Windows入门]Windows-Run与RunOnce注册表启动项的枚举

  [复制链接]
跳转到指定楼层
楼主
WangWei-CM 发表于 2026-7-15 22:24 回帖奖励
本帖最后由 WangWei-CM 于 2026-7-17 10:24 编辑

这是系列文章,因为重合度较高,后续会在跟帖中继续此板块的讲解,各位只看楼主即可

Windows Run 与 RunOnce 注册表启动项的安全枚举

RunRunOnce 是登录阶段常见的注册表位置。安全读取的顺序是:确定根键和视图,使用只读权限打开键,按 API 返回的长度分配缓冲区,按值类型解释原始字节,最后再解释 RunOnce 的值名前缀。

一、先确定要读取的位置

1. 根键决定配置范围

读取范围的第一个问题是区分当前用户与本机配置。常见位置如下:

HKCU\Software\Microsoft\Windows\CurrentVersion\Run
HKCU\Software\Microsoft\Windows\CurrentVersion\RunOnce
HKLM\Software\Microsoft\Windows\CurrentVersion\Run
HKLM\Software\Microsoft\Windows\CurrentVersion\RunOnce

HKCUHKEY_CURRENT_USER 的缩写,表示当前登录用户的注册表配置。HKLMHKEY_LOCAL_MACHINE 的缩写,表示本机配置;同名值分别位于 HKCU 与 HKLM 时,属于两条独立记录。

条目身份的第二个问题是不能只保存值名或命令文本。读取结果需要同时保留根键、完整子键路径、32 位或 64 位视图和值名;命令文本属于该记录的数据,不能单独充当位置。

RunOnce 的第三个问题是值存在时间不能表示执行结果。Run 值通常会保留在键中,RunOnce 值涉及删除时机;某个值消失或仍然存在,都不足以单独说明对应目标是否已成功启动或完成。

2. 机器级软件键需要明确注册表视图

视图选择的第一个问题是进程位数会改变默认读取范围。64 位 Windows 对部分 HKLM\Software 键维护 32 位和 64 位视图;64 位调用方默认读取 64 位视图,32 位调用方默认读取 32 位视图。

访问掩码的第二个问题是让读取范围固定。需要检查机器级 Run 或 RunOnce 时,使用 KEY_WOW64_64KEY 请求 64 位视图,使用 KEY_WOW64_32KEY 请求 32 位视图。两次读取分别记录视图名称,才能区分同一路径文本下的不同数据。

用户键的第三个问题是不能手工把 Wow6432Node 拼进路径。这个名称是部分注册表重定向呈现出来的结果,适用范围由 Windows 对具体键的规则决定。用户键应使用标准路径,再根据目标键选择是否指定视图。

二、用只读权限打开一个启动键

3. 打开键时先声明函数含义和返回规则

打开现有注册表键的宽字符 API 会返回一个新的 HKEY。返回值类型是 LSTATUSERROR_SUCCESS 表示成功,其它值已经是 Win32 错误码,能够直接转换为错误文本。

// 意义:打开 hKey 下的 lpSubKey,并按 samDesired 请求指定权限和注册表视图。
// 返回:ERROR_SUCCESS 成功;其它 LSTATUS 表示键不存在、权限不足等错误。
LSTATUS RegOpenKeyExW(
    HKEY hKey,             // 输入:预定义根键或已打开的父键。
    LPCWSTR lpSubKey,      // 输入:相对 hKey 的子键路径;不能为未终止的字节缓冲区。
    DWORD ulOptions,       // 输入:保留,必须为 0。
    REGSAM samDesired,     // 输入:访问权限与 KEY_WOW64_* 视图标志的组合。
    PHKEY phkResult        // 输出:成功时得到的新 HKEY;调用方负责关闭。
);

// 意义:关闭 RegOpenKeyExW 成功返回的 HKEY。
// 返回:ERROR_SUCCESS 成功;关闭预定义根键没有读取价值,也不应当作普通子键句柄管理。
LSTATUS RegCloseKey(
    HKEY hKey              // 输入:先前成功打开且尚未关闭的键句柄。
);

正确打开的第一个步骤是只请求枚举所需权限。KEY_QUERY_VALUE 允许读取值;对 HKLM 显式加入一个视图标志,成功后在同一作用域关闭返回的句柄。

const wchar_t* const runPath =
    L"Software\\Microsoft\\Windows\\CurrentVersion\\Run";

HKEY openedKey = nullptr;
const REGSAM access = KEY_QUERY_VALUE | KEY_WOW64_64KEY;
const LSTATUS openStatus = RegOpenKeyExW(
    HKEY_LOCAL_MACHINE,    // 从本机配置根键开始。
    runPath,               // 使用标准相对路径。
    0,                     // 保留参数固定为 0。
    access,                // 只读权限加 64 位视图。
    &openedKey);           // 接收新句柄的地址。

if (openStatus != ERROR_SUCCESS) {
    // openStatus 是 API 直接返回的错误码,应按它处理错误。
    return;
}

// 此处读取 openedKey 中的值。
RegCloseKey(openedKey);    // 成功打开的句柄在完成读取后关闭。

错误用法的第二个问题是把默认视图误当成完整范围。下面的调用在 32 位调用方中默认打开 32 位视图,无法用于固定读取 64 位视图。

// 错误:缺少 KEY_WOW64_64KEY,读取范围随调用方位数变化。
RegOpenKeyExW(HKEY_LOCAL_MACHINE, runPath, 0, KEY_QUERY_VALUE, &openedKey);

三、先查询容量,再枚举值

4. 查询键信息时区分字符数与字节数

查询键信息的宽字符 API 会返回统计信息和各类名称、数据的最大长度。返回 ERROR_SUCCESS 时,最长值名的单位是宽字符个数,最长值数据的单位是字节;两者不能互换。

// 意义:查询一个已打开键的子键数、值数、名称最大长度和数据最大长度。
// 返回:ERROR_SUCCESS 成功;其它 LSTATUS 表示句柄无效、权限不足等错误。
LSTATUS RegQueryInfoKeyW(
    HKEY hKey,                     // 输入:已成功打开的注册表键。
    LPWSTR lpClass,                // 输出,可选:键类名缓冲区。
    LPDWORD lpcchClass,            // 输入/输出,可选:类名缓冲区字符容量与实际长度。
    LPDWORD lpReserved,            // 保留:必须为 nullptr。
    LPDWORD lpcSubKeys,            // 输出,可选:直接子键数量。
    LPDWORD lpcMaxSubKeyLen,       // 输出,可选:最长直接子键名的字符数,不含 NUL。
    LPDWORD lpcMaxClassLen,        // 输出,可选:最长子键类名的字符数。
    LPDWORD lpcValues,             // 输出,可选:直接值数量。
    LPDWORD lpcMaxValueNameLen,    // 输出,可选:最长值名的字符数,不含 NUL。
    LPDWORD lpcMaxValueLen,        // 输出,可选:最长值数据的字节数。
    LPDWORD lpcbSecurityDescriptor,// 输出,可选:安全描述符字节数。
    PFILETIME lpftLastWriteTime    // 输出,可选:最后写入时间。
);

正确分配的第一个步骤是为值名额外预留一个 NUL,为值数据按字节预留空间。这里使用 std::vector<wchar_t> 接收名称,使用 std::vector<BYTE> 接收原始数据。

DWORD valueCount = 0;
DWORD maxValueNameChars = 0;
DWORD maxValueDataBytes = 0;

const LSTATUS queryStatus = RegQueryInfoKeyW(
    openedKey,
    nullptr, nullptr, nullptr,      // 不读取类名和保留字段。
    nullptr, nullptr, nullptr,      // 不读取子键统计信息。
    &valueCount,                    // 接收当前直接值数量。
    &maxValueNameChars,             // 接收最长值名的字符数。
    &maxValueDataBytes,             // 接收最长值数据的字节数。
    nullptr, nullptr);              // 不读取安全描述符和时间。

if (queryStatus != ERROR_SUCCESS) {
    return;
}

std::vector<wchar_t> valueName(maxValueNameChars + 1, L'\0');
std::vector<BYTE> valueData(maxValueDataBytes == 0 ? 1 : maxValueDataBytes);

错误分配的第二个问题是把字节数直接作为宽字符数量。下面的写法会把数据容量误读为字符数量,并隐藏名称缓冲区缺少终止符空间的问题。

// 错误:maxValueDataBytes 的单位是字节,不能用于 wchar_t 元素数量。
std::vector<wchar_t> valueData(maxValueDataBytes);

// 错误:maxValueNameChars 不包含 NUL,最大长度值名会没有终止符位置。
std::vector<wchar_t> valueName(maxValueNameChars);

5. 枚举一个值时必须重新提供容量

按索引读取当前键直接值的宽字符 API 会修改两个长度参数。返回 ERROR_SUCCESS 时,值名长度变为实际字符数,数据长度变为实际字节数;下一次调用前必须重新填回缓冲区容量。

// 意义:读取 hKey 中索引为 dwIndex 的一个直接值。
// 返回:ERROR_SUCCESS 成功;ERROR_NO_MORE_ITEMS 表示枚举结束;ERROR_MORE_DATA 表示缓冲区不足。
LSTATUS RegEnumValueW(
    HKEY hKey,                 // 输入:已打开且具有 KEY_QUERY_VALUE 权限的键。
    DWORD dwIndex,             // 输入:当前值索引。
    LPWSTR lpValueName,        // 输出:值名缓冲区,可为 nullptr。
    LPDWORD lpcchValueName,    // 输入/输出:值名字符容量与实际长度,不含 NUL。
    LPDWORD lpReserved,        // 保留:必须为 nullptr。
    LPDWORD lpType,            // 输出:REG_SZ、REG_DWORD 等注册表类型。
    LPBYTE lpData,             // 输出:原始值数据缓冲区,可为 nullptr。
    LPDWORD lpcbData           // 输入/输出:数据字节容量与实际长度。
);

正确枚举的第一个步骤是在每次调用前恢复两个容量变量。成功后按 API 返回的实际长度裁剪缓冲区,避免把预分配的尾部零字节误当作注册表数据。

for (DWORD index = 0;; ++index) {
    LSTATUS enumStatus = ERROR_MORE_DATA;
    DWORD valueType = REG_NONE;

    // 最多重试三次;每次重试都重新设置输入容量。
    for (int attempt = 0; attempt < 3 && enumStatus == ERROR_MORE_DATA; ++attempt) {
        valueName.assign(maxValueNameChars + 1, L'\0');
        valueData.assign(maxValueDataBytes == 0 ? 1 : maxValueDataBytes, 0);
        DWORD valueNameChars = static_cast<DWORD>(valueName.size());
        DWORD valueDataBytes = static_cast<DWORD>(valueData.size());

        enumStatus = RegEnumValueW(
            openedKey, index,
            valueName.data(), &valueNameChars,
            nullptr, &valueType,
            valueData.data(), &valueDataBytes);

        if (enumStatus == ERROR_SUCCESS) {
            valueName.resize(valueNameChars);  // 字符数,不包含 NUL。
            valueData.resize(valueDataBytes);  // 字节数。
            break;
        }
        if (enumStatus == ERROR_MORE_DATA) {
            // 注册表可能刚刚变化;刷新上限后重试同一个 index。
            const LSTATUS refreshStatus = RegQueryInfoKeyW(
                openedKey, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
                &valueCount, &maxValueNameChars, &maxValueDataBytes, nullptr, nullptr);
            if (refreshStatus != ERROR_SUCCESS) {
                enumStatus = refreshStatus;
            }
        }
    }

    if (enumStatus == ERROR_NO_MORE_ITEMS) {
        break;                         // 正常结束。
    }
    if (enumStatus != ERROR_SUCCESS) {
        continue;                      // 三次重试后仍失败,继续后续索引。
    }

    // 此处开始按 valueType 解释 valueData。
}

错误用法的第二个问题是复用上一次的实际长度。第一次读取短值后,下面的下一次调用只给 API 一个很小的容量,长值会更容易得到 ERROR_MORE_DATA

// 错误:valueNameChars、valueDataBytes 已经被上一次调用改成实际长度。
RegEnumValueW(openedKey, index, valueName.data(), &valueNameChars,
              nullptr, &valueType, valueData.data(), &valueDataBytes);

四、先按类型和字节边界读取值数据

6. 文本类型需要先检查 UTF-16 单元边界

类型处理的第一个步骤是只把 REG_SZREG_EXPAND_SZ 当作单个 UTF-16 文本。REG_SZ 保存普通文本,REG_EXPAND_SZ 保存含环境变量标记的文本;REG_DWORDREG_QWORDREG_BINARYREG_MULTI_SZ 具有不同的数据布局。

REG_EXPAND_SZ 的内容可以是下面这种原始文本。%SystemRoot% 是环境变量标记,当前系统常把它展开为 Windows 目录;原始文本与展开后的文本都应保留。

原始 REG_EXPAND_SZ:%SystemRoot%\System32\SecurityHealthSystray.exe
当前环境展开后: C:\Windows\System32\SecurityHealthSystray.exe

正确解码的第二个步骤是先验证字节数,再按实际长度复制。注册表 API 返回的数据长度以字节表示,Windows 的 wchar_t 占两个字节;奇数字节长度不能形成完整 UTF-16 单元。

// 意义:将已经读取到的 REG_SZ 或 REG_EXPAND_SZ 原始字节转成宽字符串。
// 返回:true 表示类型和 UTF-16 长度有效;false 表示应保留原始类型与字节数,不显示文本。
bool DecodeRegistryText(
    DWORD valueType,                 // 输入:RegEnumValueW 返回的注册表类型。
    const std::vector<BYTE>& bytes,  // 输入:RegEnumValueW 返回的实际字节,不依赖 NUL。
    std::wstring& textOut            // 输出:成功时得到不含一个尾部 NUL 的文本。
) {
    if (valueType != REG_SZ && valueType != REG_EXPAND_SZ) {
        return false;                 // 其它类型不能按单个命令文本解释。
    }
    if (bytes.size() % sizeof(wchar_t) != 0) {
        return false;                 // 奇数字节长度会截断一个 UTF-16 单元。
    }

    textOut.resize(bytes.size() / sizeof(wchar_t));
    if (!bytes.empty()) {
        std::memcpy(textOut.data(), bytes.data(), bytes.size());
    }
    if (!textOut.empty() && textOut.back() == L'\0') {
        textOut.pop_back();           // 只移除真实存在的一个结尾终止符。
    }
    return true;
}

错误解码的第三个问题是把原始字节强制转换为 NUL 终止字符串。wcslen 会一直查找终止符,未终止的注册表数据会让读取越过 bytes 的分配边界。

// 错误:bytes 的长度来自 API,不能假定 data() 后面存在 L'\0'。
const wchar_t* text = reinterpret_cast<const wchar_t*>(bytes.data());
std::wstring copied(text, wcslen(text));

数值数据的第四个步骤是验证固定长度。REG_DWORD 必须恰好有四个字节,REG_QWORD 必须恰好有八个字节;长度不匹配时,只能把它报告为类型与长度异常,不能把部分字节当成完整数值。

二进制数据的第五个步骤是保留类型。REG_BINARY 即使恰好包含可显示文字,也应保留其二进制类型、字节长度和有限十六进制摘要;文本外观不能改变注册表的原始语义。

7. 显示文本时需要把 UTF-16 转成 UTF-8

宽字符到多字节的转换 API 按指定代码页输出文本。返回值是写入或所需的字节数,返回 0 表示转换失败;CP_UTF8 选择 UTF-8 输出,WC_ERR_INVALID_CHARS 要求遇到无效 UTF-16 时失败。

// 意义:将 UTF-16 宽字符转换为 UTF-8 字节。
// 返回:非零表示写入或所需的 UTF-8 字节数;0 表示转换失败。
int WideCharToMultiByte(
    UINT CodePage,             // 输入:目标代码页;CP_UTF8 表示 UTF-8。
    DWORD dwFlags,             // 输入:WC_ERR_INVALID_CHARS 用于拒绝无效 UTF-16。
    LPCWCH lpWideCharStr,      // 输入:宽字符起始地址。
    int cchWideChar,           // 输入:宽字符数量;传显式长度时不包含 NUL。
    LPSTR lpMultiByteStr,      // 输出:UTF-8 缓冲区;首次查询容量时为 nullptr。
    int cbMultiByte,           // 输入:输出缓冲区的字节容量;首次查询时为 0。
    LPCCH lpDefaultChar,       // 输入:UTF-8 时必须为 nullptr。
    LPBOOL lpUsedDefaultChar   // 输出:UTF-8 时必须为 nullptr。
);

正确转换的第一个步骤是调用两次。第一次只查询 UTF-8 所需字节数,第二次使用同样的显式 UTF-16 长度写入精确容量;传入长度不是 -1 时,结果不包含额外 NUL。

std::string ToUtf8(const std::wstring_view text) {
    if (text.empty()) {
        return {};
    }

    const int neededBytes = WideCharToMultiByte(
        CP_UTF8, WC_ERR_INVALID_CHARS,
        text.data(), static_cast<int>(text.size()),
        nullptr, 0, nullptr, nullptr);
    if (neededBytes <= 0) {
        return {};                       // 调用方把空结果作为转换失败处理。
    }

    std::string result(static_cast<std::size_t>(neededBytes), '\0');
    const int writtenBytes = WideCharToMultiByte(
        CP_UTF8, WC_ERR_INVALID_CHARS,
        text.data(), static_cast<int>(text.size()),
        result.data(), neededBytes, nullptr, nullptr);
    return writtenBytes == neededBytes ? result : std::string();
}

错误转换的第二个问题是把 UTF-16 字符数量当成 UTF-8 字节容量。中文和其它非 ASCII 字符通常需要多个 UTF-8 字节,下面的容量会不足。

// 错误:text.size() 是 UTF-16 字符数,不能直接用作 UTF-8 字节容量。
std::string utf8(text.size(), '\0');
WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast<int>(text.size()),
                    utf8.data(), static_cast<int>(utf8.size()), nullptr, nullptr);

五、把环境展开与命令行解析分开

8. 环境展开使用当前进程的环境块

环境变量展开的宽字符 API 用当前进程环境变量替换文本中的 %名称% 标记。返回值是包含结尾 NUL 在内所需或实际字符数,返回 0 表示失败;展开结果反映当前环境,原始 REG_EXPAND_SZ 文本仍是注册表事实。

// 意义:将 source 中的 %VARIABLE% 替换为当前进程环境变量的值。
// 返回:非零表示所需或写入字符数,数值包含结尾 NUL;0 表示失败。
DWORD ExpandEnvironmentStringsW(
    LPCWSTR lpSrc,   // 输入:以 NUL 结束的源文本。
    LPWSTR lpDst,    // 输出:目标缓冲区;查询容量时为 nullptr。
    DWORD nSize      // 输入:lpDst 的 wchar_t 容量;查询容量时为 0。
);

正确展开的第一个步骤是保留原始文本,并先查询输出容量。环境展开 API 需要 NUL 终止输入,因此内部含 NUL 的注册表文本不能直接交给它;成功后移除 API 写入的一个结尾 NUL。

bool ExpandCurrentEnvironment(
    const std::wstring_view source,
    std::wstring& expandedOut) {
    if (source.find(L'\0') != std::wstring_view::npos) {
        return false;                   // c_str() 会截断内嵌 NUL 后的原始数据。
    }

    const std::wstring sourceText(source);
    const DWORD requiredChars = ExpandEnvironmentStringsW(
        sourceText.c_str(), nullptr, 0);
    if (requiredChars == 0) {
        return false;
    }

    std::wstring buffer(requiredChars, L'\0');
    if (ExpandEnvironmentStringsW(sourceText.c_str(), buffer.data(), requiredChars) == 0) {
        return false;
    }
    buffer.resize(requiredChars - 1);    // requiredChars 包含 API 写入的 NUL。
    expandedOut = std::move(buffer);
    return true;
}

错误展开的第二个问题是提前减去终止符容量。下面的缓冲区比 API 要求少一个宽字符,无法容纳完整展开结果和结尾 NUL。

// 错误:requiredChars 已经包含 NUL,不能在调用前减一。
std::wstring buffer(requiredChars - 1, L'\0');
ExpandEnvironmentStringsW(sourceText.c_str(), buffer.data(), requiredChars - 1);

命令行边界的第三个步骤是保留完整文本。"C:\\Program Files\\Agent\\agent.exe" --background 的第一个空格位于带引号的路径内部;rundll32.execmd.exe /c 和脚本解释器还具有不同参数规则,因此不能按第一个空格切出所谓的唯一镜像路径。

六、解释 RunOnce 值名前缀

9. 前缀只说明 Windows 对该值的处理条件

!* 的第一个作用是改变 RunOnce 值的处理条件。值名以 ! 开头时,删除时机延后到命令行被系统处理之后;以 * 开头时,该项允许在安全模式下被处理;两个字符可以连续出现,例如 !*Repair

正确解析的第二个步骤是从值名开头连续读取控制字符,同时保留原始值名。去掉前缀后的文本只用于显示名称,原始名称仍然是注册表中真正保存的名称。

struct RunOnceNameInfo {
    std::wstring_view displayName;  // 去除开头控制字符后的名称视图。
    bool deferDeletion = false;     // 是否出现 !。
    bool allowSafeMode = false;     // 是否出现 *。
};

RunOnceNameInfo ParseRunOnceName(const std::wstring_view originalName) {
    RunOnceNameInfo info{};
    std::size_t offset = 0;
    while (offset < originalName.size()) {
        if (originalName[offset] == L'!') {
            info.deferDeletion = true;
            ++offset;
            continue;
        }
        if (originalName[offset] == L'*') {
            info.allowSafeMode = true;
            ++offset;
            continue;
        }
        break;                         // 首个普通字符开始显示名称。
    }
    info.displayName = originalName.substr(offset);
    return info;
}

错误解析的第三个问题是只识别一个固定前缀顺序。下面的判断会遗漏 *!Repair,也无法保留多个连续控制字符的语义。

// 错误:只处理第一个字符,组合前缀无法完整识别。
if (!originalName.empty() && originalName[0] == L'!') {
    // 只记录 !,忽略紧随其后的 *。
}

执行结果的第四个问题是前缀不能证明成功。! 改变的是删除时机,值名带有 !* 只能说明观察到的配置;是否创建进程、目标是否完成仍要用进程创建记录、事件日志或其它独立证据确认。

七、让资源释放和错误状态保持准确

10. 句柄所有权需要覆盖所有返回路径

资源管理的第一个步骤是让一个对象独占 RegOpenKeyExW 成功返回的句柄。析构函数调用前面声明的 RegCloseKey,无论读取成功、容量查询失败或中途返回,键句柄都会释放。

class RegKey final {
public:
    explicit RegKey(HKEY key) : key_(key) {}
    ~RegKey() {
        if (key_ != nullptr) {
            RegCloseKey(key_);          // 只关闭打开得到的子键句柄。
        }
    }

    RegKey(const RegKey&) = delete;     // 禁止两个对象关闭同一个 HKEY。
    RegKey& operator=(const RegKey&) = delete;

    HKEY get() const { return key_; }   // 借出句柄,不转移关闭责任。

private:
    HKEY key_ = nullptr;
};

错误状态的第二个步骤是直接保存注册表 API 的 LSTATUSRegOpenKeyExWRegQueryInfoKeyWRegEnumValueW 已经通过返回值给出错误码;在其它调用之后再读取线程错误状态,可能得到无关的旧值。

错误释放的第三个问题是遗漏早退分支。下面的直接 returnopenedKey 没有机会关闭;读取大量键时,这类泄漏会累积系统句柄。

// 错误:QuerySomething 失败时,openedKey 没有调用 RegCloseKey。
if (QuerySomething(openedKey) != ERROR_SUCCESS) {
    return;
}

并发变化的第四个步骤是限制重试次数。ERROR_MORE_DATA 表示查询到容量后数据又增长,重新查询上限并最多重试同一个索引几次即可;持续失败时记录根键、视图、索引和返回码,再继续其它位置,避免枚举停在一个不断变化的值上。

完整可运行程序在附件。


执行效果如下:
001-Windows-Run与RunOnce注册表启动项的安全枚举.7z (134.09 KB, 下载次数: 6)

免费评分

参与人数 1吾爱币 +1 热心值 +1 收起 理由
fengzhongdexin + 1 + 1 用心讨论,共获提升!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
jrjmusic 发表于 2026-7-17 02:52
解释得非常详细!确实受益颇多!
3#
kid5117 发表于 2026-7-17 06:07
4#
 楼主| WangWei-CM 发表于 2026-7-17 10:25 |楼主

Windows 兼容与特殊登录启动键的枚举

兼容启动键、策略型启动键和命令解释器配置键分散在多个注册表位置。枚举时要先确定来源,再读取原始数据,最后按各自的用途解释。这个顺序可以避免漏项,也能防止把配置记录误当成程序已经运行的证据。

一、对象关系和完整流程

1. 先分清系统使用流程和枚举流程

系统使用流程从一个触发动作开始。触发动作可能是用户登录、启动 cmd.exe,也可能是进入终端服务的安装模式。负责该动作的 Windows 组件会寻找对应的注册表位置。它读取值类型和数据后,才决定是否处理其中的命令。

登录 Shell 是用户登录后提供桌面和开始菜单的程序,Windows 默认使用 explorer.exe。命令解释器是读取并执行命令文本的程序,这里指 cmd.exe。终端服务安装模式用于处理多用户环境中的应用安装兼容问题。三个场景由不同组件处理,因此不能把相关注册表值都叫作“开机自启”。

安装程序、管理员或策略写入配置 -> 形成“根键 + 注册表视图 + 子键路径 + 值名”记录 -> 登录、cmd.exe 启动或终端服务安装模式触发 -> 对应的 Windows 组件读取配置 -> 解释值类型和文本 -> 决定是否执行

枚举流程只读取配置。它不会触发这些命令,也不能单独证明某个命令曾经执行。

列出所有来源 -> 确定根键和注册表视图 -> 用只读权限打开键 -> 按 API 返回的长度读取原始字节 -> 记录值名、类型、长度和读取状态 -> 对已验证的文本生成解释结果

2. 一条记录由四部分共同定位

注册表是 Windows 管理的分层配置数据库。子键类似目录,值类似目录中的具名配置项。根键 HKEY_LOCAL_MACHINE(HKLM)保存本机范围配置,通常影响多个用户。根键 HKEY_CURRENT_USER(HKCU)保存当前用户配置,通常只影响当前登录身份。

一条记录的位置由根键、注册表视图、子键路径和值名共同组成。例如:

根键:HKCU
子键:Software\Microsoft\Command Processor
值名:Autorun

这四部分中的任一项不同,都应视为另一条记录。子键不存在、值不存在、值为空字符串和读取失败也是四种不同状态。枚举结果要分别显示这些状态,避免把权限不足写成“没有配置”。

HKLM
    Software\Microsoft\Windows\CurrentVersion\Run
        SecurityHealth = %windir%\system32\SecurityHealthSystray.exe

3. 注册表视图决定读取哪一份数据

64 位 Windows 会为部分软件配置维护 64 位和 32 位注册表视图。视图可以理解为同一路径下的两套独立数据。64 位程序默认看到 64 位视图,32 位程序默认看到 32 位视图。

KEY_WOW64_64KEY 明确选择 64 位视图。KEY_WOW64_32KEY 明确选择 32 位视图。枚举程序应主动选择视图,使结果不受程序自身位数影响。

HKLM\Software
    64 位视图:供 64 位组件使用
    32 位视图:供 WOW64 下的 32 位组件使用

4. 值类型决定原始字节的解释方法

注册表 API 返回值类型和一段原始字节。REG_SZ 保存普通 UTF-16 文本。UTF-16 是 Windows 宽字符 API 常用的文本编码,每个编码单元占两个字节。REG_EXPAND_SZ 也保存 UTF-16 文本,其中可以包含 %SystemRoot% 这样的环境变量标记。

REG_DWORD 保存四字节无符号整数。REG_MULTI_SZ 保存多段文本。每段文本以一个 NUL 结束,整个列表以额外的 NUL 结束。NUL 是数值为零的终止字符。

文本转换前必须先检查值类型和返回的字节数。类型不匹配、字节数为奇数或文本没有终止符时,都要按异常数据处理。

REG_SZ:        explorer.exe
REG_EXPAND_SZ: %SystemRoot%\System32\example.exe
REG_DWORD:     0x00000001
REG_MULTI_SZ:  first\0second\0\0

5. 读取时先保留来源

打开键之前,先记录 HKLM 或 HKCU、完整子键路径和 32 位或 64 位视图。枚举值时,再记录值名、REG 类型、原始字节长度和 API 返回码。来源信息与原始数据保存完成后,才能解释 REG_SZREG_EXPAND_SZ 文本。

环境变量展开、路径检查、签名验证和命令行拆分都属于后续分析。它们不能覆盖原始文本,也不能代替来源信息。

读取状态也要保留。ERROR_FILE_NOT_FOUND 表示对象不存在。ERROR_ACCESS_DENIED 表示当前访问权限不足。ERROR_MORE_DATA 表示缓冲区太小,需要扩容后重试。ERROR_NO_MORE_ITEMS 表示枚举正常结束。每个来源都要独立读取,一个来源失败后仍应继续处理其它来源。

6. 配置存在不等于程序已经运行

配置层只回答键和值是否存在,以及读取到了什么原始数据。处理层回答当前 Windows 版本和相关组件是否会读取该位置。运行层回答命令是否执行、目标进程是否创建,以及程序是否正常完成。

枚举程序只能直接观察配置层。它应记录完整路径、值名、REG 类型、原始字节长度、注册表视图、调用账户和读取时间。实际执行情况需要结合进程、事件或日志证据判断。

7. API 类型说明资源和长度单位

HKEY 是打开注册表键后得到的引用。调用方完成读取后,要用 RegCloseKey 释放它。REGSAM 是访问权限的位集合。KEY_QUERY_VALUE 允许读取值,KEY_WOW64_32KEYKEY_WOW64_64KEY 用于选择注册表视图。DWORD 是 32 位无符号整数,可以保存访问位、值类型或长度。

名称长度通常以 UTF-16 宽字符个数计算。值数据长度通常以字节计算。注册表返回的文本不一定带有结尾 NUL,因此只能按 API 返回的精确长度构造字符串。使用 wcslen 扫描未确认终止的缓冲区可能越过有效数据边界。

二、先把不同位置分开记录

1. 每个键的作用范围不同

枚举应先列出所有独立来源。常见的兼容或特殊登录位置包括:

HKCU\Software\Microsoft\Windows\CurrentVersion\RunServices
HKLM\Software\Microsoft\Windows\CurrentVersion\RunServices
HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer\Run
HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer\Run
HKCU\Software\Microsoft\Windows NT\CurrentVersion\Windows  (Run、Load)
HKCU\Software\Microsoft\Command Processor                 (Autorun)
HKLM\Software\Microsoft\Command Processor                 (Autorun)
HKLM\Software\Microsoft\Windows NT\CurrentVersion\Terminal Server\Install\...

根键必须作为来源的一部分保留。HKCU 保存当前用户配置,HKLM 保存本机配置。同名值出现在两处时,代表两个独立设置。

不同键也有不同用途。RunServices 属于兼容位置。策略型 Explorer\Run 位于策略路径。Windows 键中的 RunLoad 是固定值名。Command Processor 的 Autorun 是命令解释器初始化设置。

HKLM 的 32 位和 64 位软件视图需要分别读取。KEY_WOW64_64KEYKEY_WOW64_32KEY 可以固定读取范围。结果中必须写明视图。路径文本相同的值也不能合并。

2. 打开键时只请求读取权限

打开现有键的宽字符 API 返回一个新的 HKEY。返回 ERROR_SUCCESS 表示成功,其它 LSTATUS 返回值表示键不存在、权限不足等 Win32 错误。

// 意义:在 hKey 下打开 lpSubKey,并请求 samDesired 指定的权限和注册表视图。
// 返回:ERROR_SUCCESS 表示成功。其它 LSTATUS 是可记录的 Win32 错误码。
LSTATUS RegOpenKeyExW(
    HKEY hKey,          // 输入:预定义根键或已经打开的父键。
    LPCWSTR lpSubKey,   // 输入:相对 hKey 的、以 NUL 结束的键路径。
    DWORD ulOptions,    // 输入:保留,必须为 0。
    REGSAM samDesired,  // 输入:KEY_QUERY_VALUE 与可选 KEY_WOW64_* 标志。
    PHKEY phkResult     // 输出:成功时得到的新 HKEY,调用方负责关闭。
);

// 意义:释放 RegOpenKeyExW 成功得到的键句柄。
// 返回:ERROR_SUCCESS 表示成功。同一个打开句柄只能关闭一次。
LSTATUS RegCloseKey(
    HKEY hKey           // 输入:已打开且尚未关闭的子键句柄。
);

正确做法:先记录键路径和视图,再用最小权限打开键。一个键不能读取时,记录该来源的失败状态,然后继续处理其它来源。

HKEY key = nullptr;
const LSTATUS status = RegOpenKeyExW(
    HKEY_LOCAL_MACHINE,
    L"Software\\Microsoft\\Command Processor",
    0,
    KEY_QUERY_VALUE | KEY_WOW64_64KEY,
    &key);

if (status == ERROR_SUCCESS) {
    // 读取 Autorun 等直接值。
    RegCloseKey(key);
}

错误示例:把默认视图当成完整范围。下面的调用在 32 位程序中默认读取 32 位视图,无法固定得到 64 位 Command Processor 设置。

// 错误:缺少 KEY_WOW64_64KEY,结果依赖调用方位数。
RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"Software\\Microsoft\\Command Processor",
              0, KEY_QUERY_VALUE, &key);

三、用长度上限枚举每个键的直接值

3. 查询容量时区分名称字符数与数据字节数

查询键信息的宽字符 API 返回直接值数量、最长值名和最长值数据长度。最长值名以宽字符个数计算。最长数据以字节计算。最大值名长度不包含结尾 NUL。

// 意义:读取已打开键的值数量、最长值名和最长值数据长度。
// 返回:ERROR_SUCCESS 表示成功。其它 LSTATUS 表示键句柄或访问状态异常。
LSTATUS RegQueryInfoKeyW(
    HKEY hKey, LPDWORD lpClass, LPDWORD lpcchClass, LPDWORD lpReserved,
    LPDWORD lpcSubKeys, LPDWORD lpcMaxSubKeyLen, LPDWORD lpcMaxClassLen,
    LPDWORD lpcValues,           // 输出:当前直接值数量。
    LPDWORD lpcMaxValueNameLen,  // 输出:最长值名字符数,不含 NUL。
    LPDWORD lpcMaxValueLen,      // 输出:最长值数据字节数。
    LPDWORD lpcbSecurityDescriptor, PFILETIME lpftLastWriteTime
);

正确做法:为值名额外预留一个 wchar_t,值数据则按字节分配。RunLoadAutorun 都可能是空文本、未终止文本或环境变量文本。数据长度只能使用 API 返回值。

DWORD valueCount = 0, maxNameChars = 0, maxDataBytes = 0;
const LSTATUS status = RegQueryInfoKeyW(
    key, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
    &valueCount, &maxNameChars, &maxDataBytes, nullptr, nullptr);
if (status != ERROR_SUCCESS) {
    return;
}

std::vector<wchar_t> valueName(maxNameChars + 1, L'\0');
std::vector<BYTE> valueData(maxDataBytes == 0 ? 1 : maxDataBytes);

错误示例:把数据字节数当成宽字符数。这个写法会掩盖单位错误,也没有为最大长度值名预留 NUL 位置。

// 错误:maxDataBytes 的单位是字节,不能用来构造 wchar_t 缓冲区。
std::vector<wchar_t> valueData(maxDataBytes);

4. 枚举值时重新填入两个容量参数

按索引读取值的宽字符 API 只读取当前键的直接值。返回成功后,名称长度变为实际字符数,数据长度变为实际字节数。下一次调用前,需要重新填入缓冲区容量。

// 意义:读取 hKey 中 dwIndex 指定的一个直接值。
// 返回: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
);

正确做法:每次调用前恢复容量。遇到 ERROR_MORE_DATA 时,重新查询长度上限,再有限重试同一索引。只有 ERROR_NO_MORE_ITEMS 表示当前键枚举正常结束。值数量只是查询时刻的统计结果。

DWORD nameChars = static_cast<DWORD>(valueName.size());
DWORD dataBytes = static_cast<DWORD>(valueData.size());
DWORD type = REG_NONE;
const LSTATUS status = RegEnumValueW(
    key, index, valueName.data(), &nameChars, nullptr,
    &type, valueData.data(), &dataBytes);

if (status == ERROR_SUCCESS) {
    valueName.resize(nameChars);   // 实际字符数,不包含 NUL。
    valueData.resize(dataBytes);   // 实际字节数。
}

错误示例:把所有值都解释为启动命令。Windows 键中的 RunLoad 需要按固定值名识别。Command Processor 的 Autorun 也需要单独识别。策略键和 RunServices 中的普通值仍要保留完整位置与类型。未知值不能只靠名称猜测用途。

四、保留文本、环境变量和命令解释边界

5. 原始 REG_EXPAND_SZ 文本与展开文本不同

原始字节必须先保留。一个 REG_EXPAND_SZ 值可以是 %SystemRoot%\System32\cmd.exe /d /c task.cmd。其中,%SystemRoot% 来自当前进程的环境变量。不同用户和服务账户可能得到不同的 %APPDATA%%USERPROFILE% 展开结果。

命令文本必须完整保留。"C:\\Program Files\\Tool\\tool.exe" /background 的第一个空格位于引号内。cmd.exe /c 后面的内容也要按命令解释器规则处理。按第一个空格截断会得到错误的路径。

只有 REG_SZREG_EXPAND_SZ 可以按单个 UTF-16 文本处理。REG_BINARYREG_DWORDREG_MULTI_SZ 需要保留原始类型与字节长度。数据看起来可以显示,也不能直接按命令文本解释。

五、让结论保持在只读范围内

6. 同名值不应跨位置去重

条目位置必须包含根键、注册表视图、键路径和值名。策略型 Run 与普通 Run 属于不同位置。HKCU 与 HKLM 也属于不同位置。32 位与 64 位视图即使出现相同命令文本,也不能合并为同一条设置。

注册表状态和实际活动需要分别判断。读取到兼容键或 Autorun 值,只能说明该值在本次查询时存在。它是否被具体系统组件读取,是否创建目标进程,目标是否完成,都需要独立的进程或事件证据。

每个成功打开的 HKEY 都要关闭。程序只请求 KEY_QUERY_VALUE,不会创建、修改或删除键值。这样的读取不会改变观察对象。

完整可运行程序在附件。



002-Windows兼容与特殊登录启动键的枚举.zip (160.71 KB, 下载次数: 1)

5#
 楼主| WangWei-CM 发表于 2026-7-17 21:33 |楼主

RunOnceEx 注册表分组键的安全遍历

RunOnceEx 的典型布局包含根键、一级分组子键和分组内的值。读取时要先确定根键与注册表视图,再分别枚举根键直接值和一级分组值。每条结果都要保留所在层级。来源和类型验证完成后,才能解释 TitleFlags 等数据。

一、对象关系和完整流程

RunOnceEx 的读取流程由注册表层级决定。根键可以直接保存值,也可以包含一级分组键。分组键中又可以保存自己的值。注册表 API 每次只枚举当前键的直接内容,不会自动进入下一层。

选择 HKLM 或 HKCU -> 选择 32 位或 64 位视图 -> 打开 RunOnceEx 根键 -> 枚举根键直接值 -> 枚举一级分组名 -> 逐个打开分组 -> 枚举分组内的值 -> 按类型解释原始字节

1. 注册表用键和值保存系统与用户设置

读取层级前要先分清键和值。Windows 注册表是由多个配置单元组成的层级数据库。HKEY_LOCAL_MACHINE(HKLM)保存计算机范围的设置。HKEY_CURRENT_USER(HKCU)保存当前登录用户的设置。每个键可以包含子键,也可以包含多个命名值。每个值由名称、类型和原始字节组成。

路径 HKLM\Software\Microsoft\Windows\CurrentVersion\RunOnceEx\0001 中,RunOnceEx0001 都是键名。0001 键里的 TitleFlags 或自定义名称才是值名。读取程序先打开键,再从该键读取直接值。下一层对象不会自动出现在当前层的枚举结果中。

下表中的数据类型决定了读取程序应如何解释字节。类型信息必须和数据一起保存,脱离类型直接把缓冲区转换为字符串会损坏数值、二进制数据和多字符串数据。

类型 原始内容 合理的读取方式
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 是带一级分组的配置结构。传统 RunRunOnce 直接在一个键下用值名对应命令。RunOnceEx 还可以建立一级分组键,把标题、标志和多个条目放入同一组。不同 Windows 版本和安装程序可能使用不同的登录初始化方式。某个键存在,只能证明配置存在,不能证明当前系统会处理其中每一项。

RunOnceEx 的根键通常出现在两个范围。HKLM 中的键面向计算机级设置,读取时可能需要更高权限。HKCU 中的键面向当前用户,普通用户通常能读取自己的配置。两个位置要独立枚举。一个位置读取失败时,另一个位置仍要继续处理。

RunOnceEx 根键
    根键直接值
        ValueName = ValueData
    0001                         一级分组键
        Title = "组件初始化"
        Flags = DWORD
        001 = "命令文本"
    0002
        ...

这个层级关系决定了记录格式。每条结果至少要保存根配置单元、软件视图、根路径、分组名、值名、类型和原始长度。完整的位置字段可以准确区分 0001\0010002\001

3. 32 位和 64 位程序看到的软件视图可能不同

HKLM 软件键还要明确注册表视图。64 位 Windows 为部分 HKLM\Software 内容维护 32 位和 64 位视图。32 位程序默认打开 32 位视图,64 位程序默认打开 64 位视图。完整检查需要分别使用 KEY_WOW64_64KEYKEY_WOW64_32KEY。只读取程序默认视图时,不要加入这两个标志。

KEY_WOW64_64KEYKEY_WOW64_32KEY 只影响可重定向的注册表区域,不能用于推断系统位数,也不能对 HKCU 的所有路径强行套用。结果中标记“64 位视图”或“32 位视图”能够避免把两个不同对象误合并为同一项。

4. 根键直接值与分组值的读取顺序应独立保存

根层和分组层需要分别记录状态。打开 RunOnceEx 根键后,先枚举它的直接值,再枚举一级子键。每个一级子键都要独立打开,然后枚举其中的值。根键没有分组、某个分组无法打开、分组存在但没有值,都是不同的配置事实。

根键直接值可以与分组内的值同名。例如,根键的 Install0001\Install 是两个位置不同的注册表值。记录应包含根配置单元、软件视图、根路径、可选分组名、值名、REG 类型、原始数据长度和读取状态。缺少其中任何一个位置字段,都可能让同名项相互覆盖。

5. 名称、数据和类型决定字符串解码是否安全

值解码前要先检查类型和长度。RegEnumValueW 返回的值名长度以 UTF-16 宽字符数计算,值数据长度以字节计算。名称缓冲区要额外预留一个 NUL,数据缓冲区则按字节分配。REG_SZREG_EXPAND_SZ 的数据长度能被 sizeof(wchar_t) 整除时,才能按 UTF-16 解码。结尾 NUL 只在真实存在时移除一个。

REG_DWORD 需要恰好四字节才能读取为数值。REG_MULTI_SZ 需要按单 NUL 分段,并以双 NUL 结束。REG_BINARY 没有通用文本语义。未知值名和未知类型同样属于枚举结果,也要保存类型和原始长度。

6. 并发写入会改变枚举容量和索引含义

注册表内容可能在枚举期间变化。RegQueryInfoKeyW 返回的最大名称和数据长度,只能作为当前时刻的初始容量。后续 RegEnumKeyExWRegEnumValueW 返回 ERROR_MORE_DATA 时,要扩大对应缓冲区,再重试同一索引。只有 ERROR_NO_MORE_ITEMS 表示本轮枚举结束。访问拒绝和键删除要单独记录,并继续处理同级其它项。

索引只用于当前一轮 API 调用。比较两次读取结果时,要按完整键路径、分组名和值名配对,并保留每次的时间、视图与类型。分组改名、插入或删除后,原来的索引可能代表另一个对象。

7. HKEY、REGSAM 与访问失败的语义也应成为输出的一部分

HKEY 是成功打开注册表键后由调用方持有的资源引用,使用结束后要用 RegCloseKey 释放。REGSAM 是本次请求的访问掩码,决定能否查询值或枚举子键。只读遍历请求 KEY_QUERY_VALUEKEY_ENUMERATE_SUB_KEYS 和明确的 WOW64 视图,不请求写入、创建或删除权限。

根键打开失败、分组打开失败、值缺失和缓冲区不足不能合并为一个空结果。每种失败都携带完整路径、视图、请求权限和 LSTATUS 返回值,读者才能知道是配置不存在、当前令牌不可读,还是注册表在读取过程中发生变化。

采样时间与调用账户也要记录。它们说明读取结论适用的系统时刻和权限环境。完整保留这些字段后,才能比较同一位置在不同时间的变化。

这一记录模型不依赖固定的分组名和值名,因此也适用于未知配置。后续审阅时,每条结果都能回到原始位置和读取状态。

二、先保留根键和分组键的边界

1. 一条值需要完整位置

层级识别要从 RunOnceEx 根键开始。常见根路径为:

HKLM\Software\Microsoft\Windows\CurrentVersion\RunOnceEx
HKCU\Software\Microsoft\Windows\CurrentVersion\RunOnceEx

分组值的位置还要包含一级分组名。根键中的 InstallRunOnceEx\0001 中的 Install 是不同值。完整位置应包含根键、32 位或 64 位视图、根路径、分组名和值名。

数字分组名不能直接解释为处理顺序。00010002 只是子键名称,注册表 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_SZREG_EXPAND_SZ 可以按实际字节长度构造 UTF-16 文本。奇数字节长度无法形成完整宽字符。末尾 NUL 只在真实存在时移除一个。不要使用 wcslen 搜索缓冲区外的终止符。

遇到 ERROR_MORE_DATA 时,要重新查询根键或分组键的当前上限。重新分配缓冲区后,有限次数重试同一索引。连续失败时记录位置和返回码,再继续处理同级其它对象。

六、保持结论的时间边界

6. 枚举索引不等于系统处理顺序

索引只能作为本次读取的定位信息。子键和值可能在读取期间增加、删除或改名。索引不能跨两次读取作为稳定身份,也不能代表 RunOnceEx 的实际处理顺序。

注册表状态和实际活动需要分别判断。某个分组或值在本次读取中存在,只能证明它位于对应根键、视图和路径。它是否被系统处理,目标是否完成,需要独立的进程或事件证据。

完整可运行程序在附件。


003-RunOnceEx注册表分组键的安全遍历.zip

168.25 KB, 下载次数: 1, 下载积分: 吾爱币 -1 CB

6#
 楼主| WangWei-CM 发表于 2026-7-18 21:12 |楼主

用户启动文件夹与公共启动文件夹的双重枚举

Startup 文件夹中的目录项会在用户登录后由 Windows 外壳处理。枚举时要先用已知文件夹 ID 定位当前用户目录与公共目录,再分别读取两个目录的一级条目。路径、属性、文件身份和签名结果需要分开保存。

一、对象关系和完整流程

系统处理流程从用户登录开始。Windows 建立用户会话并启动桌面外壳。桌面外壳通常是 explorer.exe,负责提供桌面、开始菜单,并在相应阶段处理 Startup 目录项。

用户登录 -> Windows 建立用户会话 -> 启动桌面外壳 -> 处理当前用户 Startup 目录 -> 处理公共 Startup 目录 -> 按目录项类型继续处理

枚举程序只观察目录内容。它不会触发目录项,也不能证明目标程序已经运行。

解析已知文件夹 ID -> 得到实际绝对路径 -> 枚举目录一级条目 -> 记录名称和属性 -> 打开普通文件 -> 读取文件身份 -> 保存范围、路径和错误状态

1. Startup 文件夹是文件系统中的两个登录范围

Startup 文件夹包含当前用户范围和公共范围。Windows 把每个用户的配置与文件放在各自的用户配置文件目录中,同时提供由本机用户共享的公共目录。FOLDERID_Startup 指向当前登录用户的启动文件夹。FOLDERID_CommonStartup 指向公共启动文件夹。两者都表示文件系统目录。

当前用户 Startup 目录的常见显示路径为 %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup%APPDATA% 会因用户配置文件位置、文件夹重定向和企业策略而变化。公共 Startup 目录的常见显示路径为 %PROGRAMDATA%\Microsoft\Windows\Start Menu\Programs\StartUp,它面向本机多个用户,写入通常需要更高权限。

登录范围决定记录含义。只出现在当前用户目录中的条目,仅与该用户的登录环境有关。公共目录中的条目可能影响本机上多个交互式用户。读取结果要明确保存范围名称,不能只给出路径文本。

Windows 文件夹逻辑关系

当前登录用户
    用户配置文件
        AppData\Roaming\...\Programs\Startup      FOLDERID_Startup

本机所有用户
    ProgramData\...\Programs\StartUp               FOLDERID_CommonStartup

2. “已知文件夹”使用稳定的逻辑标识符

目录位置要通过已知文件夹标识符获取。Windows 为常用目录分配了 GUID(Globally Unique Identifier,全局唯一标识符),例如 FOLDERID_Startup。系统结合用户配置、重定向规则和当前令牌解析标识符,然后返回实际的绝对路径。

已知文件夹 API 返回的路径指针带有明确的内存所有权。调用者拿到的 PWSTR 由 COM 任务分配器分配,读取完文本后必须调用 CoTaskMemFree。把这一指针直接交给 delete[]freeLocalFree 都会破坏分配器配对关系。

目录尚未创建也是一种有效状态。全新的用户配置文件、精简系统映像或管理员设置的重定向策略,都可能让目标路径暂时不存在。读取程序要分别记录“路径解析成功”“路径存在”和“能够列出目录”。

3. 外壳处理的是目录项,目录项的种类并不相同

目录项可能是文件、快捷方式或子目录。Startup 文件夹经常包含 .lnk 快捷方式,也可以包含可执行文件、批处理文件、Internet 快捷方式或目录。目录项名称只表示文件系统中的条目。外壳怎样处理它,还取决于条目类型、文件关联、快捷方式目标和登录环境。

.lnk 文件保存 Shell Link 数据,其中可能包含目标路径、工作目录、参数和图标信息。Shell Link 是 Windows 外壳定义的快捷方式格式。枚举 Startup 目录时,先把 .lnk 当作普通文件记录。需要分析目标时,再通过 Shell Link 的 COM 接口解析。快捷方式文件名不能直接当成目标路径。

文件夹内的子目录也应与直接文件分开保留。直接枚举可以完整回答“这个 Startup 文件夹当前有哪些一级条目”,递归遍历回答的是另一个问题,二者混在同一个列表里会掩盖真实的目录层级。

4. 登录、Explorer 初始化与目录枚举有不同的时间边界

目录枚举不代表执行记录。用户完成交互式登录后,Windows 为该用户建立会话并启动桌面外壳。外壳随后在相应阶段处理 Startup 目录内容。目录枚举只能说明某个时刻有哪些一级条目,无法证明外壳已经处理它们,也无法证明目标程序成功运行。

完整观察分为三层。

  1. 解析已知文件夹 ID,得到当前用户目录或公共目录的实际路径。
  2. 读取一级目录项、属性和文件身份。
  3. 按需要解析快捷方式目标、检查文件是否存在,或结合进程与事件记录。

每层都要保存自己的时间和错误状态。

5. Shell Link、文件路径和文件对象身份需要分别记录

快捷方式文件和链接目标是两个对象。Startup 目录中的 .lnk 文件是一个文件系统条目。链接目标是另一个对象,可能不存在,也可能已经被替换。快捷方式文件名、快捷方式完整路径、解析出的目标文本和目标文件身份需要分别记录。

文件身份通常由卷序列号和文件索引组合表示。路径可能被改名、重解析或替换。文件索引可以在同一卷上辅助识别本次打开的文件对象。这个身份仍然与读取时间有关。文件被删除或替换后,旧身份不能继续代表新路径。读取失败时要保留文件路径和 GetLastError,然后继续枚举其它条目。

6. 文件查找句柄、文件句柄和 COM 分配内存的释放规则不同

每类资源都有对应的释放 API。SHGetKnownFolderPath 返回的 PWSTR 由 COM 任务分配器管理,要用 CoTaskMemFree 释放。FindFirstFileW 返回搜索句柄,要用 FindClose 释放。CreateFileW 返回普通 HANDLE,要用 CloseHandle 释放。三种释放函数不能互换。

资源所有者应在同一段逻辑中保持清晰。路径文本复制到 std::wstring 后,再释放 PWSTR。目录遍历结束后关闭搜索句柄。每个成功打开的文件都在读取身份后关闭。某个条目访问失败时,前一层资源仍能正常释放,另一个 Startup 范围也能继续枚举。

7. 一条目录项记录需要同时保存范围、名称、属性和读取结果

每条结果都要能回到对应目录。记录至少包含范围标签、已知文件夹 API 返回的目录路径、相对名称、完整路径、目录属性、目录标志、文件身份读取状态和采样时间。范围标签说明条目来自当前用户目录还是公共目录。.lnk.exe.cmd.bat 或未知扩展名只表示名称或文件类型,不能代替路径来源。

目录可能在枚举期间变化。某个名称由 FindFirstFileW 返回后,文件可能立即被删除,后续 CreateFileW 就会失败。文件也可能在打开后被其它进程替换。输出应保留“目录项当时返回的名称”和“文件身份读取失败码”,不能直接丢弃失败条目。

当前用户目录和公共目录要独立读取。当前用户目录解析失败时,继续解析公共目录。公共目录不可访问时,仍然输出当前用户条目。每个错误都要保留对应的范围,避免把一个范围的问题写成另一个范围的空目录。

目录路径、目录项和文件身份要按同一采样时间组合输出。后续差异比较才能对应到明确范围。

路径比较时,可以用完整绝对路径作为展示键,用文件身份辅助复核。重定向、重解析点、大小写显示和目录改名都可能改变路径文本。文件索引读取失败时,要保留失败状态,不能伪造身份字段。

完整的状态字段可以分别解释两次枚举之间的新增、删除、替换和不可读条目。

输出中的失败码要和目录范围一同保存。记录完成后再进行汇总展示,避免丢失单个范围的错误状态。

二、先定位两个不同范围的 Startup 文件夹

1. 已知文件夹 ID 比拼接环境变量更稳定

目录定位要区分当前用户与公共范围。FOLDERID_Startup 对应当前用户的 Startup 文件夹,常见位置为 %APPDATA%\Microsoft\Windows\Start Menu\Programs\StartupFOLDERID_CommonStartup 对应公共 Startup 文件夹,常见位置为 %PROGRAMDATA%\Microsoft\Windows\Start Menu\Programs\StartUp

实际路径要使用已知文件夹 API 获取。API 会根据当前用户配置、重定向设置和系统版本返回结果,不需要手工替换 %APPDATA%%PROGRAMDATA%

// 意义:释放 COM 任务分配器分配的内存。
// 返回:无。参数必须来自 CoTaskMemAlloc 或约定使用同一分配器的 API。
void CoTaskMemFree(
    LPVOID pv                 // 输入:SHGetKnownFolderPath 成功返回的路径指针。
);

// 意义:获取 folderId 指定的已知文件夹绝对路径。
// 返回:S_OK 表示成功。失败 HRESULT 表示已知文件夹不可用或访问状态异常。
HRESULT SHGetKnownFolderPath(
    REFKNOWNFOLDERID rfid, // 输入:FOLDERID_Startup、FOLDERID_CommonStartup 等 ID。
    DWORD dwFlags,         // 输入:KF_FLAG_DEFAULT 使用默认解析方式。
    HANDLE hToken,         // 输入:nullptr 表示当前用户令牌。
    PWSTR* ppszPath        // 输出:CoTaskMemAlloc 分配的宽字符串,调用方必须 CoTaskMemFree。
);

正确做法:复制 API 分配的文本,再释放原始内存。输出指针来自 COM 任务分配器,只能由 CoTaskMemFree 释放。

PWSTR allocatedPath = nullptr;
const HRESULT result = SHGetKnownFolderPath(
    FOLDERID_Startup, KF_FLAG_DEFAULT, nullptr, &allocatedPath);
if (SUCCEEDED(result) && allocatedPath != nullptr) {
    std::wstring startupPath(allocatedPath);
    CoTaskMemFree(allocatedPath);
    // startupPath 现在拥有独立副本。
}

错误示例:混用分配器。下面的 delete[] 不匹配 API 的分配方式,可能破坏堆内存。

// 错误:SHGetKnownFolderPath 的输出必须用 CoTaskMemFree 释放。
delete[] allocatedPath;

三、枚举目录时只读取一层

2. 文件查找 API 返回目录项属性

第一次查找文件的宽字符 API 返回搜索句柄和第一条目录项。返回 INVALID_HANDLE_VALUE 表示失败。搜索句柄必须由 FindClose 关闭,不能使用 CloseHandle

// 意义:按 lpFileName 的通配符查找第一条目录项。
// 返回:有效搜索句柄表示成功。INVALID_HANDLE_VALUE 表示失败。
HANDLE FindFirstFileW(
    LPCWSTR lpFileName,          // 输入:目录路径加 \\* 通配符。
    LPWIN32_FIND_DATAW lpFindFileData // 输出:第一条名称、属性和时间信息。
);

// 意义:读取同一搜索句柄的下一条目录项。
// 返回:非零表示成功。返回 0 且错误码为 ERROR_NO_MORE_FILES 表示正常结束。
BOOL FindNextFileW(HANDLE hFindFile, LPWIN32_FIND_DATAW lpFindFileData);

// 意义:关闭 FindFirstFileW 返回的搜索句柄。
// 返回:非零表示成功。参数只能是搜索句柄。
BOOL FindClose(HANDLE hFindFile);

正确做法:跳过 ...,只处理目录第一层。Startup 文件夹中的普通文件、快捷方式和子目录都是不同目录项。递归进入子目录会把非直接内容混入结果。

WIN32_FIND_DATAW data{};
const HANDLE findHandle = FindFirstFileW((startupPath + L"\\*").c_str(), &data);
if (findHandle != INVALID_HANDLE_VALUE) {
    do {
        if (wcscmp(data.cFileName, L".") == 0 || wcscmp(data.cFileName, L"..") == 0) {
            continue;
        }
        const bool isDirectory = (data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
        // 记录完整路径、目录标志和属性。普通文件再读取文件身份。
    } while (FindNextFileW(findHandle, &data) != FALSE);
    FindClose(findHandle);
}

错误示例:把 FindFirstFileW 返回的句柄交给 CloseHandle。搜索句柄有专用关闭 API,混用关闭函数会留下资源或产生错误状态。

// 错误:findHandle 应由 FindClose 关闭。
CloseHandle(findHandle);

四、用文件对象身份辅助路径比对

3. 路径文本与文件身份是两类信息

文件打开的宽字符 API 返回普通文件句柄。FILE_READ_ATTRIBUTES 只请求元数据读取,文件共享标志允许并发读取、写入和删除,避免因另一个进程占用文件而不必要失败。

// 意义:打开已有文件并获得文件对象句柄。
// 返回:有效 HANDLE 表示成功。INVALID_HANDLE_VALUE 表示失败。
HANDLE CreateFileW(
    LPCWSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode,
    LPSECURITY_ATTRIBUTES lpSecurityAttributes, DWORD dwCreationDisposition,
    DWORD dwFlagsAndAttributes, HANDLE hTemplateFile
);

// 意义:关闭 CreateFileW 成功返回的普通文件句柄。
// 返回:非零表示成功。搜索句柄和 SC_HANDLE 不使用此函数关闭。
BOOL CloseHandle(HANDLE hObject);

// 意义:读取文件卷序列号、文件索引、属性与时间等身份信息。
// 返回:非零表示成功。返回 0 表示失败。
BOOL GetFileInformationByHandle(
    HANDLE hFile,                         // 输入:CreateFileW 成功返回的文件句柄。
    LPBY_HANDLE_FILE_INFORMATION lpInfo   // 输出:文件对象信息。
);

正确做法:同时保存完整路径、卷序列号与文件索引。路径表示 Startup 位置。卷序列号和文件索引可以辅助识别同一路径被替换后的不同文件对象。

const HANDLE file = CreateFileW(path.c_str(), FILE_READ_ATTRIBUTES,
    FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
    nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (file != INVALID_HANDLE_VALUE) {
    BY_HANDLE_FILE_INFORMATION info{};
    if (GetFileInformationByHandle(file, &info) != FALSE) {
        const unsigned long long fileIndex =
            (static_cast<unsigned long long>(info.nFileIndexHigh) << 32) | info.nFileIndexLow;
        // 保存 info.dwVolumeSerialNumber 与 fileIndex。
    }
    CloseHandle(file);
}

错误示例:用扩展名判断文件是否可信。.lnk.exe.cmd.bat.url 都可能出现在 Startup 目录。扩展名只描述名称形式。签名验证、签名主体、目标解析和文件身份都需要独立信息。

五、保持目录范围和签名结论独立

4. 签名信息不能替代路径事实

文件位置与签名结果要分开记录。文件位于用户 Startup 或公共 Startup,属于目录事实。签名是否有效、证书主体是谁、证书链是否可信,属于独立的密码学检查结果。这两类结论不能互相替代。

目录枚举由多次文件系统调用组成。文件可能在查找期间被创建、删除或替换。打开文件身份失败时,记录路径和错误状态,再继续读取其它目录项。

完整可运行程序在附件。



004-用户启动文件夹与公共启动文件夹的双重枚举.zip

151.85 KB, 下载次数: 2, 下载积分: 吾爱币 -1 CB

7#
 楼主| WangWei-CM 发表于 2026-7-19 15:24 |楼主

WMI 永久事件处理对象的命令、脚本与日志配置

本篇要完成的是:只读枚举 ROOT\subscription 中的四类永久事件处理对象,在控制台输出对象类型、名称及命令、脚本或日志字段。查询或枚举失败时输出 HRESULT,属性不可读时输出占位文本。命令行对象提供命令模板和工作目录。脚本对象提供脚本文本与脚本引擎。日志对象只写文件或事件日志,不能按启动命令解释。

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

  1. 初始化 COM 并设置进程默认安全参数。
  2. 连接 ROOT\subscription 并设置 WMI 服务代理安全参数。
  3. 分别查询四类事件处理对象,限制单次读取的等待时间。
  4. VARIANT 的真实类型转换属性并输出文本。

这四步从连接环境开始,到属性输出结束。类名、对象名称和每个属性的占位结果需要一同查看,才能分清没有实例、查询失败和属性不可读这三种情况。

一、先理解四步要读取的对象

WMI 是 Windows 的管理对象系统。它把系统状态、配置与管理操作组织成可查询对象。客户端通过 COM 接口连接 WMI 服务,再在指定命名空间中查询类和实例。永久事件订阅是其中一类配置,涉及命名空间、类、实例、属性、提供程序、查询和对象引用。

永久订阅的运行路径由三个对象连接。Filter 描述事件条件。Binding 保存 Filter 与 Consumer 的对象路径引用。Consumer 描述事件匹配后执行命令、运行脚本或写日志等处理方式。

事件来源或系统状态变化 -> __EventFilter 匹配 WQL 条件 -> __FilterToConsumerBinding 关联对象 -> Consumer 按自身类型处理命令、脚本或日志

枚举程序只读取事件处理对象的实例及其属性。它不会触发事件,也不能证明 Filter、Binding 已完整配置或 Consumer 已成功处理事件。

初始化 COM -> 连接 ROOT\subscription -> 查询事件处理对象类 -> 读取每个实例的 __CLASS 和属性 -> 按 VARIANT 类型解释 -> 保存对象路径、来源和 HRESULT

命名空间是 WMI 对象的逻辑容器。ROOT\CIMV2 保存大量常见的系统管理类,例如进程、服务、磁盘和网络适配器。ROOT\subscription 保存永久事件订阅相关的系统类和实例。命名空间路径决定查询范围,同名类在不同命名空间可以表示不同的对象集合。

ROOT
    CIMV2
        Win32_Process       进程实例
        Win32_Service       服务实例
        其它系统管理类
    subscription
        __EventFilter       永久订阅过滤器
        __FilterToConsumerBinding
        CommandLineEventConsumer
        其它永久事件处理对象类

类描述对象具有哪些属性和方法,实例是类的一条实际记录。CommandLineEventConsumer 是命令行事件处理对象类,名称为 Example 的一条配置是该类的实例。NameCommandLineTemplateExecutablePathWorkingDirectory 是这个实例的属性。读取时必须保留类名和实例名称,属性名相同并不代表来自同一类。

提供程序负责把某些 WMI 类连接到真实数据或管理功能。WMI 服务接收查询后,根据类所在命名空间调用相应提供程序或读取仓库中的永久对象。客户端无需直接调用驱动、注册表或服务控制器,也不能假设所有类都由同一种底层来源提供。

WQL 是 WMI 查询语言,语法接近 SQL。SELECT Name FROM CommandLineEventConsumer 表示从指定类的实例中读取 Name 属性。WQL 本身只描述要读取哪些对象和属性,命名空间、连接权限和提供程序可用性仍由 WMI 环境决定。

连接:ROOT\subscription
查询:SELECT Name, CommandLineTemplate FROM CommandLineEventConsumer
结果:该类的零个或多个实例
属性:Name、CommandLineTemplate 等带类型的值

WMI 属性使用 VARIANT 返回。字符串通常为 VT_BSTR,整数、布尔值、数组和对象引用使用其它类型。属性缺失、空值和读取失败需要分开表示。把任何属性都当成 C 字符串会丢失类型信息,也可能访问错误的联合成员。

永久事件订阅由三种对象协作。__EventFilter 保存事件条件和 WQL。Consumer 类实例保存事件发生后的处理方式。__FilterToConsumerBinding 保存两者之间的关联。这类处理对象只说明可执行、脚本或写日志等处理端。触发条件来自 Filter,关联关系来自 Binding。

本篇只读取这类对象实例。连接、查询和类型解释都保持只读。对象存在不代表其对应的 Filter、Binding 或实际事件触发已经成功,三个层面需要分别查询。

二、第一步:初始化 COM 与进程安全

1. COM 初始化和安全设置先于 WMI 查询

初始化 COM 的 API 为当前线程建立 WMI 调用环境。S_OKS_FALSE 表示可继续使用 COM。RPC_E_CHANGED_MODE 表示线程已采用另一种 COM 模型,读取时需要避免重复清理。

// 意义:为当前线程初始化 COM。
// 返回:S_OK 或 S_FALSE 成功。RPC_E_CHANGED_MODE 表示已有不同模型。
HRESULT CoInitializeEx(LPVOID pvReserved, DWORD dwCoInit);

// 意义:撤销当前线程一次成功的 COM 初始化。
// 返回:无。只能与 S_OK 或 S_FALSE 的 CoInitializeEx 对应。
void CoUninitialize(void);

// 意义:设置本进程默认 COM 安全级别。
// 返回:S_OK 成功。RPC_E_TOO_LATE 表示安全设置已由更早的调用完成。
HRESULT CoInitializeSecurity(
    PSECURITY_DESCRIPTOR pSecDesc, LONG cAuthSvc, SOLE_AUTHENTICATION_SERVICE* asAuthSvc,
    void* pReserved1, DWORD dwAuthnLevel, DWORD dwImpLevel,
    void* pAuthList, DWORD dwCapabilities, void* pReserved3
);

正确做法:接受 RPC_E_TOO_LATE,并且只在 CoInitializeEx 成功时调用 CoUninitialize。任务、WMI 和其它 COM 组件可能已经设置过同一进程的安全参数。

const HRESULT initStatus = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
const bool needUninitialize = SUCCEEDED(initStatus);
if (FAILED(initStatus) && initStatus != RPC_E_CHANGED_MODE) {
    return;
}

const HRESULT securityStatus = CoInitializeSecurity(
    nullptr, -1, nullptr, nullptr, RPC_C_AUTHN_LEVEL_DEFAULT,
    RPC_C_IMP_LEVEL_IMPERSONATE, nullptr, EOAC_NONE, nullptr);
if (FAILED(securityStatus) && securityStatus != RPC_E_TOO_LATE) {
    if (needUninitialize) CoUninitialize();
    return;
}

错误示例:把 RPC_E_TOO_LATE 当成连接失败。这个状态说明安全设置已存在,可以继续使用已有设置。直接终止会遗漏本来可读的对象。

完成第一步后,当前线程已经具备调用 COM 的环境,进程也已有可用的默认安全设置。接下来要连接保存永久订阅对象的命名空间,才能取得实际执行查询的 WMI 服务接口。

三、第二步:连接 ROOT\subscription 并设置代理安全

2. 连接 ROOT\subscription 并取得服务接口

创建 COM 类的 API 根据 CLSID 返回指定接口。CLSID_WbemLocator 用于创建 IWbemLocator,随后由 locator 连接 WMI 命名空间。接口指针需要调用 Release

// 意义:创建 WMI locator COM 对象。
// 返回:S_OK 成功。失败 HRESULT 表示类或接口不可用。
HRESULT CoCreateInstance(
    REFCLSID rclsid, LPUNKNOWN pUnkOuter, DWORD dwClsContext,
    REFIID riid, LPVOID* ppv
);

// 意义:连接 WMI 命名空间并得到服务接口。
// 返回:S_OK 成功。ppNamespace 接收 IWbemServices,调用方 Release。
HRESULT IWbemLocator::ConnectServer(
    BSTR strNetworkResource, BSTR strUser, BSTR strPassword, BSTR strLocale,
    LONG lSecurityFlags, BSTR strAuthority, IWbemContext* pCtx,
    IWbemServices** ppNamespace
);

// 意义:创建一个由 OLE 自动化分配器管理的 BSTR。
// 返回:成功时为新 BSTR,内存不足时为 nullptr。必须使用下方匹配的释放函数。
BSTR SysAllocString(const OLECHAR* psz);

// 意义:释放 SysAllocString 或 COM getter 返回的 BSTR。
// 返回:无。传入 nullptr 安全。
void SysFreeString(BSTR bstrString);

// 意义:为已连接的 WMI 服务代理设置身份验证和模拟级别。
// 返回:S_OK 成功。失败 HRESULT 表示当前令牌或代理安全设置不可用。
HRESULT CoSetProxyBlanket(
    IUnknown* pProxy, DWORD dwAuthnSvc, DWORD dwAuthzSvc, OLECHAR* pServerPrincName,
    DWORD dwAuthnLevel, DWORD dwImpLevel, RPC_AUTH_IDENTITY_HANDLE pAuthInfo,
    DWORD dwCapabilities
);

正确做法:使用 ROOT\subscription,再给服务代理设置模拟级别。该命名空间保存永久订阅对象。ROOT\CIMV2 中的普通系统类不能代替它。

IWbemLocator* locator = nullptr;
IWbemServices* services = nullptr;
if (SUCCEEDED(CoCreateInstance(CLSID_WbemLocator, nullptr, CLSCTX_INPROC_SERVER,
                               IID_IWbemLocator, reinterpret_cast<void**>(&locator)))) {
    BSTR path = SysAllocString(L"ROOT\\subscription");
    const HRESULT connected = locator->ConnectServer(
        path, nullptr, nullptr, nullptr, WBEM_FLAG_CONNECT_USE_MAX_WAIT,
        nullptr, nullptr, &services);
    SysFreeString(path);
    if (SUCCEEDED(connected)) {
        CoSetProxyBlanket(services, RPC_C_AUTHN_WINNT, RPC_C_AUTHZ_NONE, nullptr,
                          RPC_C_AUTHN_LEVEL_CALL, RPC_C_IMP_LEVEL_IMPERSONATE, nullptr, EOAC_NONE);
    }
    locator->Release();
}

错误示例:省略代理安全设置。服务接口已经返回时,后续查询仍可能因当前令牌的模拟级别不足失败。连接和代理安全是两个独立步骤。

完成第二步后,已经取得了 ROOT\subscription 的服务接口,并为这个代理设置了当前令牌所需的安全级别。接下来需要按对象类型执行独立查询,才能避免把日志配置当成命令配置。

四、第三步:分别读取四类对象

3. WQL 查询需要有限等待时间

执行查询的方法返回枚举器。WBEM_FLAG_FORWARD_ONLY 降低内存使用,WBEM_FLAG_RETURN_IMMEDIATELY 使 WMI 异步准备结果。枚举器的 Next 调用需要有限超时,异常 WMI 提供者不能无限阻塞读取线程。

// 意义:执行只读 WQL 查询并返回结果枚举器。
// 返回:S_OK 成功。ppEnum 接收 IEnumWbemClassObject,调用方 Release。
HRESULT IWbemServices::ExecQuery(
    const BSTR strQueryLanguage, const BSTR strQuery,
    LONG lFlags, IWbemContext* pCtx, IEnumWbemClassObject** ppEnum
);

// 意义:从 WMI 枚举器读取至多 lRequested 个对象。
// 返回:WBEM_S_NO_ERROR 成功。WBEM_S_TIMEDOUT 表示等待超时。
HRESULT IEnumWbemClassObject::Next(
    LONG lTimeout, ULONG uCount, IWbemClassObject** apObjects, ULONG* puReturned
);

正确做法:为每类事件处理对象使用独立 WQL。CommandLineEventConsumer 读取 CommandLineTemplateExecutablePathWorkingDirectoryActiveScriptEventConsumer 读取 ScriptTextScriptingEngineLogFileEventConsumer 读取 FilenameTextNTEventLogEventConsumer 读取 SourceNameEventIDCategory

const wchar_t* query =
    L"SELECT Name, CommandLineTemplate, ExecutablePath, WorkingDirectory "
    L"FROM CommandLineEventConsumer";

// 每次 Next 最多等待五秒。连续超时达到上限时记录类名并继续其它对象类。
HRESULT status = enumerator->Next(5000, 1, &object, &returned);
if (status == WBEM_S_TIMEDOUT && returned == 0) {
    // 记录超时,不把它当作“该类没有对象”。
}

错误示例:把日志处理对象的文件名当作启动映像。LogFileEventConsumerFilename 是写入目标,NTEventLogEventConsumerSourceName 是事件来源。这两类对象记录日志,不提供可执行命令。

完成第三步后,已经按类拿到对象实例和对应字段。实例中的每个属性仍是一段带类型的数据,接下来需要检查 VARIANT,才能安全取得文本、数值或空值。

五、第四步:按属性类型转换并输出文本

4. WMI 属性使用 VARIANT 返回

读取属性的方法把值写入 VARIANTVT_BSTR 保存文本,整数和布尔值使用各自字段。每次读取后必须调用 VariantClear,因为属性可能携带 BSTR、接口或数组资源。

// 意义:读取 WMI 对象的一个命名属性。
// 返回:S_OK 成功。pVal 接收 VARIANT,调用方必须 VariantClear。
HRESULT IWbemClassObject::Get(
    LPCWSTR wszName, LONG lFlags, VARIANT* pVal,
    CIMTYPE* pType, LONG* plFlavor
);

// 意义:把 VARIANT 初始化为空值,供 COM 方法写入。
// 返回:无。每个初始化后的 VARIANT 最终都需要清理内部资源。
void VariantInit(VARIANTARG* pvarg);

// 意义:释放 VARIANT 内部的 BSTR、数组、接口等资源。
// 返回:S_OK 成功。应在不再读取属性后调用。
HRESULT VariantClear(VARIANTARG* pvarg);

正确做法:初始化 VARIANT、读取属性、按 vt 分支解释,再清理资源。空值、缺失字段和查询失败要保留不同状态,不能都替换为空字符串。

VARIANT value{};
VariantInit(&value);
if (SUCCEEDED(object->Get(L"ScriptText", 0, &value, nullptr, nullptr))) {
    if (value.vt == VT_BSTR && value.bstrVal != nullptr) {
        // 保存 ActiveScriptEventConsumer 的原始脚本文本。
    }
}
VariantClear(&value);

错误示例:把 VARIANT 强制转换为 BSTREventIDCategory 等字段可能为整数,直接访问 bstrVal 会读取错误的联合成员。

完成第四步后,读取结果已经按属性的真实类型转换为文本。最后还要把对象类别、触发条件和实际执行分别解释,避免从一个静态对象推断事件已经触发。

六、读取结果的解释边界

5. 事件处理对象本身不说明触发条件

对象类型要通过类名记录。命令行对象和脚本对象包含可执行或可解释内容。日志对象只表达记录动作。字段语义由类名决定。

触发条件要与 Filter、Binding 分开读取。处理对象只定义处理方式。__EventFilter 定义 WQL 条件。__FilterToConsumerBinding 才把两者关联。仅看到处理对象不能推导它何时触发。

结果已经能够说明每个对象属于哪一类、保存了哪些原始属性,以及本次读取是否成功。Filter、Binding 和实际事件处理仍属于另外三类证据,不能用这些对象的枚举结果替代。

完整可运行程序在附件



https://wangweicm.lanzouu.com/izqvX3xdeqxi
8#
 楼主| WangWei-CM 发表于 2026-7-20 10:51 |楼主

WMI 永久订阅的 Filter 与 Consumer 绑定解析

本篇要完成的是:读取 ROOT\subscription 中的永久订阅,列出每个 Filter 的触发条件,再通过 Binding 的对象路径还原它连接的 Filter 和 Consumer。

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

  1. 初始化 COM 与调用安全状态。
  2. 连接 ROOT\subscription,取得该命名空间的服务接口。
  3. 查询 __EventFilter,保存名称、WQL、查询语言和事件命名空间。
  4. 查询 __FilterToConsumerBinding,解析其中的 Filter 与 Consumer 对象路径。

这四步按顺序提供读取条件、目标命名空间、触发条件和关联关系。最后得到的结果说明某个 Filter 在读取时连接到哪个 Consumer,同时保留无法解析的原始引用和失败状态。

一、对象关系和完整流程

永久订阅的运行路径由 Filter、Binding 和 Consumer 组成。Filter 负责匹配事件。Binding 保存对象引用。Consumer 负责事件匹配后的处理方式。三者都位于 WMI 的 ROOT\subscription 命名空间。

系统状态或事件来源变化 -> __EventFilter 匹配事件 WQL -> __FilterToConsumerBinding 提供对象引用 -> Consumer 执行命令、处理脚本或写日志
初始化 COM -> 连接 ROOT\subscription -> 查询 Filter -> 查询 Binding -> 解析两端对象路径 -> 输出关联关系与读取状态

读取过程需要分别查询 Filter 与 Binding。它以 Binding 的两个对象路径建立关系,不按名称文本猜测归属。Consumer 的类名和名称只在对象路径可解析时读取。

WMI 是按命名空间组织的管理对象系统

Windows Management Instrumentation(WMI)为系统、驱动、服务和应用提供统一的管理对象访问方式。WMI 服务接收查询请求。WMI 提供程序把操作系统中的状态映射成类和实例。客户端通过 COM 接口读取这些对象。

WMI 的基础对象各有职责。命名空间类似逻辑容器,例如 ROOT\CIMV2 包含大量系统管理类。类描述一组属性和方法,例如 Win32_Process。实例表示某一时刻的具体对象,例如某个进程实例。对象路径是能够唯一定位实例的字符串。客户端指定命名空间和查询后,就能通过 IWbemServices 获取实例。

WMI 客户端
  COM 调用 / WQL 查询
  WMI 服务(winmgmt)
    命名空间 ROOT\CIMV2:提供程序与系统管理对象
    命名空间 ROOT\subscription:永久订阅对象

命名空间之间相互独立。某个类在 ROOT\CIMV2 存在,不表示相同类名能在 ROOT\subscription 查询到。连接成功也只代表拿到所选命名空间的服务接口。

WQL 描述对象筛选条件,事件 WQL 描述“什么时候”

普通查询与事件查询需要分开理解。WQL(WMI Query Language)是一种面向 WMI 对象的查询语言,语法接近 SQL。普通 WQL 例如 SELECT Name, ProcessId FROM Win32_Process 读取当前实例。事件 WQL 使用 __InstanceCreationEvent__InstanceDeletionEvent__InstanceModificationEvent 等事件类,描述对象状态变化时应关注的条件。

一个典型的内在事件查询可能是:

SELECT * FROM __InstanceCreationEvent WITHIN 5
WHERE TargetInstance ISA 'Win32_Process'

WITHIN 5 表示 WMI 按一定间隔检查这类变化,单位是秒。TargetInstance 是事件携带的变化对象。ISA 'Win32_Process' 限制关注的对象类别。查询只表达“匹配什么事件”,并不包含执行命令、写日志或调用脚本的处理细节。

事件还可以由专用提供程序直接报告。无论事件来自轮询还是提供程序,Filter 都需要通过 EventNamespace 指明查询在哪个命名空间解释。相同的 WQL 文本放在不同事件命名空间中,可能指向完全不同的类集合。

临时订阅存在于连接期间,永久订阅保存为 WMI 对象

临时订阅与永久订阅的存续范围不同。临时事件订阅由客户端调用事件查询接口建立,客户端断开后该订阅通常不再保留。永久事件订阅把 Filter、Consumer 和 Binding 实例保存到 ROOT\subscription。WMI 服务可以在随后满足条件时,按保存的关联处理事件。

永久订阅由三个可独立存在的对象组成。__EventFilter 保存事件 WQL、查询语言和事件命名空间。事件处理对象实例保存处理方式。__FilterToConsumerBindingFilterConsumer 属性保存到两端的对象路径引用。Binding 才是条件与处理端之间的连接边。

__EventFilter
  Name / Query / QueryLanguage / EventNamespace
__FilterToConsumerBinding
  Filter / Consumer / DeliveryQoS / MaintainSecurityContext
事件处理对象实例
  __CLASS / Name / 该处理对象类型的特有属性

这三个对象可以形成一对多或多对一关系。一个 Filter 能由多个 Binding 指向不同 Consumer,一个 Consumer 也能被多个 Binding 引用。因此读取过程应为每个对象保留稳定对象路径,并以 Binding 的两个引用建立关联,不能仅按 Name 文本猜测归属。

Consumer 类型决定处理属性的含义

处理对象的属性要先按 WMI 类区分。NTEventLogEventConsumer 用事件日志相关字段描述处理方式。LogFileEventConsumer 用日志文件相关字段描述处理方式。CommandLineEventConsumer 才会出现命令行模板。ActiveScriptEventConsumer 则会涉及脚本引擎和脚本文本等属性。

对象路径类似 CommandLineEventConsumer.Name="Example",它是 WMI 实例定位符,不是可执行文件路径。正确过程是用 GetObject 打开该路径,读取 __CLASS,再按这一类处理对象的文档读取适用字段。未知类别应保留类名、对象路径和原始属性状态,不能把每个 Consumer 都转换为命令文本。

WMI 接口、BSTR 和 VARIANT 有各自的资源责任

WMI 的 COM 自动化对象有明确的所有权。IWbemLocatorIWbemServicesIEnumWbemClassObjectIWbemClassObject 都是 COM 接口指针。成功得到接口后,持有者在使用结束时调用 ReleaseBSTR 是自动化字符串,SysAllocString 成功后由调用方 SysFreeString。不能用 delete[]free 释放。

VARIANT 是可保存字符串、整数、布尔值、接口或数组的联合类型。WMI 属性读取会把真实类型写入 VARIANT。调用方先检查 vt,再读取相应成员,然后调用 VariantClear 释放其中可能存在的 BSTR、接口或数组。把任何属性都强制转换为字符串会破坏数值和对象路径的边界。

永久订阅配置与一次事件处理结果属于不同层次

静态对象与事件执行记录要分开。完整读取 Filter、Binding 和 Consumer,只说明在观察时刻这些 WMI 实例与对象路径可以关联。事件是否发生、WQL 是否匹配、处理对象是否成功处理以及处理产生什么结果,需要从事件日志、WMI 活动记录或独立运行时证据确认。

输出可明确分为三层:配置层记录三类对象和引用关系。解析层记录类名和特有属性。运行时层记录事件时间、匹配结果和处理状态。任何层缺失都保留原始失败 HRESULT,不用另一层的成功替代。

每次读取还应记录命名空间路径、读取时间和调用账户上下文,避免不同用户或不同远程连接得到的结果混在同一批记录中。

二、第一步:初始化 COM 与调用安全状态

这一步让当前线程能够使用 COM 接口,并为后续 WMI 服务调用准备默认安全状态。COM(Component Object Model,组件对象模型)是 Windows 中一套对象调用规则。WMI 客户端、定位器和服务接口都通过这套规则协作。

初始化失败时不能继续读取。默认安全初始化可能返回 RPC_E_TOO_LATE,表示进程中的其它组件已经先设置了 COM 安全参数。此时保持已有设置,继续连接目标命名空间。

完成第一步后,当前线程已经具备调用 WMI COM 接口的前提,但还没有指向任何命名空间。接下来需要连接 ROOT\subscription,取得保存永久订阅对象的服务接口。

三、第二步:连接 ROOT\subscription

Filter 和 Binding 位于永久订阅命名空间

连接 WMI locator 的方法返回命名空间服务接口。ROOT\subscription 保存永久事件订阅,ROOT\CIMV2 通常保存触发事件涉及的系统类。两个命名空间的用途不同。

// 意义:连接指定 WMI 命名空间。
// 返回:S_OK 成功。ppNamespace 接收 IWbemServices,调用方 Release。
HRESULT IWbemLocator::ConnectServer(
    BSTR strNetworkResource, BSTR strUser, BSTR strPassword, BSTR strLocale,
    LONG lSecurityFlags, BSTR strAuthority, IWbemContext* pCtx,
    IWbemServices** ppNamespace
);

// 意义:设置连接后 WMI 服务代理的认证和模拟级别。
// 返回:S_OK 成功。失败 HRESULT 表示代理安全状态不可用。
HRESULT CoSetProxyBlanket(
    IUnknown* pProxy, DWORD dwAuthnSvc, DWORD dwAuthzSvc, OLECHAR* pServerPrincName,
    DWORD dwAuthnLevel, DWORD dwImpLevel, RPC_AUTH_IDENTITY_HANDLE pAuthInfo,
    DWORD dwCapabilities
);

// 意义:从宽字符文本创建 OLE 自动化分配器管理的 BSTR。
// 返回:成功时为新 BSTR,内存不足时为 nullptr。需要下方释放函数。
BSTR SysAllocString(const OLECHAR* psz);

// 意义:释放 SysAllocString 或 COM getter 返回的 BSTR。
// 返回:无。传入 nullptr 安全。
void SysFreeString(BSTR bstrString);

正确做法:以当前令牌连接 ROOT\subscription,再设置 RPC_C_IMP_LEVEL_IMPERSONATE。连接返回服务接口后,代理安全设置仍是独立步骤。

BSTR path = SysAllocString(L"ROOT\\subscription");
IWbemServices* services = nullptr;
if (SUCCEEDED(locator->ConnectServer(path, nullptr, nullptr, nullptr,
                                     WBEM_FLAG_CONNECT_USE_MAX_WAIT, nullptr, nullptr, &services))) {
    CoSetProxyBlanket(services, RPC_C_AUTHN_WINNT, RPC_C_AUTHZ_NONE, nullptr,
                      RPC_C_AUTHN_LEVEL_CALL, RPC_C_IMP_LEVEL_IMPERSONATE, nullptr, EOAC_NONE);
}
SysFreeString(path);

错误示例:从 ROOT\CIMV2 查询 __FilterToConsumerBinding。该类位于永久订阅命名空间,错误命名空间会得到“类不存在”或空结果。

完成第二步后,已经获得只对应 ROOT\subscriptionIWbemServices 服务接口。这个接口能查询永久订阅对象,但关联的起点仍然未知。接下来先读取 Filter,保留触发条件和稳定对象路径。

四、第三步:查询 __EventFilter

Filter 保存触发条件本身

执行 WQL 的方法返回枚举器。__EventFilterName 是对象名称,Query 是触发 WQL,QueryLanguageEventNamespace 说明查询解释方式与事件来源。

// 意义:执行只读 WQL 查询并返回对象枚举器。
// 返回:S_OK 成功。ppEnum 接收 IEnumWbemClassObject,调用方 Release。
HRESULT IWbemServices::ExecQuery(
    const BSTR strQueryLanguage, const BSTR strQuery,
    LONG lFlags, IWbemContext* pCtx, IEnumWbemClassObject** ppEnum
);

正确做法:保留名称、相对路径、WQL、查询语言和事件命名空间。相对路径如 __EventFilter.Name="SCM Event Log Filter" 可与 Binding 的 Filter 引用直接对照。

const wchar_t* query =
    L"SELECT Name, Query, QueryLanguage, EventNamespace, __RELPATH "
    L"FROM __EventFilter";
// 对每个结果分别保存 Name、Query、QueryLanguage、EventNamespace、__RELPATH。

错误示例:只保存 WQL。相同 WQL 在不同事件命名空间中的含义可能不同。缺少 EventNamespace 时,无法确定查询的事件类来源。

完成第三步后,每个 Filter 都有名称、触发 WQL、事件命名空间和相对路径。此时只知道“什么事件会匹配”,还不知道匹配后连接到哪个处理对象。接下来读取 Binding,并逐条保留它的两个引用。

五、第四步:查询 Binding 并解析两端对象路径

Binding 引用连接 Filter 与 Consumer

Binding 查询要保存两个原始引用。Filter 指向一个 __EventFilter 对象。Consumer 指向 CommandLineEventConsumerActiveScriptEventConsumer、日志处理对象或其它处理对象。DeliveryQoSMaintainSecurityContext 是 Binding 自身属性。

const wchar_t* query =
    L"SELECT Filter, Consumer, DeliveryQoS, MaintainSecurityContext "
    L"FROM __FilterToConsumerBinding";
// 对每个 Binding 保留 Filter 与 Consumer 的原始对象路径,不用名称文本替代引用。

正确做法:通过服务接口读取引用对象。GetObject 接受 WMI 对象路径。返回对象后,读取 __CLASSName。解析失败时保留原始引用。引用缺失本身也是读取结果。

// 意义:按 WMI 对象路径取得一个对象实例。
// 返回:S_OK 成功。ppObject 接收 IWbemClassObject,调用方 Release。
HRESULT IWbemServices::GetObject(
    const BSTR strObjectPath, LONG lFlags, IWbemContext* pCtx,
    IWbemClassObject** ppObject, IWbemCallResult** ppCallResult
);

IWbemClassObject* endpoint = nullptr;
if (SUCCEEDED(services->GetObject(bindingConsumerPath, 0, nullptr, &endpoint, nullptr))) {
    // 读取 endpoint 的 __CLASS 与 Name,随后 Release。
    endpoint->Release();
}

错误示例:把 Consumer 引用当成可执行文件路径。它通常是 NTEventLogEventConsumer.Name="..." 这类 WMI 对象路径,需要先解析类和对象名。是否存在命令文本取决于处理对象类型。

完成第四步后,Filter、Binding 和 Consumer 已能通过对象路径对应起来。读取结果还需要处理超时、属性类型和资源释放,才能区分空结果、等待超时和属性不可读。

六、处理等待与属性资源

枚举器需要有超时边界

读取枚举器的方法每次最多等待指定毫秒数。WBEM_S_TIMEDOUT 且没有对象时表示本次等待结束,不等于类没有实例。连续超时达到上限后记录状态并继续其它类。

// 意义:从 WMI 枚举器读取对象。
// 返回:WBEM_S_NO_ERROR 成功。WBEM_S_TIMEDOUT 等待超时。
HRESULT IEnumWbemClassObject::Next(
    LONG lTimeout, ULONG uCount, IWbemClassObject** apObjects, ULONG* puReturned
);

HRESULT status = enumerator->Next(5000, 1, &object, &returned);
if (status == WBEM_S_TIMEDOUT && returned == 0) {
    // 增加超时次数。达到上限后结束这一类查询。
}

属性读取后要清理 VARIANT。Filter 的 Query、Binding 的引用通常是 BSTR,数值和布尔字段使用其它联合成员。读取后需要使用下方清理 API,不能把所有属性强制转换成字符串指针。

// 意义:把 VARIANT 初始化为空值,以便 WMI 属性方法写入。
// 返回:无。每个初始化后的 VARIANT 都需要清理内部资源。
void VariantInit(VARIANTARG* pvarg);

// 意义:释放 VARIANT 内部的 BSTR、接口、数组等资源。
// 返回:S_OK 成功。
HRESULT VariantClear(VARIANTARG* pvarg);

完成等待和属性处理后,输出中的每个字段都有明确来源,也能保留读取失败的边界。最后要保持三类对象各自的身份,避免把配置关系误读为一次实际执行结果。

七、保持三类对象的独立身份

Filter、Binding 与 Consumer 不能合成一条记录

对象建模要分别保存 Filter、Binding 和 Consumer。Filter 表示条件,Consumer 表示动作,Binding 表示关联。一个 Filter 可以连接多个 Consumer,一个 Consumer 也可被多个 Binding 引用。

配置与实际触发需要分别判断。读取到完整 Binding,说明三类对象在读取时可以关联。实际事件是否发生、WQL 是否匹配和处理对象是否成功处理,需要事件日志或其它独立证据确认。

完整可运行程序在附件




008-WMI永久订阅的Filter与Consumer绑定解析.zip (156.22 KB, 下载次数: 0)

您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-7-20 19:01

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表