Explorer Shell 扩展、图标覆盖与 COM 服务器定位
本文所述所有内容已在KswordARK中实现,项目完全开源,地址:https://github.com/KSwordDEV/KSword/

要想在不加载任何 COM 对象的前提下,定位 Explorer Shell 扩展和图标覆盖登记所指向的 COM 服务器,需要完成四步:
- 区分值型与子键型扩展来源,并打开用户级、机器级和 32/64 位注册表视图
- 枚举值型来源的值名、类型和原始值数据
- 枚举图标覆盖来源的子键名与默认值
- 验证 CLSID,再定位
InprocServer32与LocalServer32注册
这四步保留了"哪一类 Shell 功能引用了哪个 CLSID"和"该 CLSID 在哪里登记服务器"两层信息。得到的结果是静态注册信息,不能代替 COM 激活或 Explorer 实际加载结果。
一、对象关系和完整流程
Explorer 是 Windows 的文件管理与桌面外壳。它在右键菜单、属性页、图标覆盖、文件预览或 Shell Execute 钩子等场景查询扩展来源。来源记录提供 CLSID。COM 再根据 CLSID 的 Classes 注册定位 DLL 或 EXE 服务器。
text
Explorer 需要某类 Shell 功能 -> 读取扩展来源键 -> 得到 CLSID -> 查询 HKCU 或 HKLM Classes 注册 -> 读取 InprocServer32 或 LocalServer32 -> COM 创建对象并请求扩展接口
读取过程要先保留来源结构,再验证 CLSID,随后独立查询用户级、机器级和 32/64 位视图中的 COM 服务器。读取结果不能证明扩展对象已被成功创建。
text
枚举值型或子键型来源 -> 保存路径、视图和值数据 -> 验证 CLSID 格式 -> 查询 Classes 根 -> 读取服务器默认值 -> 保存服务器类型、原始文本和错误码
Shell 扩展是由 Explorer 或其它外壳组件调用的 COM 对象
Shell 扩展的运行方式由 Explorer 和 COM 共同决定。许多扩展按 COM(Component Object Model)规则注册。外壳从注册表取得类标识符,再请求相应的 COM 服务器创建对象。
COM 客户端和 COM 服务器各有职责。Explorer 是客户端,它知道要请求的接口类别。扩展 DLL 或 EXE 是服务器,它实现对应接口。CLSID 是一个 GUID 形式的类标识符,例如 {01234567-89AB-CDEF-0123-456789ABCDEF},用于把客户端请求映射到服务器注册信息。
text
Explorer 或其它 Shell 客户端 -> 扩展来源注册项 -> CLSID -> HKCU 或 HKLM\Software\Classes\CLSID\{CLSID} -> InprocServer32(DLL)或 LocalServer32(EXE 命令文本)
CLSID 只是身份标识。它本身没有可执行路径、签名状态或接口实现信息。需要沿着 Classes 根下的注册路径继续读取,才能取得服务器位置和服务器种类。
不同 Shell 扩展来源使用不同的注册表形状
扩展来源的注册表形状并不相同。ShellExecuteHooks、SharedTaskScheduler、ShellServiceObjectDelayLoad 和 Shell Extensions\Approved 常采用"值名 + 值数据"的形式,CLSID 可能位于值名或数据中。ShellIconOverlayIdentifiers 则采用"子键 + 默认值"的形式,子键名经常是显示标识,默认值才可能存放 CLSID。
图标覆盖扩展的用途是让文件或文件夹图标显示小型状态图案。例如同步状态或版本控制状态可能通过图标覆盖表达。子键名称表达的是注册项的标识文本,默认值表达的是可激活对象的身份。只读其中一个字段无法保留完整关系。
HKCR 是合并视图,HKCU 与 HKLM 需要分别检查
HKEY_CLASSES_ROOT(HKCR)是 Classes 合并视图。它整合了当前用户的 HKCU\Software\Classes 与机器级的 HKLM\Software\Classes。需要解释来源和优先级时,应直接查询两个实际根路径并记录根键身份。
用户级注册可只影响当前用户,机器级注册通常面向本机用户。两个位置都出现同一 CLSID 时,查询合并视图得到的文本无法完整说明各自原始注册内容。枚举程序应分别报告用户级和机器级的 InprocServer32、LocalServer32,随后由调用场景决定解析优先级。
进程内服务器与本地服务器的启动边界不同
COM 服务器模型分为进程内和本地服务器。InprocServer32 通常指向 DLL,客户端进程通过加载 DLL 在自身进程内创建对象。LocalServer32 通常保存 EXE 的命令文本,COM 通过独立进程提供对象。两者的路径文本、架构匹配和故障范围都不同。
进程内 DLL 必须与 Explorer 的进程架构兼容,32 位和 64 位 Classes 视图因此需要分别检查。LocalServer32 的默认值可能包含带引号的可执行路径及参数,读取时应保留原始命令文本,不能以第一个空格作为路径分界。
Explorer 先发现扩展类别,再按 CLSID 激活对象
注册路径与 COM 激活形成连续调用过程。Explorer 在需要某类外壳功能时读取相应注册来源,例如图标覆盖、Shell Execute 钩子或延迟加载的 Shell Service Object。来源项给出 CLSID 后,Explorer 或其它 Shell 客户端通过 COM 查找 Classes 注册,再按服务器类型加载 DLL 或启动 EXE,然后请求该扩展类别所需的接口。
text
Explorer 的文件或桌面操作 -> 扩展来源键(值或子键) -> CLSID -> Classes\CLSID 注册 -> InprocServer32 或 LocalServer32 原始文本 -> COM 激活、接口查询与运行时对象
来源键存在说明 Explorer 有机会发现这条登记。CLSID 注册存在说明 COM 有可查询的类信息。实际激活成功还取决于服务器文件、架构、COM 接口、权限和运行时状态。每个阶段都需要单独输出,不能用"扩展已注册"覆盖所有结果。
图标覆盖子键名和默认值承担不同角色
图标覆盖的子键名与默认值要分别读取。ShellIconOverlayIdentifiers 的直接子键名通常是覆盖标识,方便人类识别某个状态类别。子键默认值常保存 CLSID,用于定位实际 COM 对象。一个子键没有默认值时仍说明该标识存在,只是当前无法从该字段取得类身份。
图标覆盖显示还受到 Shell 的运行时选择规则影响。枚举到多个注册项并不代表每一个都会出现在桌面图标上。实际渲染与覆盖优先级、文件状态、扩展接口返回结果以及当前 Explorer 进程有关。静态审计报告应保留子键名、默认值类型和 CLSID 解析状态,不替运行时显示做判断。
标准枚举应先保存来源字段,再独立查询 Classes 根
发现 CLSID 后仍要保留来源上下文。每次从值型键读取记录时保存完整源路径、视图、值名、REG 类型、原始数据和"CLSID 出现在值名还是值数据"的位置。每次从子键型键读取记录时保存父键、子键名、默认值类型和默认值原始数据。只有格式验证通过的文本才进入 Classes 查询。
cpp
HKEY root = nullptr;
const LSTATUS opened = RegOpenKeyExW(
HKEY_LOCAL_MACHINE, // 输入:机器级根。系统所有,调用方不关闭。
overlayPath, // 输入:NUL 结尾 UTF-16 图标覆盖来源路径。
0, // 输入:保留,必须为 0。
KEY_ENUMERATE_SUB_KEYS | KEY_WOW64_64KEY, // 输入:枚举子键和明确视图。
&root); // 输出:成功时得到 HKEY,本段负责关闭。
if (opened == ERROR_SUCCESS) {
// 用 RegEnumKeyExW 读取一级子键名。名称容量单位是 UTF-16 字符数。
// 打开每个子键后以 L"" 读取默认值。数据容量单位是字节。
// 保存来源字段后才验证默认值是否为 CLSID,并继续查询 Classes 根。
RegCloseKey(root); // 释放成功打开的 HKEY,已保存文本必须为独立副本。
}
错误做法是只把 CLSID 放进一个无来源的列表。相同 CLSID 出现在图标覆盖、Shell Execute Hook 与其它来源时,去重会抹掉它被哪一种 Shell 功能引用的事实。
HKEY、REGSAM、NUL 和两种长度单位需要明确区分
注册表 API 涉及资源与长度单位。HKEY 是进程持有的已打开键引用,RegOpenKeyExW 成功后由调用方 RegCloseKey。REGSAM 是访问掩码,KEY_QUERY_VALUE 与 KEY_ENUMERATE_SUB_KEYS 对应不同读取能力,KEY_WOW64_64KEY 或 KEY_WOW64_32KEY 选择数据视图。
RegEnumKeyExW 的名称长度以 UTF-16 宽字符数计,RegEnumValueW 的值数据长度以字节计。NUL 是数值为零的终止字符,返回数据不能只靠搜索 NUL 判断长度。应使用 API 返回的字符数或字节数。发生 ERROR_MORE_DATA 时分别扩大对应缓冲区,发生 ERROR_NO_MORE_ITEMS 时才结束枚举。
二、第一步:按来源结构和注册表视图打开键
第一步要确定每一种来源的登记形状和读取位置。ShellExecuteHooks、SharedTaskScheduler、ShellServiceObjectDelayLoad、Shell Extensions\Approved 使用值型结构。每个值名和值数据都是独立字段,CLSID 可能出现在任意一侧。
ShellIconOverlayIdentifiers 使用子键型结构。每个一级子键表示一个图标覆盖标识,子键默认值常保存 CLSID。子键名本身和默认值都需要单独保存。
打开值型来源时只申请查询值权限。返回的 HKEY 由调用方关闭,根键本身不需要关闭。
cpp
// 意义:打开注册表子键。
// 返回:ERROR_SUCCESS 表示成功。其它返回值是 Win32 错误码。
// 成功时 phkResult 接收 HKEY,调用方用 RegCloseKey 关闭。
LSTATUS RegOpenKeyExW(
HKEY hKey, LPCWSTR lpSubKey, DWORD ulOptions,
REGSAM samDesired, PHKEY phkResult
);
// 意义:关闭由 RegOpenKeyExW 返回的键句柄。
// 返回:ERROR_SUCCESS 表示成功。
LSTATUS RegCloseKey(HKEY hKey);
64 位系统要显式读取两个注册表视图。机器级和用户级来源都保留根键标签,值相同也不能合并成一条记录。
cpp
const REGSAM views[] = { KEY_WOW64_64KEY, KEY_WOW64_32KEY };
for (REGSAM view : views) {
RegOpenKeyExW(HKEY_LOCAL_MACHINE, sourcePath, 0,
KEY_QUERY_VALUE | view, &key);
}
cpp
// 错误示例:x64 程序的默认视图会遗漏 32 位 COM 注册。
RegOpenKeyExW(HKEY_LOCAL_MACHINE, sourcePath, 0, KEY_QUERY_VALUE, &key);
完成第一步后,来源键的形状、所在根键和读取视图都有了明确边界。接下来需要逐个读取值型来源的值名与原始数据,才能判断 CLSID 来自哪个字段。
三、第二步:枚举值型来源的值名、类型和值数据
枚举值前先查询本次键的最大值名长度和最大数据长度。值名长度单位是 UTF-16 字符数,值数据长度单位是字节,两个数不能互换。
cpp
// 意义:取得键的值数、最大值名长度和最大值数据长度。
// 返回:ERROR_SUCCESS 表示成功。长度仅是本次枚举的初始容量,
// 并发改写时仍要处理 RegEnumValueW 的 ERROR_MORE_DATA。
LSTATUS RegQueryInfoKeyW(
HKEY hKey, LPWSTR lpClass, LPDWORD lpcchClass, LPDWORD lpReserved,
LPDWORD lpcSubKeys, LPDWORD lpcbMaxSubKeyLen, LPDWORD lpcbMaxClassLen,
LPDWORD lpcValues, LPDWORD lpcbMaxValueNameLen, LPDWORD lpcbMaxValueLen,
LPDWORD lpcbSecurityDescriptor, PFILETIME lpftLastWriteTime
);
// 意义:按索引读取一个值名、值类型和原始数据。
// 返回: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 时分别扩大字符缓冲区和字节缓冲区,不能把返回的数据字节数当作值名字符数。
cpp
std::vector<wchar_t> name(maxValueNameChars + 1, L'\0');
std::vector<BYTE> data(maxValueDataBytes);
DWORD nameChars = static_cast<DWORD>(name.size());
DWORD dataBytes = static_cast<DWORD>(data.size());
DWORD type = REG_NONE;
LSTATUS status = RegEnumValueW(
key, index, name.data(), &nameChars, nullptr,
&type, data.empty() ? nullptr : data.data(), &dataBytes);
if (status == ERROR_SUCCESS) {
std::wstring valueName(name.data(), nameChars);
data.resize(dataBytes);
}
字符串只在类型为 REG_SZ 或 REG_EXPAND_SZ 且数据字节数能被 sizeof(wchar_t) 整除时解码。值数据为二进制或数值时保留原始类型和字节数,不能强制转换成路径文本。
完成第二步后,值型来源中的 CLSID 候选项已经带有完整的路径、视图和值字段。图标覆盖采用另一种注册表形状,接下来要读取它的子键名和默认值,才能获得同样完整的来源记录。
四、第三步:枚举图标覆盖的子键与默认值
图标覆盖来源的第一层是子键名。某些键允许 KEY_ENUMERATE_SUB_KEYS 却拒绝额外的键查询权限,子键名缓冲区可以从合理初始大小开始,在 ERROR_MORE_DATA 时扩容。
cpp
// 意义:按索引读取一级子键名。
// 返回:ERROR_SUCCESS 表示成功。ERROR_NO_MORE_ITEMS 表示结束。
// ERROR_MORE_DATA 表示 lpName 的字符容量不足。
LSTATUS RegEnumKeyExW(
HKEY hKey, DWORD dwIndex, LPWSTR lpName, LPDWORD lpcchName,
LPDWORD lpReserved, LPWSTR lpClass, LPDWORD lpcchClass,
PFILETIME lpftLastWriteTime
);
正确枚举后读取每个子键的默认值。默认值名称在 RegQueryValueExW 中用空字符串或 nullptr 表示,读取结果和子键名组成一条图标覆盖记录。
cpp
HKEY child = nullptr;
if (RegOpenKeyExW(root, childPath.c_str(), 0, KEY_QUERY_VALUE | view, &child) == ERROR_SUCCESS) {
DWORD type = REG_NONE;
DWORD bytes = 0;
RegQueryValueExW(child, L"", nullptr, &type, nullptr, &bytes);
RegCloseKey(child);
}
cpp
// 错误示例:只保存子键名称,默认值中的 CLSID 没有被读取。
std::wstring overlayName = subKeyName;
完成第三步后,值型来源和图标覆盖来源都保留了原始登记字段。接下来只让格式正确的 CLSID 进入 Classes 注册查询,避免把显示名称或任意文本误当成 COM 类。
五、第四步:验证 CLSID 后定位 COM 服务器
CLSID 文本应满足 {8-4-4-4-12} 结构。值名是 CLSID 时直接使用。值数据恰好为 CLSID 时也可使用。其它显示名称和任意字符串不进入 COM 注册路径。
cpp
bool IsClsidText(std::wstring_view text)
{
if (text.size() != 38 || text.front() != L'{' || text.back() != L'}') return false;
return text[9] == L'-' && text[14] == L'-' &&
text[19] == L'-' && text[24] == L'-';
}
COM 服务器定位需要同时查询两个 Classes 根。用户级路径是 HKCU\Software\Classes\CLSID\{CLSID},机器级路径是 HKLM\Software\Classes\CLSID\{CLSID}。每个根下分别读取 InprocServer32 与 LocalServer32 的默认值。
cpp
const std::wstring clsidPath = L"Software\\Classes\\CLSID\\" + clsid;
const std::wstring inprocPath = clsidPath + L"\\InprocServer32";
const std::wstring localPath = clsidPath + L"\\LocalServer32";
// 每个路径都使用当前的 KEY_WOW64_64KEY 或 KEY_WOW64_32KEY 打开,
// 再读取默认值。找到 InprocServer32 后仍继续检查 LocalServer32,保留全部注册结果。
InprocServer32 的默认值通常是进程内服务器路径,LocalServer32 的默认值通常是本地服务器命令文本。两种默认值都属于 COM 注册数据,原始文本应先保存。路径展开、文件存在性或签名检查属于后续独立步骤。
完整可运行程序在附件

https://wangweicm.lanzouu.com/i4Xe93y06u2b
文章摘要
本文详述了在不加载 COM 对象的前提下,定位 Explorer Shell 扩展和图标覆盖所指向 COM 服务器的四步流程:区分来源结构、枚举值型数据、枚举图标覆盖子键、验证 CLSID 并定位服务器。解析了 Shell 扩展与 COM 的关系、注册表形状差异、HKCR 视图要点及进程内/本地服务器区别,提供了关键 API 示例,实现静态注册信息的完整审计。