lionzl的专栏

轻财足以聚人,律己足以服人,量宽足以得人,身先足以帅人

Hook Com接口函数
标 题: 【原创】COM接口函数通用Hook方法
作 者: zhangluduo
时 间: 2014-12-08,22:34:20
链 接: http://bbs.pediy.com/showthread.php?t=195371


本文是我的本科学位论文, 今发表在此, 以示原创之据
原文地址:http://bbs.pediy.com/showthread.php?t=195371
作者: 张鲁夺 (http://www.ZhangLuduo.com/)
如有不足之处,请各位兄长指教
为适应论文要求,本文中废话较多,见谅。

《COM组件接口函数通用Hook方法》

目  录
摘  要  I
Abstract  III
第1章 绪论  1
1.1 研究背景  1
1.2 研究意义  1
第2章 问题抽象及关键技术研究  1
2.1 问题的提出  1
2.2 解决方案  1
2.3 相关技术简介  2
2.3.1 COM概述  2
2.3.2 COM内存模型描述及C语言和C++语言实现  3
2.3.3 调用约定  5
2.3.4 Hook API原理  5
2.3.5 Windows钩子原理及进程注入  7
2.3.6 开发及调试环境  7
本章小节  8
第3章 总体设计  1
3.1 实现目标  1
3.2 设计原则  1
3.2.1 通用性  1
3.2.2 简单性  1
3.2.3 高效性  2
3.3研究过程  2
3.3.1 文献参考  2
3.3.2 理论分析  2
3.3.3 调试实验  2
3.3.4面向IE的网页弹窗拦截器设计  3
本章小节  3
第4章 针对C++接口函数的挂钩研究与实现  1
4.1 实验01:通过调试器查看C++类的虚函数表  1
4.2 实验02:通过函数指针调用C++虚函数  2
4.3 实验03:交换两个相同C++类的虚函数表  4
4.4 实验04-1:替换C++虚函数表中的虚函数地址(__thiscall调用约定)  7
4.5 实验04-2:替换C++虚函数表中的虚函数地址(__stdcall调用约定)  13
4.6 实验05:进程内Hook API(MessageBoxW)  15
本章小结  19
第5章 针对COM接口函数的挂钩研究与实现  1
5.1 实验06:准备COM组件样本,并实现一个IMath接口(程序名称:Sample.dll)  1
5.2 实验07-1:测试Sample.dll组件(程序名称:SampleTest.exe)  2
5.3 实验07-2:测试Sample.dll组件(程序名称:SampleTest.html)  5
5.4 实验08:使用对象代理法来Hook IMath的接口函数(程序名称:SampleProxy.dll)  6
5.5 实验09:使用虚表补丁法来Hook IMath的接口函数(程序名称:SampleVtable.dll)  9
5.6 实验10:对实验程序SampleProxy.dll,SampleVtable.dll的综合测试(程序名称:SampleHook.exe)  12
本章小结  17
第6章 实例:面向IE的网页弹窗拦截器  1
6.1 实现背景  1
6.2 网页弹窗原理1:IHTMLWindow2Vtable::open  1
6.3 网页弹窗例程1:例程 Open_IHTMLWindow2.html  2
6.4 网页弹窗原理2:IWMPCore::launchURL  2
6.5 网页弹窗例程2:例程 Open_IWMPCore.html  5
6.6.1 使用虚表补丁法Hook IHTMLWindow2Vtable::open(程序名称:Kill.dll)  5
6.6.2 使用虚表补丁法Hook IWMPCore::launchURL(程序名称:Kill.dll)  7
6.7 IE弹窗拦截器主程序(程序名称:PopTerminator.exe)  10
6.8 Dll入口函数(DllMain)需要注意的问题  11
6.9 程序测试  11
本章小结  12
第7章 结论与展望  1
参考文献  1
致谢  1
附录(术语对照表)  1
原创声明  1
 
第1章 绪论
1.1 研究背景
两年前,我由于工作需要,需要在开发的软件中加入截取其他播放器中正在播放的电影画面功能,起初也没觉得困难,也就是利用Windows的GDI函数而已。在程序测试的过程中发现,偶尔截取出来的画面是黑的。经探究得知,是由于开启了硬件加速造成了截取的图像黑屏。

在Windows环境下开发视频播放器,多数使用微软的DirectXSDK。通常屏幕上的数据都是通过Primary Surface(中文称为主表面)送至显示器。除了Primary Surface还有OffScreen Suface(中文称之为离屏表面),故名思义,这种表面处理的图像数据是无法直接用于显示的,必须传输至Primary Surface才能够显示在显示器上。对于开启了硬件加速的机器,还可以使用Overlay Surface(中文称之为覆盖表面)。对Overlay Surface的支持完全由硬件提供,使用Overlay Surface能够充份利用硬件优势。通常使用的截屏函数都是针对Primary Surface,而无法获取Overlay Surface中的数据。所以,当播放器使用Overlay Surface显示图像时,截出来的画面黑屏就不足为奇了。而且微软也没有提供相应的API来截取Overlay Surface中的数据。并且,DirectX开发包是以COM接口形式提供给用户使用的,就这意味着,不能使用Hook API方法来获取COM接口中的数据。

任何程序的核心功能,其本质都是处理数理。如果一个程序在它的生命周期内,没有处理并输出任何数据,那么这个程序肯定是毫无意义的。程序处理的数据通常来源于以下几个渠道:来自用户输入的数据,来自数库中的数据,来自存储设备的数据,以及来自网络的数据。这些数据的源都是可以“正常”获取的。换言之,在各个编语言中都提供了公开的API来读写这些数据。但是这些数据的获取方式,并不能满足所有的应用。例如前文中所描述的情况:我要获取的是第三方播放器的屏幕图像数据。既然没有公开的API来获取,就只能另辟蹊径了。
1.2 研究意义
当前的应用要求越来越复杂,从第三方应用的进程中获取数据的情况也会越来越多。例如:针对进程级的文件透明加密,可以使用Hook API技术来实现。内核级的文件透明加密,可以Hook内核相关函数,或通过文件过滤技术来实现。还有一些系统还原软件(例如,影子系统)可以使用卷过滤技术来实现。这些常见的应用,其本质都是先获取“别人”的数据再进行处理。可见,处理第三方应用程序中的数据这种要求在未来还会有很多。

随着个人计算机的普及,Windows用户也越来越多。在Windows系统中,COM技术渗透到了各个领域内。几经发展,现在COM技术已经非常成熟悉。在未来会有越来越多的程序使用COM技术来构建。这就是说,我们还会有很多的机会遇到从COM接口中获取数据的需求。有鉴于此,研究从如何HookCOM接口函数,是非常有必要的。 
第2章 问题抽象及关键技术研究

2.1 问题的提出
在Windows系统编程领域,经常会遇到控制或者修改第三方进程的数据的要求。在过去相当长的一段时间里,由于大部分应用,基本上都是使用Win32 API来完成软件的功能,所以当需要在第三方进程中读写或修改数据时,仅使用Hook API即可达到这一要求。而随着Windows对COM技术的推进,越来越多的应用趋于使用COM技术来构建,由于COM的调用方式与Win32 API截然不同,对于这一部分应用,虽然还是以Win32为关键的技术支持,但是Hook API技术已经不能完全的达到控制其进程中的数据这一要求。本文针对此情况,提出了一种通用的挂钩COM接口函数的方法,来解决这一类相关的问题。通过一系列小的实验片断,一步一步揭示出挂钩COM接口的关键技术,并最终实现了一个具有现实意义的程序。

2.2 解决方案
本文提出一种通用的方法对COM接口函数进行挂钩。对于COM接口函数的调用,必须要首先取得对应的接口指针。接口指针相当于一扇门,而接口函数相当于门里摆放的具有各种功能的设备,要使用这些设备必须要从这扇门走进来,才能操纵门里的设备。
接口是由客户程序进行创建的,所以通常意义上讲,只要得到了客户创建的接口指针,也就进而可以得到接口函数的地址。有了接口函数的地址,就可以进行挂钩了。获得客户创建的接口指针的前题是,我们自己的挂钩程序必须先于客户程序运行。在现实中,这是很难保证的。而事实上,我们并不关心接口指针,我们关心的只是指针所指向的实际函数的调用。对我们来说,函数才是有意义的。

所以本文提出了一种较为通用的挂钩COM接口函数的方法,这种方法具有以下特点:
1. 挂钩程序不需要先于被挂钩程序之前运行。从程序使用的角度上说,我们开发了一个程序,不能无理的要求用户在使用我们的程序之前还要先运行目标程序。所以,我们的挂钩程序可以在任何时间启动,这更符合用户使用习惯。
2. 挂钩程序得到COM接口指针,不需要依赖于目标程序的创建接口指针动作。这在一定程度上提高了程序运行效率,因为在某些情况下,客户程序创建接口指针,使用完毕就马上销毁了。如果客户程序频繁的创建然后销毁,那我们开发的挂钩程序就不必在此环节保存并进而查找接口函数,这会使客户程序的运行效果不受任何损失。
3. 方法简单。针对不同和COM组件,对于开发人员来说,最重要的不是挂钩这一动作,而重要的是挂钩后,所要进行的具体工作,所以在程序中,不宜花太多的代码来进行挂钩。运用本文所提出的方法,挂钩方法的核心代码仅几百行便可完成,使得开发者可以节省出时间用在编写具体的功能上面。
综上所述,本文所提出的方案具有,程序可以随即运行,效率高等特点。因此本文提出的方法,能够运用在高要求,严要求的商业软件的开发之中。
2.3 相关技术简介
2.3.1 COM概述
COM(Component Object Mode),中文称为组件对象模型。COM是微软公司制定的面向对象的二进制可重用组件标准。COM技术的前身是OLE技术,起源于上世纪90年代。在Windows平台环境中,COM技术随处可见。最被人们所熟知的就是基于COM技术中的ActiveX技术了(ActiveX是COM技术的一个分支)。COM的主要特点体现在:内存模型确定性,组件面向对象,编写组件语言无关性,调用组件语言无关性,组件位置透明性。
内存模型确定性:在COM标准中,明确规范了COM接口及其二进制内存模型,无论使用什么语言来编写COM组件,只要能够按照COM标准,编译出它规范的内存模型即可。
组件面向对象:COM组件是面向对象的,即COM组件中的接口都是由各个COM对象来实现。
编写组件语言无关性:COM组件可以使用任何语言进行编写,只要能够实际COM标准中的内存模型即可。
调用组件语言无关性:COM组件是二进制一级的重用标准,理论上可以使用任何语言调用。但实际上由于COM中的一些复杂的数据类型,有一些编程语言调用起来还是比较复杂的,故此基于COM自动化技术的ActiveX技术得以发展。
组件位置透明性:调用一个COM组件前,不需要知道它在什么位置,只需要知道要使用的接口GUID和对象的GUID即可进行调用。
2.3.2 COM内存模型描述及C语言和C++语言实现
COM接口通常是一个指针(有的编程语言以已去掉了指针的概念,例如C#),接口指针指向的另一块内存,被称为接口函数表,接口函数表里包含了一组属于该接口的函数的实际地址。COM组件的结构如图2.1所示:
pic2.1.png 
图2.1 COM组件结构
Fig.2.1 COM structure

COM接口的内存结构如图2.2所示:
 
pic2.2.png
图2.2 COM接口的内存结构
Fig.2.2 COM interface memory structure

COM接口的内存模型可用如下C语言来描述:
struct *ISampleVtable;
struct ISample
{
  ISampleVtable* VtablePtr;
};

RETVAL (*Function1)(ISample* ThisPtr); // 声明Function1类型的指针
RETVAL (*Function2)(ISample* ThisPtr); // 声明Function2类型的指针
RETVAL (*Function3)(ISample* ThisPtr); // 声明Function3类型的指针
struct ISampleVtable
{
  Function1 pFxn1;
  Function1 pFxn2;
  Function1 pFxn3;
};
使用C语言来描述COM接口的内存模型,需要用到两个结构,上面代码段中的第二个结构保存了所有的函数指针。而第一个结构保存了第二个结构的指针。调用过程如下:
ISampleVtable->VtablePtr->pFxn1();
ISampleVtable->VtablePtr->pFxn2();
ISampleVtable->VtablePtr->pFxn3();
COM接口的内存模型有点类似于C++接口,当一个C++类中的成员函数全部是纯虚函数时,那么这样的C++类被称为“接口”(也有的文献中称为“抽象类”)。也可以理解为C++接口就是一个只有声明,但没有任何实现的C++类。要使用C++接口,必须创建一个继承于C++接口类的子类,并实现其全部纯虚函数。C++编译器在进行编译时,会为C++接口类生成一个“虚函数表”,在运行时程序初始这个表,并将真实的函数地址添加到虚函数表中。事实上,只要C++类中只要有虚函数的存在,编译器总会为这样的C++类生成一个虚函数表(后续实验中有所体现)。使用Viaual C++编译器进行编译时,这个虚函数表的位置就是C++对象的this指针所指向的地址。故此上面的COM接口模型,可以用下面的C++代码来描述:
class ISample
{
public:
  virtual RETVAL Function1() = 0;
  virtual RETVAL Function2() = 0;
  virtual RETVAL Function3() = 0;
};
注:在C++语言标准中,并没有明确各个编译器处理虚函数表的机制,签于此,本文所有的实验代码,若无特殊说明,均是基于Visual C++来开发并编译的。
2.3.3 调用约定
任何程序在函数调用的过程中,都会涉及到函数的返回值放在哪里,函数的参数如何传递,函数的Stack(栈帧)由谁来Cleanup(清除)等问题。所以需要在函数的Caller(调用者)及Callee(被调用者)之间制定一些公约,即调用约定。下文主要说明三种在本文中涉及的调用约定。

__cdecl:C语言及Visual C++编译器默认的调用约定,该调用约定的特点是支持可变参数的传递,如典型的printf函数。函数参数从右至左依次入栈,函数调用完毕后,由调用者清除栈帧。

__stdcall: 函数参数从右至左依次入栈,函数调用完毕后,由被调者自已来清除栈帧。

__thiscall: 该调用约定是C++成员函数的默认调用约定。this指针通过ECX寄存器传递,其他参数从右至左依次入栈。函数调用完毕后,由被调者自已来清除栈帧。如果在C++成员函数前,显示的指定其他调用约定,则this是第一个参数(即最左边的)。MSDN中明确指出,在Visual C++ 2005以前__thiscall不能被显示的指定,因为它不是调用约定的关键字。

返回值: 小于等于4字节的值通过EAX返回,如果返回值小于4字节,将被扩展成4字节。8字节的值通过EDX:EAX返回,高4字节位于EDX,低4字节位于EAX。
大于8字节的结构,将一个指向结构的隐含的指针通过EAX返回。
2.3.4 Hook API原理
在Windows环境中,Hook这个单词极为常见,在一些中文的文献中Hook被翻译成“钩子”,“挂钩”,“挂接”,从名称就可看出,Hook就是要“钩拄什么”,“挂住什么”,或者“要与什么对接”。也可以理解为Hook就是要将函数的调用进行拦截。

Windows中较为常见的是鼠标钩子,键盘钩子,和消息钩子。尤其是键盘钩子,早就因各种盗号木马而名扬天下。这就不难看出,安装钩子的目的就是要先于目标程序得到一些信息,比如,要先于目标程序得到用户的按键信息。Windows系统内部维护了一个“钩子链”的数据结构,最后安装钩子的程序,可以最先得到将要“钩住”的信息。既然是一个“链”,那么当程序在钩住信息后,可以放行这个信息,由Windows将信息继续传递到这条链的下一个节点去。当然,也可以告诉Windows:“这条信息我扣留了,你直接返回吧”,这样的话,先安装钩子的程序就什么也得不到了。有一些安全性要求较高的应用(如银行的密码输入框,支付宝的密码输入框)也正是利用了Windows钩子链原理来达到不让其他程序截获键盘按钮消息的目的。当然,安全控件要做一些特殊的处理,以防止木马安装的钩子是有效的。因为键盘钩子常被用于非法目的,所以键盘钩子也是杀毒软件监示的最重要的目标行为。单就从技术上讲,没有正与反,技术就是技术,正如一把枪,它最终的作用,要取决于是在警察手里,还是在恶魔手里。
Windows提供了安装钩子的API,函数为SetWindowsHookEx,其原型如下:
HHOOK SetWindowsHookEx(
  int idHook,
    HOOKPROC lpfn,
    HINSTANCE hMod,
    DWORD dwThreadId
);
idHook:要安装的钩子类型
lpfn::钩子过程回调函数,原型如下:
LRESULT CALLBACK HookProc(
  int nCode,
  WPARAM wParam,
  LPARAM lParam
  );
需要注意的是,钩子可以是针对进程(当前线程)本身的,也可以是针对整个系统的。如果安装的钩子工作在系统范围内,那么这个回调函数必须实现在一个DLL中,并传递DLL的句柄给hMod参数,dwThreadId也必须置为零。
hMod::如果安装的是系统范围的钩子,则需要传递一个DLL的实例句柄。如果  dwThreadId与创建钩子的线程ID相同,则此值必须置为空。    
dwThreadId:钩子工作在哪一个线程中,对于工作在系统范围的钩子,此值需置为零。
API(Application Programming Interface),API即应用程序编程接口,在Windows环境下,程序员主要依赖于Windows API进行编程。挂钩API与挂钩键盘有所异同,相同之处是,它们的目的都是要先于目标程序获得信息。Windows的普通钩子是系统内置支持的,而API钩子需要开发者自己实现。
Windows API一般都是实现在系统的DLL中的。开发者可以使用LoadLibrary和GetProcAddress来确定一个函数的地址。当确定了函数的地址,我们可以将函数的前几个字节修改为一个跳转指令,并令其指向我们准备好的函数。这样一来,当目标进程调用函数时,就会跳转到我们准备好的函数中,待我们从函数的参数中拿到数据后,还原函数的前几个字节的数据,将控制权返回给原函数。这一复杂的过程我们可以使用一些开源的较稳定的库来操作。以避免自己计算原函数到我们的“替换函数”偏移操作的麻烦,以及修改内存的麻烦。比较好的开源库有,微软研究院开发的Detours库,该库的免费版仅能工作在x86平台下。还有另外一个较好的开源库mhook,不仅可以工作在x86平台上,而且也可以工作在x64平台上,并且它是免费的。后续实验代码中有详细使用示程。
2.3.5 Windows钩子原理及进程注入
Windows支持多务任并行,Windows系统为了运行稳健安全,Windows使用了进程隔离技术,各个进程独立拥有4GB地址地址空间。本文所讨论的Hook COM接口技术,其目的,也是要先于目标程序获得信息。而正是由于Windows进程隔离的特性,要想修改目标程序的内存地址,首要我们自己写的代码就得工作在目标程序的进程空间内。即我们的写好的代码要注入到目标进程空间中。

另外,针对本文所讨论的题目,还有一个注入时机的问题。下文的实验中,我们将要在目标进程中挂钩CoCreateInstance函数,以便获得目标进程创建的接口指。这就要求,在目标进程创建接口指针前,我们的代码就已经工作在目标进程空间,并完成了CoCreateInstance函数挂钩的工作。所以,最佳的注入时机就是目标进程启动的过程中。基于这一点,我们需要利用Windows和WH_GETMESSAGE(消息钩子),这样,当有消息到达目标进程时,Windows会自动替我们完成注入。
2.3.6 开发及调试环境
操作系统:
Windows XP SP3 (32-bit) 中文版
开发工具:
Visual Studio 6.0 Enterprise Edition
Visual Studio 6.0 Service Pack 5
Visual Studio 6.0 Service Pack 6
Platform SDK February 2003
Visual Studio 2008
MSDN Library for Visual Studio 2008

浏览器:
Internet Explorer (32-bit)

本章小节
本章介绍介绍了本论文需要解决的问题,以及解决问题需要用到的关键技术。这些关键技术,是后续程序实现的基础。但是仅撑握本章所介绍这些关键技术,对于后续挂钩真正的COM接口函数是远远不够的。所在,在后续的章节中,较详细的研究了C++接口及COM接口的内存模型,并通过它们的内存模型,进而找到接口函数的真实地址。
 
第3章 总体设计
3.1 实现目标
对于COM接口函数,由于COM技术本身的复杂性,使得调用COM接口函数并不像调用Win32 API那般容易。这主要表现在客户程序创建COM接口的过程。对于一个简单的COM组件,客户程序可以通过CLSID以及接口的IID直接创建。对于一个支持自动化的COM组件,通常由系统(或程序)“自动”的创建相关接口,这一过程对于客户程序来说,通常是透明的。例如一个典型的ActiveX,如果使用像VB这样的可视化开发工具,可直接将其通过拖拽的方式将ActiveX拖放到程序中,客户程序只需要修改相关的属性就能决定ActiveX的行为。除此外,COM还支持不同的线程模型,例如套间线程,自由线程等。所以在调用COM接口函数时还要注意COM对象的线程模型。
挂钩COM接口函数不像挂钩Win32 API那般容易。对于一个普通的Win32 API,无论它在哪个DLL中实现,函数本身就是一个普通的内存地址而已。所以对于Win32 API的挂钩,复杂的过程在于修改函数首地址的内存,以及相关的偏移计算等。对于函数地址的寻找,几乎不费什么周折。而对于COM接口函数,由于它是通过接口指针进行调用的,我们不能简单的通过修改函数首地址这个方法来实现,并且,COM是面向对象的,在实现接口的对象中,还会有数据成员的访问,所以,如果通过挂钩Win32 API的办法来修改接口函数地址,程序一定会崩溃。由于每个COM组件的情况不同,所以本文要设计一种较为通用的方法对COM接口函数进行挂钩。
3.2 设计原则
3.2.1 通用性
本文旨在找寻一种较为通用的挂钩COM接口函数的方法,使得在任何情况下都可以运用本文提出的方法来进行COM接口函数的挂钩。

3.2.2 简单性
通常情况下,针对某一特定的COM接口函数进行挂钩,其目的是获取接口函数中的数据,或者将接口函数进行阻断执行。所以对于挂钩程序来说,处理接口函数中的数据是最为重要的目的,这就是说,不宜在挂钩这一操作上大费周折,编写过多代码使程序流程不清晰。

3.2.3 高效性
为使挂钩操作简便,在条件允许的情况下,尽量不使用WriteProcess来修改内存,因为此函数需要打开进程句柄,这势必会对宿主进程的执行效率带来一定的副作用。 所以在本文的设计中,使用程序最原始的等号赋值来修改内存。
3.3研究过程
3.3.1 文献参考
当前,针对COM接口函数的挂钩资料及程序,可以用“罕见”二字来形容。在国内的相关文献或专著中,仅见梁肇新先用所著的《编程高手箴言》,其中的4.4.2小节中稍微的提及了一下。除此外,在Codeproject.com上还有一篇文章探讨过。但是现有的资料中,描述的都不够全面,透彻。虽然相关资料较少,这并不说明挂钩COM接口函数的需求少,而是因为COM技术本身相对复杂,有关COM的专著也不如Windows编程那样多。对于挂钩COM接口这一课题的研究首先要从微软的COM规范入手,在相关的文献或专著中找到能够与挂钩接口函数的有用资料。
3.3.2 理论分析
分析接段要建立在对COM组件至少有一个比较清晰的认识,以及对于某种实现COM组件的编程语言的深刻认识的基础上。因为COM组件的实现是与编程语言无关的,这就要求,从COM的规范中,总结出,哪一些是“实现语言有关”的细节。毕竟,实现一个COM组件是需要用计算机语言来编写的。如果对于某个可以用来实现COM组件的编程语言有着较为深刻的认识,这有助于本课题的研究。本课题使用C++语言进行研究COM接口函数,而C++语言实现COM组件,也有着先天的优势。
3.3.3 调试实验
调试是证明理论分析的唯一方法。本文将通过一系列实验片断,从分析C++的接口入手,进而通过COM接口的内存模型与C++接口的内存模型对比,证明理论分析的成立。并由这些简短的C++实验片断推导出如何挂钩真正的COM接口函数。
实验主要包括:
1.  分析C++接口的内存模型,以及C++虚函数表的内存位置。
2.  通过确定虚函数表的位置,查找虚函数地址,并手动进行调用。
3.  通过交换C++接口指针观察虚函数的执行行为。
4.  通过替换C++虚函数表中的虚函数地址观察虚函数的执行行为。
5.  通过C++实验推导出挂钩COM接口函数的方法,并运用于一个自己写的真正的COM组件之中。
3.3.4面向IE的网页弹窗拦截器设计
基于前面的实验理论,以及实际的挂钩COM组件的实践,开发一款针对IE浏览器的网页弹窗拦截器。“网页弹窗拦截器”这个实例,一方面考虑到部分用户的需求,有必要开发这样一款小软件。另一方面,要使用这个真实的实例程序来证明本文所运用的分析方法,研究方法,以及实验程序得出的结论都是正确的。通过该实例的验证,使用IE浏览器在进行网页浏览时,未受任何影响,仅当IE有弹窗时,才会被该实例程序进行阻止。这也说明了,本文的编程方法,对于宿主进程来说是安全的。
本章小节

本章初步说明了,本文的设计要达到什么样的目标,在设计时要遵循什么样的原则,以及进一步该如何实现这个目标。在后续的章节中,所有的研究,所有的实验都是遵循本章的设计原则循序渐近来进行的。 
第4章 针对C++接口函数的挂钩研究与实现
4.1 实验01:通过调试器查看C++类的虚函数表
如果一个C++类中包含有虚函数,C++编译器在进行编译时,会通过动态联编机制,为这个类生成一个“虚函数表”。通过下面的实验观察到,对象的this指针值为0x003f2da8,来到0x003f2da8内存处,并查看它的内存数据,前四个字节正是虚函数表的指针0x0042501c。由此可见,虚函数表的指针值,就是对象this指针所指向的首地址。再进一步查看0x0042501c处的内存数前,前两个4字节,正是第一个和第二个虚函数的地址0x0040100a,0x00401005。通过调试看到,这个C++接口的内存模型与COM接口的内存模型是一致的。如图4.1所示。
注1:Intel X86体系结构的CPU使用Little Endian(小字节序)来存储数字。例如有一个数字为0x11223344,那么这个数据在内存中序列的顺序是44 33 22 11,即内存的低地址存放这个数字的低位。与此相对应的,还有Big Endia(大字节序),如PowerPC,所以数字0x11223344,在内存中的存储的序列为 11 22 33 44。
注2:C++语言规范没有强制要求编译器如何处理虚函数。因为本文所讨论的是COM接口挂钩,而COM是标准的。故此,在实验接段,暂且认为这些实验的片断代码就是COM对象,并以此来推导出挂钩真正的COM接口的原理。
注3:实际上,只要C++类中存在虚函数,都会产生这个虚函数表,而不仅仅是C++接口类。
实验代码如下:
0001 class A
0002 {
0003 public:
0004   virtual void test1() = 0;
0005   virtual void test2() = 0;
0006 };
0007 
0008 class B : public A
0009 {
0010 public:
0011   virtual void test1() { printf("test1\n"); }
0012   virtual void test2() { printf("test2\n"); }
0013 };
0014 
0015 int main(int argc, char* argv[])
0016 {
0017   A* p = new B();
0018 
0019   __asm int 3; // break point
0020   delete p;
0021   p = NULL;
0022 
0023   return 0;
0024 }
pic4.1.png 
图4.1 通过调试查看C++虚函数表
Fig.4.1 View C++ virtual table in Debug

4.2 实验02:通过函数指针调用C++虚函数
该例程很简单,首先使用对象p来调用test1,test2。然后手动取得虚函数表的指针值,进而取得test1,test2这两个虚函数地址,并通过函指指针来调用。通过实验输出的结果观察到,使用函数指针也能正确调用test1,test2这两个虚函数。
该例程中,C++成员函数被显示的声明为__stdcall调用约定,这是为什么呢?回想一下“调用约定”那一章节。C++成员函数默认使用__thiscall调用约定,而在类外部声明函数指针时,不能指为__thiscall调用约定。在Visual C++ 2005以前,__thiscall并不是调用约定关键字。虽然从Visual C++ 2005开始可以显示的指定调用约定为__thiscall,但这只适用于C++类成员。所以,本例程中,将C++成员函数显示的指定为__stdcall调用约定,以确保程序正确执行。
注1: COM标准中规定,COM接口函数统一使用__stdcall调用约定。
实验代码如下:
0001 class A
0002 {
0003 public:
0004   A() { n = 45; }
0005   virtual void __stdcall test1() { printf("test1, n = %d\n", n++); }
0006   virtual void __stdcall test2() { printf("test2, n = %d\n", n++); }
0007 
0008   int n;
0009 };
0010 
0011 int main(int argc, char* argv[])
0012 {
0013   A* p = new A();
0014 
0015   p->test1(); 
0016   p->test2();
0017 
0018   LPDWORD VtablePtr = (LPDWORD)(*((LPDWORD)p)); // 取虚函数表指针
0019 
0020   // 取test1,test2两函数地址
0021   DWORD Fn_0 = *(VtablePtr + 0);
0022   DWORD Fn_1 = *(VtablePtr + 1);
0023  
0024   // 声明函数指针
0025   typedef void (__stdcall *PFN_Member)(void* pThis);
0026 
0027   PFN_Member fn0 = (PFN_Member)(Fn_0);
0028   PFN_Member fn1 = (PFN_Member)(Fn_1);
0029 
0030   fn0(p);
0031   fn1(p);
0032 
0033   delete p;
0034   p = NULL;
0035 
0036   return 0;
0037 }
test1, n = 45
test2, n = 46
test1, n = 47
test2, n = 48
4.3 实验03:交换两个相同C++类的虚函数表 
该例程中将p1,p2的指针值进行了交换。试想一下,他们到底交换了什么?首先p1,p2两对象的this指针被交换,this指针即是对象的首地址,所以交换了p1,p2的指针值,也就是交换了两个对象的this指针。其次,两个对象的this指针被交换又意味着什么?通过“实验01”,和“实验02”,已经清楚一个事实,即含有虚函数的C++类,它的对象的this指针所指向的地址即是虚函数表指针的所在。就是说,随着p1,p2的交换,他们各自的虚函数表也跟着进行了交换。从实验结果可以看出,p1,p2交换后,class B替代了class A,也可以说是class A替代了class B,即它们成了各自对方的“替身”。具有体交换过程请看图4.2所示。从输出的A::n,B::n的值来看,两个对象替换后,其内部运行也是正确的。
下文件称这种方法为Object Proxy(对象代理法)。
这个实验要注意的是,两个C++类必须是一样的(虚函数的个数及原型,数据成员的内存分布),即两个C++类的内存模型要一致,否则程序会产生异常。另外,我们“偷梁换柱”的目的在于控制目标。通常我们可能只是想控制目标类里的一个或少有的几个函数,而不是控制整个C++类,但该例程要求两个C++类必须要一样。就是说,如果class A包含了几十个甚至上百个虚函数,那我们在编写class B时也要写好和class A相同数量的虚函数并且函数原型也要相同。所以,该例程中使用对象替换对象的方法,显得过于愚笨。
实验代码如下:
0001 class A
0002 {
0003 public:
0004   A() { n = 90; }
0005   virtual void __stdcall test1(int a) { printf("A::test1, a = %d, n = %d\n", a, n++); }
0006   virtual void __stdcall test2(int a) { printf("A::test2, a = %d, n = %d\n", a, n++); }
0007 
0008   int n;
0009 };
0010 
0011 class B
0012 {
0013 public:
0014   B() { n = 50; }
0015   virtual void __stdcall test1(int a) { printf("B::test1, a = %d, n = %d\n", a, n++); }
0016   virtual void __stdcall test2(int a) { printf("B::test2, a = %d, n = %d\n", a, n++); }
0017 
0018   int n;
0019 };
0020 
0021 void exchange(void** p1, void** p2)
0022 {
0023   void* temp = *p1;
0024   *p1 = *p2;
0025   *p2 = temp;
0026 }
0027 
0028 int main(int argc, char* argv[])
0029 {
0030   A* p1 = new A();
0031   B* p2 = new B();
0032 
0033   p1->test1(1);
0034   p1->test2(2);
0035 
0036   p2->test1(3);
0037   p2->test2(4);
0038 
0039   exchange((void**)&p1, (void**)&p2);
0040 
0041   p1->test1(1);
0042   p1->test2(2);
0043 
0044   p2->test1(3);
0045   p2->test2(4);
0046 
0047   delete p1;
0048   delete p2;
0049   p1 = NULL;
0050   p2 = NULL;
0051 
0052   return 0;
0053 }
A::test1, a = 1, n = 90
A::test2, a = 2, n = 91
B::test1, a = 3, n = 50
B::test2, a = 4, n = 51
B::test1, a = 1, n = 52
B::test2, a = 2, n = 53
A::test1, a = 3, n = 92
A::test2, a = 4, n = 93

 pic4.2.png
图4.2 虚函数表指针交换前后对比
Fig.4.2 Virtual table pointer exchange
4.4 实验04-1:替换C++虚函数表中的虚函数地址(__thiscall调用约定)
该例程中,class A有两个虚函数,test1,test2,以及两个数据成员n,m。这个类是做为我们将要控制的目标类。class B中的A_test2用于替换A::test2函数。就是说,本例程仅仅是要控制A::test2的行为,而不是控制整个class A。具体交换过程请参见图4.3所示。
实际上只是为了替换一个A::test2函数,其实也没必要大动干戈的再写一个class B。既然是替换,这就要求,目标函数和我们准备的替换函数要相同,如返回值,调用约定,参数个数,参数类型,参数顺序要完全相同。所以,还是调用约定的问题。A::test2的调用约定是__thiscall,所以为了与目标函数调用约定相同,我们准备的替换函数只能写在一个C++类中。
该例程是通过修改虚函数表中的某个表项来达到替换函数的目的。虚函数表里的表项,就是一个一个虚函数的地址。在x86平台上,内存地址长度为4字节,所以本例程中直接使用了DWORD数据类型来表示函数地址。在x86-64(简称为x64)平台上,内存地址长度为8字节,所以,如果考虑到程序要编译为x64平台上运行的代码,地址就要使用8字节的数据类型,例如DWORD64。可以通过一个宏来切换地址这个数据类型,如下:
#if defined WIN64
  typedef DWORD64* MEMADDR_PTR;
  typedef DWORD64 MEMADDR_VAL;
#else 
  typedef DWORD* MEMADDR_PTR;
  typedef DWORD MEMADDR_VAL;
#endif
虚函数表实际上是一个线性数组,在修改其中的值时,我们要事先清楚,要修改的虚函数地址在虚函数表里是第几项。通过查看class A的声明可知,A::test2是class A的第二个虚函数,虚函数表下标从0开始,所以我们要修改的虚函数表项表示为[1]。在挂钩实际的COM接口函数时,也是通过查看接口声明来确定接口函数是第几个。还要注意的是,接口是可以继承的,计算接口函数是第几个的时候,一定要从最顶层的接口开始,即IUnknown接口。
该实验中还涉及到了内存属性的修改。Windows使用 Page(内存页面)来管理内存,在32 位Windows环境下,每个内存页面是4k。每个页面都有自己的保护属性。虚函数表的内存区则是受写保护的,即我们不能修改或在这个区域写入内容,如果直接修改,程序是会发生错误的。Windows提供了若干内存管理函数,其中VirtualProtect函数就是用来修改内存属性的。对于虚函数表的修改,我们可以使用该函数,先修改内存的属性,使之可读写,然后再修改虚函数表中的表项数据。
VirtualProtect,该函数原型如下:
BOOL WINAPI VirtualProtect(
  __in          LPVOID lpAddress,     // 内存起始地址
  __in          SIZE_T dwSize,        // 要修改的内存大小
  __in          DWORD flNewProtect,   // 新的保护属性
  __out         PDWORD lpflOldProtect // 旧的保护属性
);
由于Windows是使用页面文件的方式来管理虚似内存的,即便是修改2字节大小的内存,若修改的内存恰巧在页面边距上,也会造成相邻间的两个页面的属性被修改。这可能会引起安全问题,所以,我们在修改内存属性后,修改完内存,一定要修改回原来的内存属性。
该实验中,需要将一个成员函数的地址赋给一个DWORD变量,成员函数地址是无法强制转换成DWORD类型的。代码中使用了一个小技巧,即利用union成员共用同一内存地址的特性,通过union将一个成员函数地址转换为一个DWORD类型的值,并且写成了一个模板函数,如下:
0043   template <typename T> 
0044   static DWORD GetMemberFxnAddr(T MemberFxnName)
0045   {
0046     union
0047     {
0048       T From; 
0049       DWORD To;
0050     }  union_cast;
0051     union_cast.From = MemberFxnName;
0052     return union_cast.To;
0053   }
通过打印输出观察到,class A虚函数表的表项修改后,原来的A::test2指向了事先准备好的B::A_test2。但是B::A_test2函数打印输出时,指定了要输出的是B::n2,和B::m2,但是最终却输出了A::n,和A::m的值。这是因为,C++对象在访问数据成员时,其通过this指针加偏移方式来访问的。简短代码如下:
class A
{
public:
  A(){n = 80; c = 'E'; m = 90;}
  virtual void test1(){printf("A %d, %d\r\n", n++, m++);}
  virtual void test2(){printf("B %d, %d\r\n", n++, m++);}
private:
  int n;
  char c;
  int m;
};

int main(int argc, char* argv[])
{
  A* p1 = new A();

  LPDWORD ThisPtr = (LPDWORD)(p1); // this

  printf("%d\n", *(ThisPtr + 1)); // A::n
  printf("%c\n", *(ThisPtr + 2)); // A::c, 内存自对齐, 占字节
  printf("%d\n", *(ThisPtr + 3)); // A::m

  delete p1;
  p1 = NULL;

  return 0;
}
class A对象偏移为0的位置是它的虚函表指针,this+1后偏移4字节是A::n的值,this+2后偏移8字节的位置是A::m的值。在class B中为了正确打印出A::n,A::m,所以在class B类第一个数据成员是为了“占位”用的。这也就是B::A_test2能够正确打印出A::n,A::m的关键。
下文件称这种方法为VTable Patching(虚表补丁法)。
本节实验代码如下:
0001 class A
0002 {
0003 public:
0004   A() {n = 99; m = 25;}
0005   virtual void test1(int a){printf("A::test1, a = %d, n = %d, m = %d\n", a, n++, m++);}
0006   virtual void test2(int a){printf("A::test2, a = %d, n = %d, m = %d\n", a, n++, m++);}
0007 
0008   int n;
0009   int m;
0010 };
0011 
0012 class B
0013 {
0014 public:
0015 
0016   // the first byte is virtual table pointer of 'class A'
0017   void* VtableAddr; 
0018   int n2; // A::n
0019   int m2; // A::m
0020   A* p1;
0021 
0022   B() 
0023   { 
0024     n2 = 20; /* used for reference */ 
0025     m2 = 60; /* used for reference */ 
0026     p1 = new A(); 
0027   }
0028   ~B() 
0029   { 
0030     if (p1) 
0031     {
0032       delete p1; 
0033       p1 = NULL; 
0034     } 
0035   }
0036 
0037   void A_test2(int a) // 替换A::test2
0038   {
0039     printf("A::test2, hook successed, a = %d, n2 = %d, m2 = %d\n", 
0040       a, B::n2++/* output A::n */, B::m2++ /* output A::m */);
0041   }
0042 
0043   template <typename T> 
0044   static DWORD GetMemberFxnAddr(T MemberFxnName)
0045   {
0046     union
0047     {
0048       T From; 
0049       DWORD To;
0050     }  union_cast;
0051     union_cast.From = MemberFxnName;
0052     return union_cast.To;
0053   }
0054 
0055   void test()
0056   {
0057     p1->test1(1);
0058     p1->test2(2);
0059 
0060     LPDWORD VtablePtr = (LPDWORD)(*((LPDWORD)(LPVOID)p1)); // 取虚表指针
0061 
0062     DWORD dwOld = 0;
0063     if(!::VirtualProtect(VtablePtr, sizeof(LONG) * 2, PAGE_READWRITE, &dwOld))
0064       return;
0065 
0066     // 修改class A的第二个虚函数,即A::test2, 下标为[1]
0067     VtablePtr[1] = GetMemberFxnAddr(&B::A_test2);
0068 
0069     if(!::VirtualProtect(VtablePtr, sizeof(LONG) * 2, dwOld, &dwOld))
0070       return;
0071 
0072     p1->test1(3);
0073     p1->test2(4);
0074   }
0075 };
0076 
0077 int main(int argc, char* argv[])
0078 {
0079   B obj;
0080   obj.test();
0081 
0082   return 0;
0083 }
A::test1, a = 1, n = 99, m = 25
A::test2, a = 2, n = 100, m = 26
A::test1, a = 3, n = 101, m = 27
A::test2, hook successed, a = 4, n2 = 102, m2 = 28

 pic4.3.png
图4.3 虚函数表中函数地址被修改
Fig.4.3 Modified virtual function address


4.5 实验04-2:替换C++虚函数表中的虚函数地址(__stdcall调用约定)
本例程是实验04-1的改版,A::test1,A::test2的两个虚函数在类成员中被指定为__stdcall调用约定。这就是说,我们可以实现一个全局函数而不是一个C++成员函数,对class A中的某个虚函数进行替换。具体交换过程如图4.4所示。COM标准中规定,所有的接口函数的调用约定必须是__stdcall,本例程更接近于真实的COM接口。虚函数的替换过程,及实验输出结果与实验04-1相同。
本节实验代码如下:
0001 class A
0002 {
0003 public:
0004   A() {n = 99; m = 25;}
0005   virtual void __stdcall test1(int a){printf("A::test1, a = %d, n = %d, m = %d\n", a, n++, m++);}
0006   virtual void __stdcall test2(int a){printf("A::test2, a = %d, n = %d, m = %d\n", a, n++, m++);}
0007 
0008   int n;
0009   int m;
0010 };
0011 
0012 typedef void (__stdcall* PFN_MEMBER)(void* ThisPtr, int a);
0013 
0014 void __stdcall A_test2(void* ThisPtr, int a) // 替换A::test2
0015 {
0016   printf("A::test2, hook successed, a = %d, n = %d, m = %d\n", 
0017     a, ((A*)ThisPtr)->n++, ((A*)ThisPtr)->m++);
0018 }
0019 
0020 int main(int argc, char* argv[])
0021 {
0022   A* p1 = new A();
0023 
0024   p1->test1(1);
0025   p1->test2(2);
0026 
0027   LPDWORD VtablePtr = (LPDWORD)(*((LPDWORD)(LPVOID)p1)); // 取虚表指针
0028 
0029   DWORD dwOld = 0;
0030   if(!::VirtualProtect(VtablePtr, sizeof(LONG) * 2, PAGE_READWRITE, &dwOld))
0031     goto _FREE;
0032 
0033   // 修改class A的第二个虚函数,即A::test2
0034   VtablePtr[1] = (DWORD)(LPVOID)A_test2;
0035 
0036   if(!::VirtualProtect(VtablePtr, sizeof(LONG) * 2, dwOld, &dwOld))
0037     goto _FREE;
0038 
0039   p1->test1(3);
0040   p1->test2(4);
0041 
0042 _FREE:
0043   if (p1)
0044   {
0045     delete p1;
0046     p1 = NULL;
0047   }
0048 
0049   return 0;
0050 }
A::test1, a = 1, n = 99, m = 25
A::test2, a = 2, n = 100, m = 26
A::test1, a = 3, n = 101, m = 27
A::test2, hook successed, a = 4, n = 102, m = 28
 pic4.4.png
图4.4 虚函数表中函数地址被修改
Fig.4.4 Modified virtual function address
4.6 实验05:进程内Hook API(MessageBoxW)
什么是Hook API? 这里所说的API单指Win32 API,由于在Windows环境下,应用程序必须要使用Windows 提供的API才能编写一个合格的Windows程序。在程序中更是频频调用系统API来完成各种功能,如窗口管理,进程线程管理,文件读写,内存管理等等。一部分应用,由于需求较为特殊,例如文件透明加密,需要在其他进程在文件读写时,为用户将文件解密和加密,必须先于目标进程获取文件读写的内容。也就是说,在目标进程调用文件读写相关API时,先要调用文件透明加密系统提供的“替代”函数。自己进行挂钩系统API,需要开发者了解DLL基址重定位,远线程注入,跳转指令机器码,跳转指令的偏移计算等相关知识。虽说Hook API并不是什么新兴技术,但是要自己实现起来,还是要花一定的功夫。
该例程中使用了一个开源并免费的Hook API库-mhook来实现这一过程。该例程仅仅是为了演示如何使用mhook,所以直接挂钩的是进程内的MessageBoxW函数。而真实的挂钩Win32 API通常要实现在一个DLL中,并且在适时,将DLL注入到目标进程中。对于mhook库的使用,可以说是傻瓜化的。一般的步骤如下:
1 声明一个与将要挂钩函数相同的函数指针。本例程中要挂钩的函数是MessageBox,实际上系统并没有提供MessageBox函数,MessageBox实际是一个宏,跟据用户所创建的工程使用的字符集,如果是UNICODE,则MessageBox自动切换为MessageBoxW函数,如果工程使用的字符集是ANSI,则MessageBox,自动切换为MessageBoxA函数。因为本例程使用的是UNICODE字符集,所以所以声明函数指针时,参照了MessageBoxW。基本上,只要涉及到字符串的函数,Windows都提供了两个版本。
2 使用声明好的函数指针保存原始的API函数地址。通常我们挂钩的API都是在DLL中实现的,DLL在被进程加载后,有一个基址重定位的过程。使用mhook库时,不用考虑这个。只需要使用GetModuleHandle函数和GetProcAddress获取地址即可。这两个函数的原型如下:
HMODULE GetModuleHandle( 
  LPCTSTR lpModuleName // 模块名称
);
FARPROC WINAPI GetProcAddress(
  __in HMODULE hModule,  // 模块句柄
  __in LPCSTR lpProcName // 函数名称(只能是ANSI字符串)
);
要挂钩的API所在的模块名称,可以通过查阅MSDN来获得。而函数的真实名称,有一个简单有效的方法得到。即在Visual C++编辑器中输入函数名称,如MessageBox,然后在此函数名上点击鼠标右键,在弹出的菜单是,点选Go To Definition,即可跳转到真实的函数声明处,如图4.5所示
pic4.5.png 
图4.5 查看函数声明
Fig.4.5 View the function declaration
3 按照将要挂钩的函数原型,准备一个“替代”函数。
4 使用Mhook_SetHook函数开始挂钩,或使用Mhook_Unhook函数停止挂钩。
5 将写好的挂钩模块,注入到目标进程。本文后续实验中仍然使用WH_GETMESSAGE钩子来完成注入过程。

0001 #include "mhook/mhook-lib/mhook.h"
0002 #if defined _DEBUG || defined DEBUG
0003   #pragma comment(lib, "Debug/mhook.lib")
0004 #else
0005   #pragma comment(lib, "Release/mhook.lib")
0006 #endif
0007 
0008 // 声明MessageBoxW的函数指针
0009 typedef int (WINAPI* PFN_MessageBoxW)(HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType); 
0010 
0011 // 保存MessageBoxW原来的地址
0012 PFN_MessageBoxW MessageBoxW_OLD = (PFN_MessageBoxW)GetProcAddress(GetModuleHandle(_T("user32.dll")), "MessageBoxW");
0013 
0014 // 用于替换原始的MessageBoxW函数
0015 int __stdcall MessageBoxW_NEW(HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType)
0016 {
0017   // 简单的修改一下标题栏文字,将控制权返回给原始的MessageBoxW
0018   return MessageBoxW_OLD(hWnd, lpText, L"Hook - We are modified the caption", uType);
0019 }
0020 
0021 // 开始拦截MessageBoxW的调用
0022 VOID MessageBoxW_Hook()
0023 {
0024   if (Mhook_SetHook((PVOID*)&MessageBoxW_OLD, MessageBoxW_NEW)) 
0025   {
0026     // successfully
0027   }
0028 }
0029 
0030 // 关闭拦截MessageBoxW的调用
0031 VOID MessageBoxW_UnHook()
0032 {
0033   Mhook_Unhook((PVOID*)&MessageBoxW_OLD);
0034 }
0035 
0036 // 主函数
0037 int APIENTRY _tWinMain(HINSTANCE hInstance,
0038                      HINSTANCE hPrevInstance,
0039                      LPTSTR    lpCmdLine,
0040                      int       nCmdShow)
0041 {
0042   MessageBoxW(NULL, L"Hello World", L"Title", MB_OK);
0043 
0044   MessageBoxW_Hook();
0045   MessageBoxW(NULL, L"Hello World", L"Title", MB_OK);
0046   MessageBoxW_UnHook();
0047 
0048   MessageBoxW(NULL, L"Hello World", L"Title", MB_OK);
0049 
0050   return 0;
0051 }
程序运行结果如图4.6,图示4.7,图4.8所示:
 pic4.6.png
图4.6 未挂钩前输出
Fig.4.6 before unhook

pic4.7.png 
图4.7 挂钩后输出
Fig.4.7 after hook
pic4.8.png 
图4.8 取消挂钩后输出
Fig.4.8 Cancel hook

本章小结
通过前几个实验,我们已经撑握了如何交换两个C++对象的的虚函数表;我们也撑握了通过虚函数表指针查找表中的虚函数,及进行虚函数地址改写,
这些知识,为我们挂钩真正的COM接口奠定了基础。但是在Hook实际的COM接口时,还有一些问题有待解决。 
第5章 针对COM接口函数的挂钩研究与实现
5.1 实验06:准备COM组件样本,并实现一个IMath接口(程序名称:Sample.dll)
在此例程中,我们创建了一个真正的COM组件,即Sample组件。在此组件中添加了一个接口IMath,并由CMath对象实现它。然后在IMath接口中添加了两个接口函数,一个是Add用于计算两数之和,一个是Sub用于计算两数之差。IMath接口除了继承了必须的接口(如IUnknow),还继承自IDispatchImpl,这是一个自动化支持接口,在后续的实验是,我们将还可以使用网页来调用Sample组件。
该组件核心代码如下:
// Sample.dll
0001 STDMETHODIMP CMath::Add(int a, int b, int *c)
0002 {
0003   *c = a + b;
0004   return S_OK;
0005 }
0006 
0007 STDMETHODIMP CMath::Sub(int a, int b, int *c)
0008 {
0009   *c = a - b;
0010   return S_OK;
0011 }
0012 
0013 MIDL_DEFINE_GUID(IID, IID_IMath,0x09783CD5,0xCCAF,0x4D1D,0xA4,0x53,0x01,0x6E,0x68,0xE8,0x52,0x09);
0014 MIDL_DEFINE_GUID(CLSID, CLSID_Math,0x6BD26856,0x7090,0x4F8F,0xA8,0xF8,0xE6,0x05,0x9C,0xEB,0x73,0xAC);
5.2 实验07-1:测试Sample.dll组件(程序名称:SampleTest.exe)
在调用实验06中的组件前,首先要将编译好的Sample.dll注册到系统当中。注册COM组件可以使用系统提供的实用工具Regsvr32。具体的注册方法是,在命令行中输入:regsvr32 -i "组件的完整路径及名称",反注册一个COM组件只需将-i改为-u即可。在客户程序(调用组件的程序)调用一个COM组件前,最重要的是知道将要使用的接口的GUID,以及实现接口函数的COM对象的CLSID。这两个信息,在实验06中已经给出。
组件注册好以后,启动本例程SampleTest.exe,上面一行文本框,分别是,加数1,加数2,和数。下面一行文本框分别是减数1,减数2,差值。通过运行观察,Sample组个提供的加减法功能正常,测试通过。
后续实验中,我们将通过挂钩IMath接口中的函数,进而从SampleTest.exe中获取用户输入的原始值,并将其“偷偷修改”。
本节测试代码如下:
// SampleTest.exe
0001 class CSampleTestDlg : public CDialog
0002 {
0003   ...
0004 private:
0005 
0006   IMath* m_lpIMath;
0007 };
0008 
0009 BOOL CSampleTestDlg::OnInitDialog()
0010 {
0011   ...
0012 
0013   AfxMessageBox("Start ?");
0014 
0015   ::CoInitialize(NULL);
0016 
0017   HRESULT hr;
0018 
0019   // 方法一
0020 
0021   /*
0022   CLSID  clsid;   
0023   CLSIDFromProgID(OLESTR("Sample.Math"),&clsid);
0024   hr = CoCreateInstance(clsid, NULL, CLSCTX_INPROC_SERVER, __uuidof(IMath), (LPVOID*)&m_lpIMath); 
0025   */
0026 
0027   // 方法二
0028   hr = CoCreateInstance(CLSID_Math, NULL, CLSCTX_INPROC_SERVER, IID_IMath, (LPVOID*)&m_lpIMath); 
0029 
0030   if (FAILED(hr))
0031   {
0032     CString str;
0033     str.Format("Error: 0x%08X", hr);
0034     AfxMessageBox(str);
0035   }
0036 
0037   return TRUE;  // return TRUE  unless you set the focus to a control
0038 }
0039 
0040 void CSampleTestDlg::OnButton1() 
0041 {
0042   if (NULL == m_lpIMath)
0043   {
0044     AfxMessageBox("NULL");
0045     return;
0046   }
0047 
0048   int a = GetDlgItemInt(IDC_EDIT1);
0049   int b = GetDlgItemInt(IDC_EDIT2);
0050 
0051   int c;
0052   m_lpIMath->Add(a, b, &c);
0053 
0054   CString str;
0055   str.Format("%d", c);
0056   SetDlgItemInt(IDC_EDIT3, c);  
0057 }
0058 
0059 void CSampleTestDlg::OnButton2() 
0060 {
0061   IMath* lpIMath = NULL;
0062   HRESULT hr = CoCreateInstance(CLSID_Math, NULL, CLSCTX_INPROC_SERVER, IID_IMath, (LPVOID*)&lpIMath); 
0063 
0064   if (FAILED(hr))
0065   {
0066     CString str;
0067     str.Format("Error: 0x%08X", hr);
0068     AfxMessageBox(str);
0069     return;
0070   }
0071 
0072   int a = GetDlgItemInt(IDC_EDIT4);
0073   int b = GetDlgItemInt(IDC_EDIT5);
0074 
0075   int c;
0076   lpIMath->Sub(a, b, &c);
0077 
0078   CString str;
0079   str.Format("%d", c);
0080   SetDlgItemInt(IDC_EDIT6, c);
0081 
0082   lpIMath->Release();
0083 }
5.3 实验07-2:测试Sample.dll组件(程序名称:SampleTest.html)
该例程,也是针对Sample组件的测试。前文中提及,Sample组件实现了IDispatchImpl接口,这就意味着,该组件支持自动化调用。在html网页中,通过object标签来嵌入该组件。例如:<object id="math" width="0" height="0" classid="CLSID:6BD26856-7090-4F8F-A8F8-E6059CEB73AC"></object>然后就可以通过javascript脚本来调用IMath接口提供的加减法功能了。
后续实验中,我们将通过挂钩IMath接口中的函数,进而从SampleTest.html中(IE浏览器)获取用户输入的原始值,并将其“偷偷修改”。
本节测试代码如下:
// 文件名:SampleTest.html
0001 <HTML>
0002 <HEAD>
0003 <TITLE>test for hook COM interface and method</TITLE>
0004 </HEAD>
0005 <BODY>
0006 <script type="text/javascript">  
0007 function TestAdd()
0008 {
0009   var c = math.Add(document.getElementById("AddNum_1").value, document.getElementById("AddNum_2").value);
0010   document.getElementById("Result1").value = c;
0011 }
0012 function TestSub()
0013 {
0014   var c = math.Sub(document.getElementById("SubNum_1").value, document.getElementById("SubNum_2").value);
0015   document.getElementById("Result2").value = c;
0016 }
0017 </script>
0018 <object id="math" width="0" height="0" classid="CLSID:6BD26856-7090-4F8F-A8F8-E6059CEB73AC"></object>
0019 <input type="text" id="AddNum_1"> + <input type="text" id="AddNum_2"> = 
0020 <input type="text" id="Result1"> 
0021 <input type="button" value="button1" onClick = "javascript: TestAdd()">
0022 <br>
0023 <input type="text" id="SubNum_1"> - <input type="text" id="SubNum_2"> = 
0024 <input type="text" id="Result2"> 
0025 <input type="button" value="button2" onClick = "javascript: TestSub()">
0026 </BODY>
0027 </HTML>
5.4 实验08:使用对象代理法来Hook IMath的接口函数(程序名称:SampleProxy.dll)
该例程挂钩了一个真正的COM接口:IMath,使用的方法就是前文所提及的对象代理法。所以我们自己重定定义了一个C++类,名称为class IMathProxy,既然class IMathProxy是原始接口IMath的代理,那自然class IMathProxy就需要实现IMath中所有的函数。IMath是我们自己实现的,其内部也就是两个函数而已。但需要注意的是,IMath所有父类中的函数,都要在class IMathProxy中实现,这才算是一个完整的IMath的代理对象。本文仅给出了最关键的代码部分,完整的代码请参见本文的附档文件中的源码。
IMathProxy实现好了,剩下的问题就是,如何将IMathProxy对象的指针与真正的IMath的指针进行交换。在关键技术研究那些小实验中,对像的指针都是我们手动new出来的。IMathProxy是我们自己实现的,当然可以使用new操作符进行创建,但是IMath呢?IMath接口指针应该是客户程序创建出来的啊。一般情况下,COM接口指针都是由CoCreateInstance这个API来创建的,该函数原型如下:
STDAPI CoCreateInstance(
  REFCLSID rclsid,
  LPUNKNOWN pUnkOuter,
  DWORD dwClsContext,
  REFIID riid,
  LPVOID * ppv
);
其中,最后一个参数即是返回给客户程序使用的接口指针。既然这个函数是由客户程序进行调用的,要想得到从这个函数中输出的返回值,所以我们必须还要通过Hook API的办法,将这个函数进行挂钩。并且,我们不知道客户程序在什么时候调用这个函数创建接口指针,所以我们挂钩CoCreateInstance的程序必须先于客户程序运行。这样客户程序在任何时候调用CoCreateInstance,我们都可以挂钩到它。如果客户程序先运行,并在运行后的第一时间就调用了CoCreateInstance来创建接口指针,而我们的挂钩操作在后,这就没有任何意义可言了。本例程中挂钩的操作还是使用的mhook库,前文中已经讲过如何使用该库,请参见本例程给出的关键代码。

在已经挂钩的IMath::Add函数中,我们仅仅是简单的将两个加数的值加上了10,然后将函数控制权就返回给了原始函数。如果未挂钩前,例如客户输入的两个加数是1,2,程序输出3,挂钩后1变成了11,2变成了12,所以客户得到的结果就会是23。
在已经挂钩的IMath::Sub函数中,我们仅仅是简单的将两个减数的值分别加上20,10,然后将函数控制权就返回给了原始函数。如果未挂钩前,例如客户输入的两个减数是1,2,程序输出-1,挂钩后1变成了21,2变成了12,所以客户得到的结果就会是9。
客户程序就是实验07-1写的程序SampleTest.exe,以及实验07-2写的网页SampleTest.html,要注意请使用IE浏览器打开这个网页。其实只要是调用了IMath接口的任何客户程序都可以用该例程挂钩,但是在该例程通过宿主的进程名称判断,是不是要挂钩的目标程序。所以只能用前面两个实验中的代码,且SampleTest.html必须使用IE浏览打开。
本节核心代码如下:
// SampleProxy.dll
0001 class IMathProxy : public IMath
0002 {
0003 public:
0004   IMathProxy(IMath* lpIMath, HANDLE EventRelease);
0005 
0006   /*** IUnknown methods ***/
0007     ...
0008 
0009   /*** IDispatch methods ***/
0010     ...
0011 
0012   /*** IMath methods ***/
0013     virtual /* [helpstring][id] */ HRESULT STDMETHODCALLTYPE Add( 
0014         /* [in] */ int a,
0015         /* [in] */ int b,
0016         /* [out] */ int __RPC_FAR *c);
0017     
0018     virtual /* [helpstring][id] */ HRESULT STDMETHODCALLTYPE Sub( 
0019         /* [in] */ int a,
0020         /* [in] */ int b,
0021         /* [out] */ int __RPC_FAR *c);
0022 
0023 //private:
0024 
0025   HANDLE m_hValid;  // 用于标识对象是否有效
0026   IMath* m_lpIMath; // 原始接口指针, 用于最终调调原始接口
0027 
0028 };
0029 
0030 /*** IMath methods ***/
0031 HRESULT STDMETHODCALLTYPE IMathProxy::Add( 
0032   /* [in] */ int a,
0033   /* [in] */ int b,
0034   /* [out] */ int __RPC_FAR *c)
0035 {
0036   ...
0037   // 返回到函始函数
0038   return m_lpIMath->Add(  a, b, c);
0039 }
0040 
0041 HRESULT STDMETHODCALLTYPE IMathProxy::Sub( 
0042   /* [in] */ int a,
0043   /* [in] */ int b,
0044   /* [out] */ int __RPC_FAR *c)
0045 {
0046   ...
0047   // 返回到函始函数
0048   return m_lpIMath->Sub(  a, b, c);
0049 }
0050 
0051 typedef int (WINAPI* PFN_CoCreateInstance)
0052   (REFCLSID rclsid, LPUNKNOWN pUnkOuter, DWORD dwClsContext, REFIID riid, LPVOID * ppv); 
0053 
0054 PFN_CoCreateInstance CoCreateInstance_OLD = (PFN_CoCreateInstance)
0055   GetProcAddress(GetModuleHandle(_T("ole32.dll")), "CoCreateInstance");
0056 
0057 HRESULT __stdcall CoCreateInstance_NEW(REFCLSID rclsid, 
0058   LPUNKNOWN pUnkOuter, DWORD dwClsContext, REFIID riid, LPVOID * ppv)
0059 {
0060   HRESULT hr = CoCreateInstance_OLD(rclsid, pUnkOuter, dwClsContext, riid, ppv);
0061 
0062   if (FAILED(hr))
0063     return hr;
0064 
0065   if (CLSID_Math == rclsid && IID_IMath == riid)
0066   {
0067     HANDLE event = CreateEvent(NULL, TRUE, FALSE, NULL);
0068 
0069     // Create proxy object
0070     IMathProxy* NewObj = new IMathProxy((IMath*)(*ppv), event);
0071 
0072     // save
0073     ...
0074 
0075     // replace object
0076     *ppv = NewObj;
0077   }
0078 
0079   return hr;
0080 }

5.5 实验09:使用虚表补丁法来Hook IMath的接口函数(程序名称:SampleVtable.dll)
该例程和前面的实验都是挂钩IMath接口,前面的实验使用的是对象代理法,而本例程使用的是虚表补丁法。在前面的实验中讲到,要得到客户创建的接口指针,必须挂钩CoCreateInstance函数,而且挂钩程序必须要先于客户程序运行。在实际的应用中,这是很难做到的,我们很难保证我们的程序一定会先于客户程序运行。而对于实验08中的例程来说,从客户程序那里获取的按口指针是要和我们自己创建的对象指针进行交换。而本例程仅仅是要获得客户程序创建出来的接口指针所指向的接口函数地址表(即虚函数表)。而接口指针,对本例程来说只是一个寻找接口函数表的桥梁而已。
在C++中,同一个C++类的所有对象,分别拥有各自的对象指针,即this指针。但是对于同一个C++类,所有对象的this指针却指向了相同的虚函数表指针,都指向了相同的内存区。也就是说,每个对象各有各的对像指针,但是虚函数表,以及虚函数表中的函数地址却是同一个C++类所有对象所共有的。事实上也没有必要有多个复本。即使是普通的C++类,无论有多少个对象,其成员函数也是这些对象所共有的,仅是因为每个对象的this指针不同,所以数据成员会有多个复本,为每个对象所独有。
所以,我们可以在要挂钩的目标进程空间内,自己创建一个要挂钩的接口IMath,并通过这个指针来寻找接口函数表。这就是说,只要我们自己能创建出要挂钩的接口指针,就不用从客户程序那里得到这个指针了。所以,我们的挂钩程序和客户程序所就无所谓运行顺序了。这也符合用户使用程序的随意性。参见实验代码中的IMathVtable::HookMethod函数。
在此例程中,我们也是简单的将IMath::Add,IMath::Sub中的数据进行修改,然后就将函数的控制权返回给原始接口函数。客户程序依然用SampleTest.exe,IE浏览器(SampleTest.html),程序的运行结果同上一实验一样。
本节关键代码如下:
// SampleVtable.dll
0001 typedef HRESULT (__stdcall* PFN_ADD)(IUnknown* This, int a, int b, int* c);
0002 typedef HRESULT (__stdcall* PFN_SUB)(IUnknown* This, int a, int b, int* c);
0003 
0004 #if defined WIN64
0005   typedef DWORD64* MEMADDR_PTR;
0006   typedef DWORD64 MEMADDR_VAL;
0007 #else
0008   typedef LPDWORD MEMADDR_PTR;
0009   typedef DWORD MEMADDR_VAL;
0010 #endif
0011 
0012 class IMathVtable
0013 {
0014 public:
0015 
0016   static HRESULT HookMethod();
0017   static HRESULT UnHookMethod();
0018 
0019   static HRESULT __stdcall Add(IUnknown* This, int a, int b, int* c);
0020   static HRESULT __stdcall Sub(IUnknown* This, int a, int b, int* c);
0021 
0022   static MEMADDR_PTR m_Add_OLD;
0023   static MEMADDR_PTR m_Sub_OLD;
0024 
0025   static MEMADDR_PTR m_lpVtable;
0026   static HMODULE m_hModule;
0027 };
0028 
0029 MEMADDR_PTR IMathVtable::m_Add_OLD   = NULL;
0030 MEMADDR_PTR IMathVtable::m_Sub_OLD   = NULL;
0031 MEMADDR_PTR IMathVtable::m_lpVtable  = NULL;
0032 HMODULE IMathVtable::m_hModule       = NULL;
0033 
0034 HRESULT IMathVtable::HookMethod()
0035 {
0036   m_hModule = ::LoadLibrary("Sample.dll");
0037   
0038   // 改写虚表前九项(DWORD_PTR)内存的属性
0039   DWORD dwVtableMaxLen = 9; 
0040 
0041   // 确保COM环境补初始化
0042   ::CoInitialize(NULL);
0043 
0044   // 自己创建一个接口
0045 
0046   IMath* lpIMath = NULL;
0047   HRESULT hr = CoCreateInstance(CLSID_Math, NULL, 
0048     CLSCTX_INPROC_SERVER, IID_IMath, (LPVOID*)&lpIMath); 
0049 
0050   if (FAILED(hr))
0051     return hr;
0052 
0053   MEMADDR_VAL VtableAddr = *((MEMADDR_PTR)lpIMath);
0054   m_lpVtable = (MEMADDR_PTR)VtableAddr;
0055 
0056   // 将内存属性改为可读写
0057   DWORD dwOld = 0;
0058     if(!::VirtualProtect(m_lpVtable, sizeof(MEMADDR_VAL) * dwVtableMaxLen,
0059     PAGE_EXECUTE_READWRITE, &dwOld))
0060         return E_FAIL;
0061 
0062   // Hook Add method, Add函数位于虚表的第七项
0063   m_Add_OLD = (MEMADDR_PTR)m_lpVtable[7];
0064   m_lpVtable[7] = (MEMADDR_VAL)(MEMADDR_PTR)IMathVtable::Add;
0065 
0066   // Hook Sub method, Sub函数位于虚表的第八项
0067   m_Sub_OLD = (MEMADDR_PTR)m_lpVtable[8];
0068   m_lpVtable[8] = (MEMADDR_VAL)(MEMADDR_PTR)IMathVtable::Sub;
0069 
0070   // 内存属性再改回来
0071     if(!::VirtualProtect(m_lpVtable, sizeof(MEMADDR_VAL) * dwVtableMaxLen,
0072     dwOld, &dwOld))
0073         return E_FAIL;
0074 
0075   lpIMath->Release();
0076   return S_OK;
0077 }
0078 
0079 HRESULT IMathVtable::UnHookMethod()
0080 {
0081   ...
0082   return S_OK;
0083 }
0084 
0085 HRESULT __stdcall IMathVtable::Add(IUnknown* This, int a, int b, int* c)
0086 {
0087   ...
0088   // 调用原始的函数
0089   PFN_ADD pFN_Add = (PFN_ADD)m_Add_OLD;
0090   return pFN_Add(This, a, b, c);
0091 }
0092 
0093 HRESULT __stdcall IMathVtable::Sub(IUnknown* This, int a, int b, int* c)
0094 {
0095   ...
0096   // 调用原始的函数
0097   PFN_SUB pFN_Sub = (PFN_SUB)m_Sub_OLD;
0098   return pFN_Sub(This, a, b, c);
0099 }
5.6 实验10:对实验程序SampleProxy.dll,SampleVtable.dll的综合测试(程序名称:SampleHook.exe)
测试一:
涉骤1:先启动SampleHook.exe,然后点击Button1。此时,程序内部通过对象代理法对IMath::Add,IMath::Sub进行挂钩。
步骤2: 启动SampleTest.exe,输入数字进行加减法。这时会弹出一个输入值被修改的提示,点击确定后查看计算结果。即是被修改过的值的计算结果。
步骤3:关闭SampleHook.exe程序,再次使用SampleTest.exe和SampleTest.html进和数值计算,此时计算结果正是用户输入的原始值的计算结果。
测试结果,如图5.1所示:
 pic5.1.png
图5.1 综合测试一
Fig.5.1 Test 1


测试二:
步骤1: 启动SampleTest.exe,或者启动IE浏览器,并打开SampleTest.html,输入数字进行加减法,计算结果正确。
步骤2:启协SampleHook.exe,点击Button3,此时,程序内部通过虚表补丁法对IMath::Add,IMath::Sub进行挂钩。
步骤3:再次使用SampleTest.exe和SampleTest.html进和数值计算,这时会弹出一个输入值被修改的提示,点击确定后查看计算结果。即是被修改过的值的计算结果。
步骤4:关闭SampleHook.exe程序,再次使用SampleTest.exe和SampleTest.html进和数值计算,此时计算结果正是用户输入的原始值的计算结果。
测试结果,如图5.2所示:
pic5.2.png 
图5.2 综合测试二
Fig.5.1 Test 2

该实验,分别对SampleProxy.dll程序中的对象代理法及SampleVtable.dll程序中的虚表补丁法进行了测试。测试结果表明,当SampleTest.exe和SampleTest.html(IE)调用IMath::Add,IMath::Sub这两个函数时,均已被挂钩,因为挂钩程序修改了参数中的值,使得计算结果也被改变了。这也证明了,在前面所得出的实验结论都是正确的。
测试关键代码如下:
// SampleTest.exe
0001 class CSampleTestDlg : public CDialog
0002 {
0003   ...
0004 private:
0005 
0006   IMath* m_lpIMath;
0007 };
0008 
0009 BOOL CSampleTestDlg::OnInitDialog()
0010 {
0011   ...
0012 
0013   AfxMessageBox("Start ?");
0014 
0015   ::CoInitialize(NULL);
0016 
0017   HRESULT hr;
0018 
0019   // 方法一
0020 
0021   /*
0022   CLSID  clsid;   
0023   CLSIDFromProgID(OLESTR("Sample.Math"),&clsid);
0024   hr = CoCreateInstance(clsid, NULL, CLSCTX_INPROC_SERVER, __uuidof(IMath), (LPVOID*)&m_lpIMath); 
0025   */
0026 
0027   // 方法二
0028   hr = CoCreateInstance(CLSID_Math, NULL, CLSCTX_INPROC_SERVER, IID_IMath, (LPVOID*)&m_lpIMath); 
0029 
0030   if (FAILED(hr))
0031   {
0032     CString str;
0033     str.Format("Error: 0x%08X", hr);
0034     AfxMessageBox(str);
0035   }
0036 
0037   return TRUE;  // return TRUE  unless you set the focus to a control
0038 }
0039 
0040 void CSampleTestDlg::OnButton1() 
0041 {
0042   if (NULL == m_lpIMath)
0043   {
0044     AfxMessageBox("NULL");
0045     return;
0046   }
0047 
0048   int a = GetDlgItemInt(IDC_EDIT1);
0049   int b = GetDlgItemInt(IDC_EDIT2);
0050 
0051   int c;
0052   m_lpIMath->Add(a, b, &c);
0053 
0054   CString str;
0055   str.Format("%d", c);
0056   SetDlgItemInt(IDC_EDIT3, c);  
0057 }
0058 
0059 void CSampleTestDlg::OnButton2() 
0060 {
0061   IMath* lpIMath = NULL;
0062   HRESULT hr = CoCreateInstance(CLSID_Math, NULL, CLSCTX_INPROC_SERVER, IID_IMath, (LPVOID*)&lpIMath); 
0063 
0064   if (FAILED(hr))
0065   {
0066     CString str;
0067     str.Format("Error: 0x%08X", hr);
0068     AfxMessageBox(str);
0069     return;
0070   }
0071 
0072   int a = GetDlgItemInt(IDC_EDIT4);
0073   int b = GetDlgItemInt(IDC_EDIT5);
0074 
0075   int c;
0076   lpIMath->Sub(a, b, &c);
0077 
0078   CString str;
0079   str.Format("%d", c);
0080   SetDlgItemInt(IDC_EDIT6, c);
0081 
0082   lpIMath->Release();
0083 }

本章小结
本章中所做的实验,经过编译,生成如下程序。
Sample.dll         - IMath接口
SampleTest.exe    -- IMath测试程序。
SampleTest.html   -- IMath测试程序,必须使用IE浏览器打开。
SampleProxy.dll   -- 运用对象代理法挂接IMath接口中的Add,Sub函数
SampleVtable.dll  -- 运用虚表补丁法挂接IMath接口中的Add,Sub函数
SampleHook.exe    -- 用于调用SampleProxy.dll,SampleVtable.dll中提供的函数完成进程注入及挂钩。
通过挂钩一个真正的COM组件(Sample.dll),充份印证了前面章节中理论的正确性。并在实际的实践过程中,对这些理论有了更清昕的进一步认识和理解。但是,这仅仅是初始阶段,要想真正将挂钩COM接口的技术应用在各种场合,还会存在各种各样的困难。同时,出于对系统以及目标进程和稳定性,和效率的考虑,编程过程中,还有储多细节问题还需仔细处理。特别是目标COM组件的线程模型及同步问题。这些问题,将在实际的应用中遇到,并且需要开发者依据自身的技术功底,以及对众多问题的综合认识,才能够得以完成一个稳定且安全的挂钩程序。这些问题,已然超出了本文范围,还需开发者有的放矢针对具体问题做出最合适的处理。 
第6章 实例:面向IE的网页弹窗拦截器
6.1 实现背景
毫无疑问,网络在今天已经渗透到每个人的工作,生活的方方面面。在使用网络的过程中,最重要的就是通过浏览器浏览互联网上万千变化的我们感兴趣的信息。在浏览网页的过程中,比较让人头疼的第一号问题就是病毒,木马。但随着网民的安全意识逐步提高,加之国内安全厂商已免费开放杀毒软件,所以,对于当前的环境,较前些年要好得很多。所以在生活中,其次让人头疼的问题,就是许许多多不道德的网站站长,为了追求利益,在网站上加了许许多多的弹窗广告,甚至是循环弹出,直到耗尽系统资源为止。有的甚至是色情的页面。签于此,有必要开发一款针对网页的弹窗拦截器。这个实例仅是针对IE浏览器。
6.2 网页弹窗原理1:IHTMLWindow2Vtable::open
知其然,才知其所以然。要想开发一个网页拦截器,必须得先知道,网页是如何弹出一个窗口的。对于IE浏览器来说,最为官方的弹出方法是调用IE浏览器内置的window对象的open方法。典型的调用方法如下:
<input type = "button" value = "button" onClick = "window.open('http://www.zhangluduo.com/')">

众所周知,IE浏览器是基于COM技术构建的。在MSDN中,可以找到window对象的原型。window对象实际对应的就是IHTMLWindow接口。而window.open实际上就是IHTMLWindow2::open(IHTMLWindow2继承自IHTMLWindow接口),该接口函数原型如下:
HRESULT open(
    BSTR url,
    BSTR name,
    BSTR features,
    VARIANT_BOOL replace,
    IHTMLWindow2 **pomWindowResult
);
在该接口函数中,最重要的参数就是url,也就是IE弹出窗口要加载的网页的网址。所以,只要我们将IHTMLWindow2::open进行挂钩即可防止window.open这类的弹窗。
6.3 网页弹窗例程1:例程 Open_IHTMLWindow2.html
0001 <HTML>
0002 <HEAD>
0003 <TITLE>test for hook COM interface and method</TITLE>
0004 </HEAD>
0005 <BODY>
0006 <input type = "button" value = "button" onClick = "window.open('http://www.zhangluduo.com/')">
0007 </BODY>
0008 </HTML>
6.4 网页弹窗原理2:IWMPCore::launchURL
还有一类弹出窗口使用的技术,也是微软官方的,但是却不是IE的正规弹窗方法。该方法是利用Windows系统在本地安装的Windows Media Player播放器的功能来实现的。Windows Media Player随着Windows操作系统一起发行,几乎是每一台安装了Windows机器都会安装Windows Media Player,并且Windows Media Player也是基于COM技术构建的,而且它支持自动化。我们常常看的在线电影,有一部分就是在网页是嵌了一个Windows Media Player来播放的。调用Windows Media Player提供的弹窗功能来进行页面弹窗,该方法典型的调用如下:
<object id="wmp" width="0" height="0" classid="CLSID:6BF52A52-394A-11D3-B153-00C04F79FAA6"></object>
<input type = "button" value = "button" onClick = "wmp.launchURL('http://www.zhangluduo.com')">
通过注册表可以查询到这个CLSID对应的组件,在注册表里也记录着该组件的特理路径,即C:\WINDOWS\system32\wmp.dll。Visual Studio 6.0企业版有一个实用工具OLE View,它可以用来查看一个COM组件的所有接口,及接口函数,但前题是,COM组件必须支持自动化。我们就可以通过OLE View显示wmp.dll 类型库。如图6.1所示:
pic6.1.png 
图6.1 在OLE View中查看COM接口函数
Fig.6.1 View the COM interface function
 pic6.2.png
图6.2 在OLE View中查看COM接口函数
Fig.6.2 View the COM interface function

在OLE View右侧的窗口(如图5.2所示)中是全部的接口声明,及对象对应的GUID。通过搜索6BF52A52-394A-11D3-B153-00C04F79FAA6以及launchURL函数,我们很快就找到了我们在挂钩时将要用到的重要信息,如下:

[
  uuid(6BF52A52-394A-11D3-B153-00C04F79FAA6),
  helpstring("Windows Media Player ActiveX Control")
]
coclass WindowsMediaPlayer

[
  odl,
  uuid(D84CCA99-CCE2-11D2-9ECC-0000F8085981),
  helpstring("IWMPCore: Public interface."),
  dual,
  oleautomation
]
interface IWMPCore : IDispatch

这样我们就确定了,在网页中调用的launchURL,其实就是IWMPCore::launchURL。我们在挂钩时,需要创建真正的IWMPCore接口,需要用到IWMPCore接口所在头文件的一些声明,所以编程前需要先安装Windows Media Player SDK。
6.5 网页弹窗例程2:例程 Open_IWMPCore.html
0001 <HTML>
0002 <HEAD>
0003 <TITLE>test for hook COM interface and method</TITLE>
0004 </HEAD>
0005 <BODY>
0006 <script type="text/javascript">  
0007 function OpenUrl()  
0008 {
0009   wmp.launchURL('http://www.zhangluduo.com');  
0010 }  
0011 function OpenInit()  
0012 {
0013   document.body.innerHTML += '<object id="wmp" width="0" height="0" classid="CLSID:6BF52A52-394A-11D3-B153-00C04F79FAA6"></object>';
0014 }  
0015 eval("window.attachEvent('onload',OpenInit);");
0016 </script>
0017 <input type = "button" value = "button" onClick = "OpenUrl()">
0018 </BODY>
0019 </HTML>
6.6.1 使用虚表补丁法Hook IHTMLWindow2Vtable::open(程序名称:Kill.dll)
在对IHTMLWindows2::open进行Hook时,也同样是首先要得到接口指针,然后找到接口函数表地址。这里有必要说一下IHTMLWindow2接口的获得,因该接口在创建前,需要注册到系统Shell中,并且创建IHTMLWindow2接口时,一定要有一个Windows窗口做为宿主,所以它的创建过程较为复杂。在此实例中,我们不是直接手工创建该接口,而是启动一个线程,不断查询系统Shell中已注册的IE窗口,然后使用Windows提供的方法来获得IHTMLWindow2接口指针。这段代码详见下文中的FindInterface函数。
关键代码如下:

0001 // 接口查询
0002 HRESULT FindInterface(LPDWORD lpVtable)
0003 {
0004   CComPtr< IShellWindows > spShellWin;
0005   HRESULT hr = spShellWin.CoCreateInstance(CLSID_ShellWindows);
0006   if (FAILED(hr)) return hr;
0007 
0008   long nCount = 0;
0009   spShellWin->get_Count(&nCount);
0010   if (0 == nCount) return E_FAIL;
0011 
0012   for (long i = 0; i < nCount; i++)
0013   {
0014     CComPtr< IDispatch > spDispIE;
0015     hr = spShellWin->Item(CComVariant((long)i), &spDispIE);
0016     if (FAILED(hr)) continue;
0017 
0018     CComQIPtr< IWebBrowser2 > spBrowser = spDispIE;
0019     if (!spBrowser) continue;
0020 
0021     CComPtr < IDispatch > spDispDoc;
0022     hr = spBrowser->get_Document(&spDispDoc);
0023     if (FAILED(hr)) continue;
0024 
0025     CComQIPtr< IHTMLDocument2 > spDocument2 = spDispDoc;
0026     if (!spDocument2) continue;
0027     IID_IHTMLWindow2;
0028     IHTMLWindow2* lpIHTMLWindow2 = NULL;
0029     hr = spDocument2->get_parentWindow(&lpIHTMLWindow2);
0030     if (FAILED(hr)) continue;
0031 
0032     *lpVtable = *((LPDWORD)(LPVOID)lpIHTMLWindow2);
0033     lpIHTMLWindow2->Release();
0034 
0035     return TRUE;
0036     break;
0037   }
0038 
0039   return FALSE;
0040 }
6.6.2 使用虚表补丁法Hook IWMPCore::launchURL(程序名称:Kill.dll)
在该实例中,直接将弹出的窗口进行了阻断式的拦截。这和前面的若干实验不一样,前面的实验中,在挂钩到相关函数时,仅仅是获取到一些信息后,就将函数的控制权返回给了原始函数,而本实例,在挂钩到IWMPCore::launchURL,IHTMLWindow2::open两个接口函数后,不再向原始函数返回。即,当目标进程调用这两个接口函数时,是无效的,这样也就达到了我们拦截弹窗的目的。但是,考虑到有一些弹窗是非常重要的,例如银行通过弹窗来通知用户某个时段要进行服务器维护等。所以,在拦截到弹窗口,还需要将拦截到的弹窗URL告之用户。本实例中,将所有拦截到的URL显示在程序主界面的一个多行文本框之中。在Kill.dll中,通过向主程序窗口发送一个WM_SETTEXT消息来通知已经拦截到的弹窗的URL。WM_SETTEXT消息对于一个普通窗口来说,是设置其标题,而对于控件窗口来说,是设置控件上的文字。本实例的主程序在收到WM_SETTEXT并不是将消息中指定的字符串显示的标题栏上,而是输出到主程序界面是的文本框之中。所以在主程序中,通过窗口子类化技术,程序拦截自身的消息,在收到WM_SETTEXT时,直接将该消息中指定的字符串输出到文本框中。
窗口子类化,是进程内拦截消息的一种较为官方的方式。众所周知,在Windows环境下编程,程序是通过一个消息循环来决定生命周期的,即程序在不断的处理由Windows系统发送来的各消息。Windows要求,每一个窗口都有一个对应的“窗口过程函数”,原型如下:
LRESULT CALLBACK WindowProc(
  HWND hwnd,     // 发生消息的窗口句柄
    UINT uMsg,     // 消息类型
    WPARAM wParam, // 消息参数,不同消息对应不同的含意
    LPARAM lParam  // 消息参数,不同消息对应不同的含意
);
窗口子类化,就是将指定窗口的窗口过程函数地址修改为开发者提供的新的函数地址。从而使得能够在原窗口在调用窗口过程函数之前,收到Windows发送来的消息。窗口子类化需要用到的函数是SetWindowLong,窗口子类化仅是该函数的功能之一,该函数原型如下:
LONG SetWindowLong( 
  HWND hWnd,     // 将要子类化的窗口句柄
  int nIndex,    // 子类化窗口时,需指定该值为GWL_WNDPROC
  LONG dwNewLong // 子类化窗口时,需指定该值为新的窗口过程函数的地址
);
子类化窗口后,若函数正确执行,将旧的窗口过程函数的地址通过返回值返返回给调用者。这个返回值通常用于将函数控制权返回给原始函数,但是将控制权返回给原始函数时,不必再一次进行子类化。调用CallWindowProc函数即可,该函数原型如下:
LRESULT CallWindowProc(
  WNDPROC lpPrevWndFunc, // 窗口过程地址,即SetWindowLong的返回值
    HWND hWnd,             // 窗口句柄
    UINT Msg,              // 窗口消息
    WPARAM wParam,         // 消息参数
    LPARAM lParam          // 消息参数
);
窗口子类化请参见附档源码中的PopTerminatorDlg.cpp。
从一个DLL向窗口中传递数据的办法除了Windows消息外,还可以使用自定义消息,管道,邮槽,socket,剪贴板,共享内存等等方式。当然,最简单的就是Windows消息。这也是本实例中所使用的方法。
除此之外,在传递数据的时候,还要考虑到DLL和窗口之间的线程同步问题。本实例使用SendMessage发送消息,而该函数本身就是一个同步函数。SendMessage的另一个版本PostMessage则是异步的,在使用此函数时,如果WPARAM或LPARAM中携带的数据是分配在堆中的,尤其要注意与窗口同步的问题。关于SendMessage和PostMessage两个函数的使用请参见MSDN。
该实例,使用了前文中的虚表补丁法,来挂钩IWMPCore::launchURL,IHTMLWindow2::open两个接口函数。同样的,我们将要自己获取IWMPCore接口的接口函数表。
关键代码如下:
0001 // IWMPCoreVtable.cpp
0002 
0003 MEMADDR_PTR IWMPCoreVtable::m_launchURL_OLD = NULL;
0004 MEMADDR_PTR IWMPCoreVtable::m_lpVtable      = NULL;
0005 HMODULE IWMPCoreVtable::m_hModule           = NULL;
0006 
0007 HRESULT IWMPCoreVtable::HookMethod(/*LPDWORD lpVtable*/)
0008 {
0009   m_hModule = ::LoadLibrary("wmp.dll");
0010 
0011   HRESULT hr ;
0012 
0013   CLSID clsWMP; // coclass WindowsMediaPlayer                 
0014   hr = CLSIDFromString(L"{6bf52a52-394a-11d3-b153-00c04f79faa6}", &clsWMP);
0015   if (FAILED(hr)) return hr;
0016 
0017   IID idWMPCore; // interface IWMPCore                  
0018   hr = CLSIDFromString(L"{d84cca99-cce2-11d2-9ecc-0000f8085981}", &idWMPCore);
0019   if (FAILED(hr)) return hr;
0020 
0021   IWMPCore* pIWMPCore = NULL;
0022   hr = ::CoCreateInstance(clsWMP, NULL, CLSCTX_INPROC_SERVER, /*IID_IUnknown*/idWMPCore, (void**)&pIWMPCore); 
0023   if (FAILED(hr) || pIWMPCore == NULL)  return hr;
0024 
0025   MEMADDR_VAL VtableAddr = *((MEMADDR_PTR)(LPVOID)pIWMPCore);
0026   m_lpVtable = (MEMADDR_PTR)VtableAddr;
0027 
0028   DWORD dwVtableMaxLen = 20;
0029   DWORD dwOld = 0;
0030     if(!::VirtualProtect(m_lpVtable, sizeof(MEMADDR_VAL) * dwVtableMaxLen,PAGE_READWRITE, &dwOld))
0031         return E_FAIL; 
0032 
0033   m_launchURL_OLD = (MEMADDR_PTR)m_lpVtable[19];
0034 
0035   m_lpVtable[19] = (MEMADDR_VAL)(MEMADDR_PTR)IWMPCoreVtable::launchURL;
0036 
0037     if(!::VirtualProtect(m_lpVtable, sizeof(MEMADDR_VAL) * dwVtableMaxLen,dwOld, &dwOld))
0038         return E_FAIL; 
0039 
0040   pIWMPCore->Release();
0041   pIWMPCore = NULL;
0042   return S_OK;
0043 }
0044 
0045 HRESULT IWMPCoreVtable::UnHookMethod()
0046 {
0047   if (!m_lpVtable)
0048     return E_FAIL;
0049 
0050   DWORD dwVtableMaxLen = 20;
0051 
0052   if (0 != IsBadReadPtr(m_lpVtable, sizeof(MEMADDR_VAL) * dwVtableMaxLen))
0053     return E_FAIL;
0054 
0055   DWORD dwOld = 0;
0056     if(!::VirtualProtect(m_lpVtable, sizeof(MEMADDR_VAL) * dwVtableMaxLen, PAGE_READWRITE, &dwOld))
0057         return E_FAIL;
0058 
0059   m_lpVtable[19] = (MEMADDR_VAL)(MEMADDR_PTR)m_launchURL_OLD;
0060 
0061     if(!::VirtualProtect(m_lpVtable, sizeof(MEMADDR_VAL) * dwVtableMaxLen, dwOld, &dwOld))
0062         return E_FAIL;
0063 
0064   return S_OK;
0065 }
0066 
0067 HRESULT STDMETHODCALLTYPE IWMPCoreVtable::launchURL(void* This,  BSTR url)
0068 {
0069   HWND hwnd = GetRecvUrlWnd();
0070   if (hwnd)
0071   {
0072     ... // 将URL发送给主程序
0073     // 直接返回,阻断原始函数的调用
0074     return S_OK;
0075   }
0076   else
0077   {
0078     PFN_LAUNCHURL pFn = (PFN_LAUNCHURL)m_launchURL_OLD;
0079     return pFn(This, url);
0080   }
0081 }

6.7 IE弹窗拦截器主程序(程序名称:PopTerminator.exe)
主程序主要负责的工作就是调用Kill.dll安装钩子,以及接收来自Kill.dll发送来的URL地址。该程序流程图见图6.3,代码请参见附档文件。
pic6.3.png 
图6.3主程序流程图
Fig.6.3 the flow chart of main program
6.8 Dll入口函数(DllMain)需要注意的问题
Kill.dll程序中,在入口函数中(DllMain)不要直接调用挂钩函数,因为在获取或创建接口的过程中,还要加载DLL文件。如果在DllMain中再加载其他DLL文件,极易造成死锁,而影响目标进程的稳定性。所以,在该程序的入口函数中,创建新的线程以完成自动挂钩过程。而且还要注意的是,在新的线程中要使用CoInitializeEx函数初始化COM环境。注意,这个函数初始的COM环境是针对线程的。在任何线程中要调用COM接口,均需要进行初始化COM环境。
另外,在由于在Kill.dll中安装了全局钩子,为了使该程序不注入到非目标进程中,在入口函数中也进行了宿主的进程名称判断。如果不是目标进行,直接在入口函数中返回FALSE即可。详细过程请参见附档文件中的源码。
6.9 程序测试
  启动IE浏览器,打开Open_IHTMLWindow2.html或者Open_IWMPCore.html,然后打开弹窗拦截程序PopTerminator.exe,点击“Start”按钮。此时,IE浏览器中的弹出窗口将被拦截掉,同时拦截到的弹窗的URL将显示在PopTerminator.exe程序中。
该程序运行结果如图6.4所示:
 pic6.4.png
图6.4 网页弹窗拦截器运行实例
Fig.6.4 Instance of popup windows terminator

本章小结
本章通过一个真正实例,拦截了两个工作在IE浏览器进程内的COM接口函数,IWMPCore::launchURL,IHTMLWindow2::open,使得IE浏览器在使用这两个方法弹窗的,会被我们的程序所拦截到,并且通过自定议消息,将拦截到的URL到发送挂钩程序的主窗口中以进行显示给用户。 
第7章 结论与展望
本文通过若干C++实验片断代码,揭示了COM接口的内存模型与C++接口模型(仅Visual C++)的一致性。从而,我们可以通过研究C++接口函数的挂钩方法进而推导出如何挂钩一个真正的COM接口函数。
通过挂接我们自己写的一个真实的COM组件,和挂钩IWMPCore::launchURL,IHTMLWindow2::open两个接口函数,实践证明,研究手段是科学而有效的。
挂钩可以简单的理解为就是修改内存,而与内存相关的知识,无论在什么系统下,都是一个大的系统性问题。理论上讲,挂钩COM接口函数要较挂钩Win32 API更简单一些。因为挂钩Win32 API还要懂得一些汇编语言,以及机器码等底层的知识。它们的共同之处是,修改目标程序的执行流程。
在Windows环境下,除了本文所提及的挂钩API,和COM接口函数是用于修改目标程序执行流程的。其实还有一些其他技术,储如,过滤驱动,Hook内核函数,SSDT,IDT等技术,其目的也是修改程序执行流程。但是这些技术需要更了解系底层的更多知识,才能游刃有余的使用。
《rootkit windows内核安全防护》的作者曾对Hook API这种技术说过,Hook API的强大,取决于想像力。换言之,Hook API是无所不能的。而随着Windows操作系统的普及,COM技术的发展如同给Windows系统插上了双翼。COM技术也在不断的前行,例如DOM,COM+。在某些情况下,在用户层做编程工作,仅仅撑握Hook API技术,已经不能适应时代的发展了。
要想在用户层无所不能,撑握Hook COM接口也是很必要的。由于COM技术一直在不断前行,Hook COM接口函数这种技术,在未来还会有更多的展示空间。 
参考文献
1. 潘爱民. COM原理与应用[M],北京:机械工业出版社,2006.4.
2. 梁肇新.编程高手箴言[M],电子工业出版社,2003.11,p.201-219
3. Jeffrey Richter,Christophe Nasarre,Windows核心编程,清华大学出版社,2008
4. Microsoft. Microsoft Developer Network[DB/OL],Microsoft,2008
5. 谭文,邵坚磊. 天书夜读:从汇编语言到Windows内核编程[M], 电子工业出版社, 2008.10
6. 余英 梁刚,Visual C++实践与提高-COM和COM+篇[M], 中国铁道出版社, 2001.4
7. 看雪学院, 段钢. 加密与解密(第三版)[M], 电子工业出版社, 2008.
8. Mario Hewardt Daniel Pravat, Windows高级调试定[M],机械工业出版社, 2009.5
9. (美)Dale Rogerson,(译)杨秀章. COM技术内幕[M], 清华大学出版社, 1999.3
10. (ChrisTavares)BrentRector (美)ChrisSells. 深入解析ATL[M], 电子工业出版社, 2007
11(美)Don Box, (译)潘爱民. COM本质论[M], 中国电力出版社, 2001.8 
致谢
这篇论文能够顺利的完成,首先要感谢我的论文指导老师吕老师,以及班主任郭老师,是他们悉心的指导及无微不致的关怀才得以使论文顺利完成。
我要真诚的感谢我的妻子和可爱的女儿,论文写作期间,妻子承担了所有家务,才使得我能够专心于课题的研究。宝贝女儿天真的眼神给了我莫大的写作动力。
真诚的感谢所有参加论文评审的各位专家,感谢你们在百忙之中对我的论文给予批评指正。
最后,真的的感谢这世上,所有我爱的,和爱我的人。正是这些爱,使我在软件开发这条道路上使终坚定的自己的信念。并将为中国的软件事业尽一份绵薄之力。 
附录(术语对照表)
COM:Component Object Mode,组件对象模型。
Hook:挂钩,挂接,钩子。
API:Application Programming Interface,应用程序编程接口。
DLL:Dynamic Link Library,动态链接库。
Interface:接口,本文中特指COM指口及C++接口。 
原创声明
本论文同步发表在本人的CSDN博客上,以及看雪论坛上,以发表时间戳来保护这篇论文的原创立场。
若在互联网上或者论文库中找到与本文相似的文章且时间早于本人发表的时间,即可认定该论文非本人原创。*转载请注明来自看雪论坛@PEdiy.com 
阅读更多
想对作者说点什么? 我来说一句

COM-hook工具

2012年10月19日 351KB 下载

没有更多推荐了,返回首页

不良信息举报

Hook Com接口函数

最多只允许输入30个字

加入CSDN,享受更精准的内容推荐,与500万程序员共同成长!
关闭
关闭