Windows Run 与 RunOnce 注册表启动项的安全枚举
Run 与 RunOnce 是登录阶段常见的注册表位置。安全读取的顺序是:确定根键和视图,使用只读权限打开键,按 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
HKCU 是 HKEY_CURRENT_USER 的缩写,表示当前登录用户的注册表配置。HKLM 是 HKEY_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。返回值类型是 LSTATUS:ERROR_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_SZ 与 REG_EXPAND_SZ 当作单个 UTF-16 文本。REG_SZ 保存普通文本,REG_EXPAND_SZ 保存含环境变量标记的文本;REG_DWORD、REG_QWORD、REG_BINARY 和 REG_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.exe、cmd.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 的 LSTATUS。RegOpenKeyExW、RegQueryInfoKeyW 和 RegEnumValueW 已经通过返回值给出错误码;在其它调用之后再读取线程错误状态,可能得到无关的旧值。
错误释放的第三个问题是遗漏早退分支。下面的直接 return 让 openedKey 没有机会关闭;读取大量键时,这类泄漏会累积系统句柄。
// 错误:QuerySomething 失败时,openedKey 没有调用 RegCloseKey。
if (QuerySomething(openedKey) != ERROR_SUCCESS) {
return;
}
并发变化的第四个步骤是限制重试次数。ERROR_MORE_DATA 表示查询到容量后数据又增长,重新查询上限并最多重试同一个索引几次即可;持续失败时记录根键、视图、索引和返回码,再继续其它位置,避免枚举停在一个不断变化的值上。
完整可运行程序在附件。