首页
社区
课程
招聘
[原创]Windows Run 与 RunOnce 注册表启动项的枚举
发表于: 6天前 935

[原创]Windows Run 与 RunOnce 注册表启动项的枚举

6天前
935

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

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


[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!

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