Skip to content

Feature Proposal: Integrating Shell native operations using VTable-Hook (hook IFileOperation Interface) #36

@AlexAdasCca

Description

@AlexAdasCca

从 Win7 开始资源管理器就不再直接通过 Shell32.dll 导出的 C 风格符号来完成各种文件操作,而是完全依赖于 COM。
我目前搜索了一些文章,他们明确指出资源管理器正在使用 IFileOperation 等接口完成文件操作,例如执行 CopyItems 方法完成文件复制,此外还使用 IFileOpenDialog 接口的 Show 方法打开文件选择对话框。

下面文章1指出了一种调试的方法,我们需要进一步调查以便于确定在 Win10/11 上,系统依然采取相同或相近的实现。在本地计算机上调试资源管理器会导致全局未响应的严重问题,资源管理器在内部使用低级钩子,并且其他各类应用也会向其中注入模块,这些模块也可能正在使用系统的SetWindowsHookEx设置低级钩子(WH_KEYBOARD_LL 和 WH_MOUSE_LL),我们应当知道这类钩子迫使 Win32k 内核在处理消息前先发送给你。如果挂钩过程未响应 CallNextHookEx,系统一般会等待执行超时30s 才禁用此类钩子,这会导致全系统范围的性能下降(我在我以前的一篇文章中描述过这个问题)。这也是为什么 OldNewThing (Chen)曾在一些文章说提到系统提供的低级钩子是不建议使用的。Of course, all this context-switching does come at a cost. Low-level hooks are consequently very expensive; don’t leave them installed when you don’t need them.。 除此之外,有记录表明使用全局消息钩子也可能引起系统的性能严重下降。

为什么在这种情况下我们不能直接调试资源管理器?因为一旦调试器附加资源管理器,则会立即插入一个 DebugBreak 软件断点,这会暂停资源管理器的所有线程,也包括那些正在使用 Win32k 所提供的 Hook 功能的线程。那么系统会瞬间陷入无响应状态,您开始等待鼠标卡住和延迟抖动超过30s,然后您才得以恢复与整个系统的交互。虽然随后您可以继续执行资源管理器。但是,问题远没有结束,一旦你再次中断(断点命中),那么你将不得不再次等待 30s(实际可能更久)。

那么有没有办法解决这个问题呢?只能说比较麻烦:1)资源管理器有一个注册表控制钩子超时时间设定的,但我不确定是否有用,而且需要重启机器。2)微软建议使用一个魔法命令行参数(我有点忘记了,应该很容易搜索到)执行资源管理器,这会使得它不加载任何 shell 扩展。

除了上述麻烦的途径,一个更好的方式是在虚拟机中建立一个干净的环境,然后只需要在虚拟机系统外的物理系统环境下设置 Windbg 的本机调试(也称为内核调试或者双机器调试),最后切换到用户模式并附加到资源管理器进程上。

注意:需要配置本机远程调试(使用WinDbg+VMware/VisualBox搭建双机调试环境),且需要一定逆向和调试的知识。

假设文章说的都是真实的资源管理器的实现,并且 win10/11上也是如此。那么,

我们需要挂钩 COM 接口/实现类。而 COM 使用一种类似虚表的结构来组织接口方法表,但与传统的 cpp 纯虚函数不同(微软采取了编译器之间能够统一的简单实现,具体可以见文章4, Chen给出了一些细节描述)。总之,除了获取函数地址方面比一般的 C 风格系统 API 麻烦些,后续的挂钩步骤几乎不变。不过我这么说是因为 Detours 并不对虚表挂钩提供支持,所以如果坚持使用 Detours,则需要手动处理这些复杂的细节(获取实例的地址以及虚表指针的地址)。

也有一些成熟的钩子框架,例如 EasyHook,PolyHook v2,他们已经完成了对 COM 挂钩的支持。不过这会引入更多的外部依赖。可以作为一种考虑,但事实上虚表的处理也并非过于复杂,是个“纸老虎”罢了。我在这里会给出一个 Gist 链接,上面我上传了一个简单的实现。

在能挂钩虚表后,我还需要指出一个关键的点:即,一个接口的虚表在相同中进程中是唯一的,也就是说,如果本次运行期间创建了多个相同实现的 COM 实例,他们的相同接口应该具有指向相同虚表的指针(不仅虚表相同,指针地址也是相同的)。这两个实例只有实例本身的地址不同,以及 IUnknown 对象的地址不同。总结为:代码只有一份。

那么,挂钩必须考虑是对所有实例都挂钩还是对某个具体的实例挂钩。前者就是前面说的直接挂钩虚表中具体的方法/函数,而后者则需要修改对象的虚表指针,将其导向到内存中我们复制的一份虚表副本,这个副本中的任何修改对其他实例透明。而每个实例级别的挂钩需要准确捕获到要修改的实例,这往往需要在程序初始化完成前后完成挂钩(挂起进程后注入)。于是引出下面要说的内容:

我搜索到一些相关的项目,他们也在考虑实例挂钩。不过非常取巧,他们选择在 CoCeateInstance 上设置挂钩并比较 CLSID,以便于发现并捕获实际创建的实例的内存地址。

相关的项目代码如下:

我不确定 FastCopy 是否要实现此项议题提出的设想,稳定性和简单优雅都是我们需要考虑的点。


附:虚表函数获取方法,用于了解原理。

传统 COM 接口必须实现三个固定的函数,特们也是 IUnknown 接口声明的方法,并且这与文档强调的“所有运行时类都必须直接或间接继承自 IUnknown ”相吻合。前文提到,COM 的虚表可以简单理解为是函数指针数组,可以按以下索引顺序进行访问。序号3开始是接口定义的额外的方法:

  • QueryInterface = 0
  • AddRef = 1
  • Release = 2

IFileOperation 在SDK的源代码中有完整的定义,可以轻松查阅。

下面是获取虚表指针的方法(“指针魔法”):

// IUnknown* -> vtable
[[nodiscard]]
static void** GetVTable(IUnknown* pUnk_)
{
    if (!pUnk_)
    {
        throw VTableException("Null IUnknown when accessing vtable");
    }
    // 注意:我们依赖 MSVC / Windows 下 COM 对象的内存布局:
    // 对于单一继承的 COM 接口,this 指针指向的对象的开头部分就是 vtable 指针。
    // *this -> lpVtable -> *lpVtable -> void* FuncVec[] -> FuncVec[0] = pFuncZero
    return *reinterpret_cast<void***>(pUnk_);
}

// 从任意 COM 接口指针获取其 vtable
template <typename TInterface>
[[nodiscard]]
static void** GetVTable(TInterface* pComInterface_)
{
    if (!pComInterface_)
    {
        throw VTableException("Null interface when accessing vtable");
    }
    return *reinterpret_cast<void***>(pComInterface_);
}

[[nodiscard]]
static void** GetVTable(wil::com_ptr_nothrow<IUnknown> const& rspUnk_)
{
    return GetVTable(rspUnk_.get());
}

template <typename TInterface>
[[nodiscard]]
static void** GetVTable(wil::com_ptr_nothrow<TInterface> const& rspComInterface_)
{
    return GetVTable(rspComInterface_.get());
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions