User Assist 注册表项:它到底是什么,存了什么?
Windows 系统为了提升用户体验,会记录你最近打开的程序,这就是 User Assist 注册表项存在的意义。它本质上是一个“行为记录器”,专门跟踪你点击过的快捷方式、可执行文件、甚至文档。这些数据并不是直接明文存储的,而是经过了一套加密流程:先对原始字符串做 ROT13 加密,然后再进行 Base64 编码(部分键值里还夹杂着时间戳和计数器)。这些数据的具体位置在:HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\UserAssist,下面还有多个子键,每个子键对应一个 GUID,比如 {CEBFF5CD-ACE2-4F4A-98E4-48C232D70A1F}。
需要注意的是,这些数据并非一成不变。不同版本的 Windows(比如 Win7、Win10、Win11)、不同的系统架构(x64 还是 WoW64)、甚至是否启用了“运行新实例”策略,都会影响数据的布局和可读性。更关键的是,默认权限下,普通进程想要读取这个路径,很可能直接触发 UAC 或者被 Windows Defender 拦下来——可不是“能打开就能读”那么简单。
用 RegQueryValueEx 读数据?先过这三道坎
很多人第一反应是直接调用 RegOpenKeyEx 加上 RegQueryValueEx,但结果往往是 ERROR_ACCESS_DENIED 或者返回空数据。为什么会这样?原因主要有三个:
- 强制性完整性控制:注册表项本身被系统标记了完整性级别,即便你有管理员权限,如果进程的完整性级别(IL)低于该键的要求(比如“Medium”级别进程无法读取“High”级别键),系统会直接拒绝访问。
- 符号链接需要穿透:某些子键(比如那些 GUID 键)本身是符号链接,你需要显式地用
REG_OPTION_OPEN_LINK标志才能打开,否则只能看到一层“外壳”。 - 动态值名:目标值名并不总是固定的,比如你以为叫
Count,但部分 Win10 更新后改成了RAZL这类无意义的名字。所以,不要硬编码,老老实实用枚举。
实操中,有几个关键点:
- 先用
RegOpenKeyEx打开父键时,传入KEY_ENUMERATE_SUB_KEYS | KEY_QUERY_VALUE,不要盲目加KEY_WOW64_64KEY或KEY_WOW64_32KEY。先调用IsWow64Process判断当前进程位数,再决定用哪个。 - 枚举所有子键名,对每个子键再调用
RegOpenKeyEx,并带上REG_OPTION_OPEN_LINK标志。 - 对每个子键,枚举其下的所有值名(用
RegEnumValue),跳过(Default)和长度为 0 的值,只处理二进制类型(REG_BINARY)且长度大于等于 16 的值。
解密 ROT13+Base64 数据:C++ 实现要点
User Assist 的数据结构其实很固定:前 4 字节是计数器(小端序),接下来 4 字节是最后执行时间(FILETIME 的低 32 位),再 4 字节是高 32 位,然后剩下的部分才是经过 ROT13 加密的 UTF-16 字符串。最后,整个结构(包括计数器和时间)再一起进行 Base64 编码。
一些常见的“坑”需要特别注意:
- 编码顺序搞反:不是先 Base64 再 ROT13,而是先对字符串做 ROT13,再整体进行 Base64 编码。
- 字符集搞错:Base64 解码后的结果不要直接当成 UTF-8 处理,它实际上是 UTF-16LE 编码,需要按 2 字节一组来读。
- 头部信息被忽略:很多人解码后直接对整个 buffer 做 ROT13,但正确做法是跳过前 16 字节,从 offset=16 开始对后续字节做 ROT13,而且只对偶数字节操作——因为 UTF-16 中 ASCII 字符只占低字节。
- 时间字段的误解:这里的时间字段不是普通的系统时间,而是 Windows 计时器 tick(自 1601 年 1 月 1 日以来的 100 纳秒间隔)。需要先转成
FILETIME,再转成SYSTEMTIME,不能直接除以 10000000 当秒数。
大致代码逻辑如下:
// 假设 raw_data 是 RegQueryValueEx 返回的 BYTE*,len 是长度 std::vectordecoded = base64_decode(raw_data, len); // 自行实现或用 OpenSSL if (decoded.size() < 16) return; uint32_t count = *(uint32_t*)&decoded[0]; uint64_t ft = *(uint64_t*)&decoded[4]; // FILETIME 是 uint64_t // 仅对 offset=16 之后的 UTF-16 字符做 ROT13 for (size_t i = 16; i + 1 < decoded.size(); i += 2) { BYTE lo = decoded[i]; if (lo >= 'a' && lo <= 'z') decoded[i] = 'a' + (lo - 'a' + 13) % 26; else if (lo >= 'A' && lo <= 'Z') decoded[i] = 'A' + (lo - 'A' + 13) % 26; } // 然后 reinterpret_cast (&decoded[16]) 即可得原始程序名
权限与反检测:为什么你的程序一读就蓝/被杀
读取 User Assist 的难点,从来不是解密算法,而是系统是否允许你触碰那块内存。这里涉及到的安全问题很多:
- Windows Defender 的 ASR 规则:尤其是规则 ID
270e243d-bf1c-45e1-9a27-9b7538959e89,会直接拦截非常规方式访问 UserAssist 键。 - 企业环境的重定向:某些企业环境启用了 Credential Guard 或 VBS(基于虚拟化的安全),此时注册表句柄会被重定向到隔离空间,
Reg*API 返回成功但数据为空。 - 调试模式的影响:如果进程以调试模式启动(
IsDebuggerPresent为 true),部分系统版本会静默丢弃 UserAssist 更新,导致你读到的是陈旧数据。
实操建议:
- 千万避免使用
RegLoadKey或反射 DLL 注入方式读取,这些操作很容易被安全软件标记。 - 不要频繁轮询,间隔至少 30 秒,否则很容易被 EDR 标记为“行为探测”。
- 如果必须高权限读取,可以尝试用
AdjustTokenPrivileges启用SE_DEBUG_PRIVILEGE和SE_TCB_PRIVILEGE,但要注意,这本身就会触发日志告警。
