-
-
[原创]Explorer Shell 扩展、图标覆盖与 COM 服务器定位
-
发表于: 2026-7-22 18:19 995
-
Explorer Shell 扩展、图标覆盖与 COM 服务器定位
文章展示的功能已加入KswordARK,开源项目地址:0b3K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6w2f1%4N6G2M7X3c8p5c8g2k6Q4x3V1k6w2f1%4N6G2M7X3c8Q4x3V1j5`.

要想在不加载任何 COM 对象的前提下,定位 Explorer Shell 扩展和图标覆盖登记所指向的 COM 服务器,需要完成四步:
- 区分值型与子键型扩展来源,并打开用户级、机器级和 32/64 位注册表视图
- 枚举值型来源的值名、类型和原始值数据
- 枚举图标覆盖来源的子键名与默认值
- 验证 CLSID,再定位
InprocServer32与LocalServer32注册
这四步保留了“哪一类 Shell 功能引用了哪个 CLSID”和“该 CLSID 在哪里登记服务器”两层信息。得到的结果是静态注册信息,不能代替 COM 激活或 Explorer 实际加载结果。
一、对象关系和完整流程
Explorer 是 Windows 的文件管理与桌面外壳。它在右键菜单、属性页、图标覆盖、文件预览或 Shell Execute 钩子等场景查询扩展来源。来源记录提供 CLSID。COM 再根据 CLSID 的 Classes 注册定位 DLL 或 EXE 服务器。
Explorer 需要某类 Shell 功能 -> 读取扩展来源键 -> 得到 CLSID -> 查询 HKCU 或 HKLM Classes 注册 -> 读取 InprocServer32 或 LocalServer32 -> COM 创建对象并请求扩展接口
读取过程要先保留来源结构,再验证 CLSID,随后独立查询用户级、机器级和 32/64 位视图中的 COM 服务器。读取结果不能证明扩展对象已被成功创建。
枚举值型或子键型来源 -> 保存路径、视图和值数据 -> 验证 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},用于把客户端请求映射到服务器注册信息。
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,然后请求该扩展类别所需的接口。
Explorer 的文件或桌面操作 -> 扩展来源键(值或子键) -> CLSID -> Classes\CLSID 注册 -> InprocServer32 或 LocalServer32 原始文本 -> COM 激活、接口查询与运行时对象
来源键存在说明 Explorer 有机会发现这条登记。CLSID 注册存在说明 COM 有可查询的类信息。实际激活成功还取决于服务器文件、架构、COM 接口、权限和运行时状态。每个阶段都需要单独输出,不能用“扩展已注册”覆盖所有结果。
图标覆盖子键名和默认值承担不同角色
图标覆盖的子键名与默认值要分别读取。ShellIconOverlayIdentifiers 的直接子键名通常是覆盖标识,方便人类识别某个状态类别。子键默认值常保存 CLSID,用于定位实际 COM 对象。一个子键没有默认值时仍说明该标识存在,只是当前无法从该字段取得类身份。
图标覆盖显示还受到 Shell 的运行时选择规则影响。枚举到多个注册项并不代表每一个都会出现在桌面图标上。实际渲染与覆盖优先级、文件状态、扩展接口返回结果以及当前 Explorer 进程有关。静态审计报告应保留子键名、默认值类型和 CLSID 解析状态,不替运行时显示做判断。
标准枚举应先保存来源字段,再独立查询 Classes 根
发现 CLSID 后仍要保留来源上下文。每次从值型键读取记录时保存完整源路径、视图、值名、REG 类型、原始数据和“CLSID 出现在值名还是值数据”的位置。每次从子键型键读取记录时保存父键、子键名、默认值类型和默认值原始数据。只有格式验证通过的文本才进入 Classes 查询。
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 由调用方关闭,根键本身不需要关闭。
// 意义:打开注册表子键。
// 返回: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 位系统要显式读取两个注册表视图。机器级和用户级来源都保留根键标签,值相同也不能合并成一条记录。
const REGSAM views[] = { KEY_WOW64_64KEY, KEY_WOW64_32KEY };
for (REGSAM view : views) {
RegOpenKeyExW(HKEY_LOCAL_MACHINE, sourcePath, 0,
KEY_QUERY_VALUE | view, &key);
}
// 错误示例:x64 程序的默认视图会遗漏 32 位 COM 注册。
RegOpenKeyExW(HKEY_LOCAL_MACHINE, sourcePath, 0, KEY_QUERY_VALUE, &key);
完成第一步后,来源键的形状、所在根键和读取视图都有了明确边界。接下来需要逐个读取值型来源的值名与原始数据,才能判断 CLSID 来自哪个字段。
三、第二步:枚举值型来源的值名、类型和值数据
枚举值前先查询本次键的最大值名长度和最大数据长度。值名长度单位是 UTF-16 字符数,值数据长度单位是字节,两个数不能互换。
// 意义:取得键的值数、最大值名长度和最大值数据长度。
// 返回: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 时分别扩大字符缓冲区和字节缓冲区,不能把返回的数据字节数当作值名字符数。
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 时扩容。
// 意义:按索引读取一级子键名。
// 返回: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 表示,读取结果和子键名组成一条图标覆盖记录。
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);
}
// 错误示例:只保存子键名称,默认值中的 CLSID 没有被读取。
std::wstring overlayName = subKeyName;
完成第三步后,值型来源和图标覆盖来源都保留了原始登记字段。接下来只让格式正确的 CLSID 进入 Classes 注册查询,避免把显示名称或任意文本误当成 COM 类。
五、第四步:验证 CLSID 后定位 COM 服务器
CLSID 文本应满足 {8-4-4-4-12} 结构。值名是 CLSID 时直接使用。值数据恰好为 CLSID 时也可使用。其它显示名称和任意字符串不进入 COM 注册路径。
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 的默认值。
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 注册数据,原始文本应先保存。路径展开、文件存在性或签名检查属于后续独立步骤。
完整可运行程序在附件

f71K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4j5h3&6Y4N6$3g2A6j5$3#2Q4x3X3g2D9j5h3&6*7L8%4g2#2i4K6u0W2j5$3!0E0i4K6u0r3K9e0c8j5k6e0V1K6P5e0l9$3N6e0u0T1
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。