伯乐共勉

讨论。NET专区
  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

屏幕取词技术实现原理与关键源码

Posted on 2007-11-30 09:00  伯乐共勉  阅读(11151)  评论(5编辑  收藏  举报
虽然屏幕取词技术早已经不是什么秘密,以至于除了汉化工具、翻译工具、中文平台等等这些东西之外,连像SnagIt这样的抓图软件也能把抓取屏幕文 本的功能做得像模像样,但金山词霸的取词技术就细节而言还是有着众多的独特之处,所以,作为在金山词霸组工作期间的一点积累,我最终还是决定把有关的一些 东西写出来,这样也作为直到2006年为止金山词霸取词技术的一个比较稳定版本的记录。

    单机版的金山词霸很难再出什么新花样了,这是在现实的环境下一个通用软件产品的生存期规律决定的,随之而来的,单机版金山词霸的结构和技术也基本不会有什 么大变动了,这其中也包括屏幕取词——虽然词霸组从05年开始就一直想对当时的屏幕取词方式进行升级以适应越来越苛刻的系统安全要求,不过后来由于种种原 因一直没有能够实施。

    金山词霸的屏幕取词技术是一种基于Win32API的,只能应用于客户端的偏底层操作技术,在这个互联网的时代,在追求注意力,追求现实效益的行业大环境 下,金山词霸的取词技术不容易再有什么比较大的发展了,短期之内其应用也仅限于一些需要此功能的小型客户端程序(如词霸豆豆)以及作为OCX插件来支持 B/S结构产品的用户体验提升。至于Windows Vista出来之后在Avalon和GDI+模式下的技术更新,则不是我现在能够预料得到的了,其可行性将在后面稍作讨论。

    好了,说了这么多废话,也该进入正题了,不过在此之前要申明的一点是:本文所涉及的所有细节技术和方法,都是行业内所共知或者从业者通过正规方式能够获知 和了解的,而宏观的思路和逻辑也是具有相当技术水平的软件开发人员通过思考能够获得的;因此本文不会侵犯到金山公司的商业机密和知识产权,也不会违反本人 与金山公司之前签署的保密协定。实际上我并不是金山词霸取词技术的主要开发人,所以即便我有心说一些什么也无法触及比较秘密的细节内容,呵呵。仅此。

    之前有不少文章来讨论或者“揭密”金山词霸的取词技术,似乎这样一种技术瞬间从神秘无比就变成了一层窗户纸,不过在接触了实际的代码之后,我想要说的是, 这是一种十分正常的软件开发技术,这样一种技术的开发、积累和完善,同许多其他技术一样也是由简而繁,从基础的思路到最终的产品一步步走过来的;那种以为 只要懂得了API Hook就了解了屏幕取词的全部技术的想法是有偏差的。

    API Hook是一种常规的核心编程技术,其基础的实现方式和思路请参照《Windows核心编程》的第22章——顺带说一下,这本书是所有触及Windows底层应用的程序开发人员应该储备的工具书之一。

    先说说屏幕取词的基本设计思路。

    对Windows编程有所了解的的人都知道,Windows为每个进程分配了2GB的虚地址空间,并使用了一系列的措施来保证每个进程各行其道,不会互相 影响——这点就比Linux要好一些,那些说Linux安全性比Windows要高的人很多时候并不知道——原则上进程间的信息交互只能由相互信任的进程 采用约定的方法——比如消息传递、共享内存、内存映射文件、Socket(Network),甚至磁盘文件系统等等;但是屏幕取词的要求本质上是要取得一 个未知进程里的某个特别操作的执行数据,那么,在没有标准方法来执行这一点的时候,我们要想办法将位置的进程编程与我们的取数进程相互信任并且已经约定好 数据交互方法的进程——目前看来比较现实的方法,或者说唯一的方法,是让目标进程执行我们设计好的代码,这样,我们的代码取得宿主进程的执行权限,并了解 如何把数据传递给我们的取词进程,如果再能够获得特定操作时的数据(例如TextOut),我们的架构就完整了。

    对于第一个需求,金山词霸的操作简单的就是几个函数的序列:WriteProcessMemory,CreateRemoteThread, ReadProcessMemory。这是我之前提到几种方法之一的变形;对于第二个需求,插入进去的代码会修改程序的运行指令,将需要获得其操作数据的 函数地址强行更改为我们自己编写的具有相同形式定义的函数,在我们的函数处理完成之后,再调用原本应该处理那些数据的函数去执行,而我们则可以通过事先约 定好的方法得到操作数据的一个副本。修改原本函数的执行地址的方法,我们称为挂接,其表现形式类似于插入一个函数调用。

    实际上这种方法很像原先在Windows 9X上使用的外壳DLL的处理方式,有一些程序出于各种目的(有些甚至是为了增强系统安全,但实际上利用了系统的不安全隐患)将系统DLL替换成自己的 DLL文件,并将原来的系统DLL改名,然后在自己的DLL文件中模拟出系统DLL的所有接口,这样程序调用系统接口的时候自然就会把数据传到新的DLL 中去,新DLL处理完成后再以同样的数据去调用那个被改了名的系统DLL中的对应接口。不过由于Win2000内核的逐渐兴起,这种方法由于适应性差,工 作量大,问题比较多而逐渐被废弃了。现在使用这个办法的程序大多只替换一些用户级的DLL库,干得一般也不是什么上得了台面的事情。

    剩下来的就是一些细枝末节的问题,但却是比较麻烦的地方。

    1、取到需要的数据。并不是所有的目标程序都使用TextOut进行文本输出,相当多的程序使用自己的缓存DC来进行文本显示,对于自绘缓存的情况,原则 上来说任何方法都不可能覆盖所有的可能,特别是对于那些带有排版、阅读甚至权限控制功能的程序。简单的对文本输出函数的挂接常常会得到多到无法筛选处理的 数据,要么就是根本监测不到函数调用。对于这种情况,无法绕开的解决办法是监视所有可能用于绘制的函数调用,并保存所有可能用于绘制的数据,然后根据目标 进程的操作来智能判断有效数据,比如在预计目标进程进行屏幕输出的时候,监测到一些内存DC的文本绘制操作,接着又监测到屏幕DC的一些BitBlt之类 的缓存覆盖操作,则要判断当前取词位置的屏幕DC被哪个内存DC所占有的缓冲区覆盖了,然后看看这个缓冲区之前曾经输出过哪些文本数据,如此等等。数据筛 选的另外一个问题是定位,知道用户的鼠标位置处于取到的数据中那一个字符之上是很重要的,是后期的单词匹配和模式分析所不可缺少的。可惜的是GDI32并 没有提供方便的方法来搞定这件事情,我们只能用一些间接的办法来实现,比如先获得字体,再执行模拟排版,这是个很麻烦的事情,对于各种字符的处理都要和 GDI32完全一致。

    2、挂接代码的执行、数据交换。由于是将代码注入到目标进程去执行,无形中就增加了许多限制。函数地址的计算是个比较大的问题,所有自定义的函数地址都要 从一个易于通过系统标准方法获得的基准地址计算偏移量来获得,调用任何一个函数的时候都要明确的意识到在目标进程执行的情况,如此等等。而且,随着对系统 安全性越来越高的要求,这种使用WriteProcessMemory进行代码注入的方式也逐渐暴露出来一些问题,例如在DEP环境下无法执行数据段代码 的问题,取词时屏幕闪烁的问题,还有某些杀毒软件对可能造成系统危险的进程间操作进行屏蔽和报警的问题。金山词霸组曾经有一段时间考虑过使用适应性更好的 DLL注入方式来替换掉挂接模块,但由于种种原因而没有实现。同时,对于一些比较复杂的数据对象,有时并不是很容易取到其内部的数据,这样就往往要辗转几 次才能迂回的完成任务,有时甚至需要修改系统文件定义才能取到Private成员这样的东西。

    3、现场清理、与其它挂接的兼容性。对于挂接API这样一种搭车行为,做完要做的事情之后最好是能够不留痕迹的清理好现场,这既是出于系统执行效率和资源 消耗的考虑,也是为了系统安全的目的,用于挂接和数据传递的代码区域在使用完成之后应该进行资源释放,对于执行失败甚至异常的操作也应该有相对稳妥的办法 去把垃圾代码清除掉。金山词霸挂接了一个不常用的函数作为自身挂接状态的标记,除了每次挂接任务完成后要执行自身清理之外,每次挂接前还要检查一下这个标 记来确定是否有未解除成功的以前的挂接,并根据需要执行清理。对于其它进程同时进行挂接的情况,如果不加判断直接将系统API挂接地址修改为自身的函数入 口地址,则另外的挂接程序就可能发生不可预知的执行问题。实际开发中发现东方快车、中文之星这样的软件在遇到挂接冲突时的确会发生问题。因此比较柔和的办 法是等待其它挂接程序先摘除自身的挂接,再执行我们的操作,同时还要保证我们的挂接代码被其它程序强行拆掉之后不给目标进程造成不良影响,且能被再次挂接 的操作识别从而完成清理。

    4、特殊的目标。一些对绘制任务执行了比较复杂处理的软件,比如Acrobat、Word、IE等等,如果使用基本的API Hook方法会使出错崩溃的机会大大增加,而且由于其不公开的执行逻辑和复杂的处理方式,使得针对其进行的调试工作难于进行,不过好在它们大多数提供了另 外的方法来完成我们的任务,我们可以将这些方法以插件的方式集成到取词的模块当中。比如Acrobat的SDK就提供了获取正在显示文档某区域文本的功 能,Word支持的Automation则允许在取词插件被启用的时候向外部进程暴露出一部分数据,IE则直接支持了获取显示窗口的Document;比 较有趣的是Apabi,它的开发人员发现词霸没有为其独立制作可用的取词插件(实际上是没办法),就在每次自己进行绘制缓存输出的时候,调用了一次空的 TextOut方法,用来配合金山词霸的取词方式,哈。这里还想顺带说一下触发目标进程重绘屏幕的方法,正常情况下我们会用一个透明窗口把用户鼠标焦点附 近挡一下,这样Windows就会自动给目标窗口发一个区域无效的消息提醒目标进程重新绘制被遮挡的部分;但仅仅是这样的话会有不少软件和你闹别扭,比如 大名鼎鼎的QQ,在某些版本里那个家伙被一个透明窗口挡住都会出现一片白色的未正常绘制的区域,而且根本不会自己重绘,对于这样的问题,呵呵,只能具体问 题具体分析了。

    5、未来可行性。前面提到了Avalon和GDI+,这些新出来的东西是金山词霸在最初开发时没有考虑的。GDI+的问题已经解决了,毕竟它目前还是运行 在Win32平台上的,通过分析它的Flat API和更底层的非文档接口,我们用同样的方法解决了取词问题,甚至,由于GDI+提供了方便的计算字符位置的方法,获取用户鼠标焦点位置字符的方法也变 得容易了许多。有限的一点感慨就是:没有文档的接口还真是不容易用啊。Avalon现在被设计为与GDI+平级的一个显示层接口,由于集成了2D和3D显 示接口,其内部结构目前看来是相当的复杂,但是由于其仍然支持Win32平台,并且考虑到目前的3D设备在系统中的位置,个人认为Avalon的2D部分 的API Hook取词也有着相当的可行性。实际上金山游侠也是金山词霸组的产品,所以我们当初考虑DirectX方式下的取词和显示也是可行的,不过由于其实现成 本比较高,预期效益也并不大,就没有做。WinFX我没有太深的研究,更深的细节现在还没法说,嘿嘿。还有一个麻烦的事情是Java,Java的桌面程序 总让人感觉不伦不类,分析下来在Windows平台下它有一部分文本输出是调用了一些W非文档的函数,而有一些则是使用自带字库进行绘制——对于后者,虽 然不能说是一点办法没有,但实际的商业价值似乎不大,只是不知道在Windows Vista上的Java虚拟机会怎么做。最后一个潜在的问题是移动设备,受用户输入方式和系统资源的限制目前对屏幕取词的需求还不是很强烈,但在可以预见 的未来,还是有一些苗头的。

    6、后记。从现在的趋势看来,即便Windows Vista给我们提供了更加丰富的接口功能,更高效的应用软件开发模式,更强悍的界面表现方式,更方便的数据通信和沟通方式,B/S的大潮仍然无法阻挡 的,这甚至代表了整个软件生产和使用的趋势,Browser功能的不断扩充与其说是网络应用进化的必然,不如说是埋下了系统表示层浏览器化的伏笔,而微软 在Windows Vista上所作的一切也使我多少嗅到了这样的味道——如果这是真的,那么金山词霸取词技术现在这个样子,则有可能随着不可挽回的Win32落潮而成为这 个时代的终篇之一。

    能想起来的都说了,再想起来什么的话,再改吧,呵呵。

 

在金山词霸中2005中带了一个XdictGrb.dll,添加引用

废话不多说了,还是把源码放上

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Text;
using System.Windows.Forms;
using XDICTGRB;//金山词霸组件

namespace WindowsApplication1
{
public partial class Form1 : Form,IXDictGrabSink
{
public Form1()
{
InitializeComponent();
}
private void Form1_Load(object sender, EventArgs e)
{
GrabProxy gp = new GrabProxy();
gp.GrabInterval = 1;//指抓取时间间隔
gp.GrabMode = XDictGrabModeEnum.XDictGrabMouse;//设定取词的属性
gp.GrabEnabled = true;//是否取词的属性
gp.AdviseGrab(this);
}
//接口的实现
int IXDictGrabSink.QueryWord(string WordString, int lCursorX, int lCursorY, string SentenceString, ref int lLoc, ref int lStart)
{
this.textBox1.Text = SentenceString;//鼠标所在语句
//this.textBox1.Text = SentenceString.Substring(lLoc + 1,1);//鼠标所在字符
return 1;
}
}
}

B.Nhw32.dll法

这个是C++写的一个组件

nhw32.dll 主要引出两个函数:

1. DWORD WINAPI BL_SetFlag32(UINT nFlag,
HWND hNotifyWnd,
int MouseX,
int MouseY)
功能:
启动或停止取词。
参数:
nFlag
[输入] 指定下列值之一:
GETWORD_ENABLE: 开始取词。在重画被取单词区域前设置此标志。nhw32.dll是通过
重画单词区域,截取TextOutA, TextOutW, ExtTextOutA,
ExtTextOutW等Windows API函数的参数来取词的。
GETWORD_DISABLE: 停止取词。
hNotifyWnd
[输入] 通知窗口句柄。当取到此时,向该通知窗口发送一登记消息:GWMSG_GETWORDOK。
MouseX
[输入] 指定取词点的X坐标。
MouseY
[输入] 指定取词点的Y坐标。
返回值:
可忽略。
2. DWORD WINAPI BL_GetText32(LPSTR lpszCurWord,
int nBufferSize,
LPRECT lpWordRect)
功能:
从 内部缓冲区取出单词文本串。对英语文本,该函数最长取出一行内以空格为界的三个英文单词串,遇空格,非英文字母及除‘-’外的标点符号,则终止取词。对汉 字文本,该函数最长取出一行汉字串,遇英语字母,标点符号等非汉语字符,则终止取词。该函数不能同时取出英语和汉语字符。
参数:
lpszCurWord
[输入] 目的缓冲区指针。
nBufferSize
[输入] 目的缓冲区大小。
lpWordRect
[输出] 指向 RECT 结构的指针。该结构定义了被取单词所在矩形区域。
返回值:
当前光标在全部词中的位置。

此外,WinNT/2000版 nhw32.dll 还引出另两个函数:

1. BOOL WINAPI SetNHW32()
功能:
Win NT/2000 环境下的初始化函数。一般在程序开始时,调用一次。
参数:
无。
返回值:
如果成功 TRUE ,失败 FALSE 。

2. BOOL WINAPI ResetNHW32()
功能:
Win NT/2000 环境下的去初始化函数。一般在程序结束时调用。
参数:
无。
返回值:
如果成功 TRUE ,失败 FALSE 。

 

 


"鼠标屏幕取词"技术是在电子字典中得到广泛地应用的,如四通利方和金山词霸等软件,这个技术看似简单,其实在windows系统中实现却是非常复杂的,总的来说有两种实现方式:
第一种:采用截获对部分gdi的api调用来实现,如textout,textouta等。
第二种:对每个设备上下文(dc)做一分copy,并跟踪所有修改上下文(dc)的操作。
第 二种方法更强大,但兼容性不好,而第一种方法使用的截获windowsapi的调用,这项技术的强大可能远远超出了您的想象,毫不夸张的说,利用 windowsapi拦截技术,你可以改造整个操作系统,事实上很多外挂式windows中文平台就是这么实现的!而这项技术也正是这篇文章的主题。
截windowsapi的调用,具体的说来也可以分为两种方法:
第一种方法通过直接改写winapi 在内存中的映像,嵌入汇编代码,使之被调用时跳转到指定的地址运行来截获;第二种方法则改写iat(import address table输入地址表),重定向winapi函数的调用来实现对winapi的截获。
第 一种方法的实现较为繁琐,而且在win95、98下面更有难度,这是因为虽然微软说win16的api只是为了兼容性才保留下来,程序员应该尽可能地调用 32位的api,实际上根本就不是这样!win 9x内部的大部分32位api经过变换调用了同名的16位api,也就是说我们需要在拦截的函数中嵌入16位汇编代码!
我们将要介绍的是第二 种拦截方法,这种方法在win95、98和nt下面运行都比较稳定,兼容性较好。由于需要用到关于windows虚拟内存的管理、打破进程边界墙、向应用 程序的进程空间中注入代码、pe(portable executable)文件格式和iat(输入地址表)等较底层的知识,所以我们先对涉及到的这些知识大概地做一个介绍,最后会给出拦截部分的关键代码。
先说windows虚拟内存的管理。windows9x给每一个进程分配了4gb的地址空间,对于nt来说,这个数字是2gb,系统保留了2gb 到 4gb之间的地址空间禁止进程访问,而在win9x中,2gb到4gb这部分虚拟地址空间实际上是由所有的win32进程所共享的,这部分地址空间加载了 共享win32 dll、内存映射文件和vxd、内存管理器和文件系统码,win9x中这部分对于每一个进程都是可见的,这也是win9x操作系统不够健壮的原因。 win9x中为16位操作系统保留了0到4mb的地址空间,而在4mb到2gb之间也就是win32进程私有的地址空间,由于 每个进程的地址空间都是相对独立的,也就是说,如果程序想截获其它进程中的api调用,就必须打破进程边界墙,向其它的进程中注入截获api调用的代码, 这项工作我们交给钩子函数(setwindowshookex)来完成,关于如何创建一个包含系统钩子的动态链接库,《电脑高手杂志》在第?期已经有过专 题介绍了,这里就不赘述了。所有系统钩子的函数必须要在动态库里,这样的话,当进程隐式或显式调用一个动态库里的函数时,系统会把这个动态库映射到这个进 程的虚拟地址空间里,这使得dll成为进程的一部分,以这个进程的身份执行,使用这个进程的堆栈,也就是说动态链接库中的代码被钩子函数注入了其它gui 进程的地址空间(非gui进程,钩子函数就无能为力了),当包含钩子的dll注入其它进程后,就可以取得映射到这个进程虚拟内存里的各个模块(exe和 dll)的基地址,如:hmodule hmodule=getmodulehandle("mypro.exe");在mfc程序中,我们可以用afxgetinstancehandle() 函数来得到模块的基地址。exe和dll被映射到虚拟内存空间的什么地方是由它们的基地址决定的。它们的基地址是在链接时由链接器决定的。当你新建一个 win32工程时,vc++链接器使用缺省的基地址0x00400000。可以通过链接器的base选项改变模块的基地址。exe通常被映射到虚拟内存的 0x00400000处,dll也随之有不同的基地址,通常被映射到不同进程的相同的虚拟地址空间处。
系统将exe和dll原封不动映射到虚 拟内存空间中,它们在内存中的结构与磁盘上的静态文件结构是一样的。即pe (portable executable) 文件格式。我们得到了进程模块的基地址以后,就可以根据pe文件的格式穷举这个模块的image_import_descriptor数组,看看进程空间 中是否引入了我们需要截获的函数所在的动态链接库,比如需要截获"textouta",就必须检查"gdi32.dll"是否被引入了。说到这里,我们有 必要介绍一下pe文件的格式,如右图,这是pe文件格式的大致框图,最前面是文件头,我们不必理会,从pe file optional header后面开始,就是文件中各个段的说明,说明后面才是真正的段数据,而实际上我们关心的只有一个段,那就是".idata"段,这个段中包含了所 有的引入函数信息,还有iat(import address table)的rva(relative virtual address)地址。
说 到这里,截获windowsapi的整个原理就要真相大白了。实际上所有进程对给定的api函数的调用总是通过pe文件的一个地方来转移的,这就是一个该 模块(可以是exe或dll)的".idata"段中的iat输入地址表(import address table)。在那里有所有本模块调用的其它dll的函数名及地址。对其它dll的函数调用实际上只是跳转到输入地址表,由输入地址表再跳转到dll真正 的函数入口。
具体来说,我们将通过image_import_descriptor数组来访问".idata"段中引入的dll的信息,然后 通过image_thunk_data数组来针对一个被引入的dll访问该dll中被引入的每个函数的信息,找到我们需要截获的函数的跳转地址,然后改成 我们自己的函数的地址……具体的做法在后面的关键代码中会有详细的讲解。
讲了这么多原理,现在让我们回到"鼠标屏幕取词"的专题上来。除了api函数的截获,要实现"鼠标屏幕取词",还需要做一些其它的工作,简单的说来,可以把一个完整的取词过程归纳成以下几个步骤:
1. 安装鼠标钩子,通过钩子函数获得鼠标消息。
使用到的api函数:setwindowshookex
2. 得到鼠标的当前位置,向鼠标下的窗口发重画消息,让它调用系统函数重画窗口。
使用到的api函数:windowfrompoint,screentoclient,invalidaterect
3. 截获对系统函数的调用,取得参数,也就是我们要取的词。
对于大多数的windows应用程序来说,如果要取词,我们需要截获的是"gdi32.dll"中的"textouta"函数。
我们先仿照textouta函数写一个自己的mytextouta函数,如:
bool winapi mytextouta(hdc hdc, int nxstart, int nystart, lpcstr lpszstring,int cbstring)
{
// 这里进行输出lpszstring的处理
// 然后调用正版的textouta函数
}
把这个函数放在安装了钩子的动态连接库中,然后调用我们最后给出的hookimportfunction函数来截获进程对textouta函数的调用,跳转到我们的mytextouta函数,完成对输出字符串的捕捉。hookimportfunction的用法:
hookfuncdesc hd;
proc porigfuns;
hd.szfunc="textouta";
hd.pproc=(proc)mytextouta;
hookimportfunction (afxgetinstancehandle(),"gdi32.dll",&hd,porigfuns);
下面给出了hookimportfunction的源代码,相信详尽的注释一定不会让您觉得理解截获到底是怎么实现的很难,ok,let s go:
///////////////////////////////////////////// begin ///////////////////////////////////////////////////////////////
#include <crtdbg.h>
// 这里定义了一个产生指针的宏
#define makeptr(cast, ptr, addvalue) (cast)((dword)(ptr)+(dword)(addvalue))
// 定义了hookfuncdesc结构,我们用这个结构作为参数传给hookimportfunction函数
typedef struct tag_hookfuncdesc
{
lpcstr szfunc; // the name of the function to hook.
proc pproc; // the procedure to blast in.
} hookfuncdesc , * lphookfuncdesc;
// 这个函数监测当前系统是否是windownt
bool isnt();
// 这个函数得到hmodule -- 即我们需要截获的函数所在的dll模块的引入描述符(import descriptor)
pimage_import_descriptor getnamedimportdescriptor(hmodule hmodule, lpcstr szimportmodule);
// 我们的主函数
bool hookimportfunction(hmodule hmodule, lpcstr szimportmodule,
lphookfuncdesc pahookfunc, proc* paorigfuncs)
{
/////////////////////// 下面的代码检测参数的有效性 ////////////////////////////
_assert(szimportmodule);
_assert(!isbadreadptr(pahookfunc, sizeof(hookfuncdesc)));
#ifdef _debug
if (paorigfuncs) _assert(!isbadwriteptr(paorigfuncs, sizeof(proc)));
_assert(pahookfunc.szfunc);
_assert(*pahookfunc.szfunc != \0 );
_assert(!isbadcodeptr(pahookfunc.pproc));
#endif
if ((szimportmodule == null) || (isbadreadptr(pahookfunc, sizeof(hookfuncdesc))))
{
_assert(false);
setlasterrorex(error_invalid_parameter, sle_error);
return false;
}
//////////////////////////////////////////////////////////////////////////////
// 监测当前模块是否是在2gb虚拟内存空间之上
// 这部分的地址内存是属于win32进程共享的
if (!isnt() && ((dword)hmodule >= 0x80000000))
{
_assert(false);
setlasterrorex(error_invalid_handle, sle_error);
return false;
}
// 清零
if (paorigfuncs) memset(paorigfuncs, null, sizeof(proc));
// 调用getnamedimportdescriptor()函数,来得到hmodule -- 即我们需要
// 截获的函数所在的dll模块的引入描述符(import descriptor)
pimage_import_descriptor pimportdesc = getnamedimportdescriptor(hmodule, szimportmodule);
if (pimportdesc == null)
return false; // 若为空,则模块未被当前进程所引入
// 从dll模块中得到原始的thunk信息,因为pimportdesc->firstthunk数组中的原始信息已经
// 在应用程序引入该dll时覆盖上了所有的引入信息,所以我们需要通过取得pimportdesc->originalfirstthunk
// 指针来访问引入函数名等信息
pimage_thunk_data porigthunk = makeptr(pimage_thunk_data, hmodule,
pimportdesc->originalfirstthunk);
// 从pimportdesc->firstthunk得到image_thunk_data数组的指针,由于这里在dll被引入时已经填充了
// 所有的引入信息,所以真正的截获实际上正是在这里进行的
pimage_thunk_data prealthunk = makeptr(pimage_thunk_data, hmodule, pimportdesc->firstthunk);
// 穷举image_thunk_data数组,寻找我们需要截获的函数,这是最关键的部分!
while (porigthunk->u1.function)
{
// 只寻找那些按函数名而不是序号引入的函数
if (image_ordinal_flag != (porigthunk->u1.ordinal & image_ordinal_flag))
{
// 得到引入函数的函数名
pimage_import_by_name pbyname = makeptr(pimage_import_by_name, hmodule,
porigthunk->u1.addressofdata);
// 如果函数名以null开始,跳过,继续下一个函数
if ( \0 == pbyname->name[0])
continue;
// bdohook用来检查是否截获成功
bool bdohook = false;
// 检查是否当前函数是我们需要截获的函数
if ((pahookfunc.szfunc[0] == pbyname->name[0]) &&
(strcmpi(pahookfunc.szfunc, (char*)pbyname->name) == 0))
{
// 找到了!
if (pahookfunc.pproc)
bdohook = true;
}
if (bdohook)
{
// 我们已经找到了所要截获的函数,那么就开始动手吧
// 首先要做的是改变这一块虚拟内存的内存保护状态,让我们可以自由存取
memory_basic_information mbi_thunk;
virtualquery(prealthunk, &mbi_thunk, sizeof(memory_basic_information));
_assert(virtualprotect(mbi_thunk.baseaddress, mbi_thunk.regionsize,
page_readwrite, &mbi_thunk.protect));
// 保存我们所要截获的函数的正确跳转地址
if (paorigfuncs)
paorigfuncs = (proc)prealthunk->u1.function;
// 将image_thunk_data数组中的函数跳转地址改写为我们自己的函数地址!
// 以后所有进程对这个系统函数的所有调用都将成为对我们自己编写的函数的调用
prealthunk->u1.function = (pdword)pahookfunc.pproc;
// 操作完毕!将这一块虚拟内存改回原来的保护状态
dword dwoldprotect;
_assert(virtualprotect(mbi_thunk.baseaddress, mbi_thunk.regionsize,
mbi_thunk.protect, &dwoldprotect));
setlasterror(error_success);
return true;
}
}
// 访问image_thunk_data数组中的下一个元素
porigthunk++;
prealthunk++;
}
return true;
}
// getnamedimportdescriptor函数的实现
pimage_import_descriptor getnamedimportdescriptor(hmodule hmodule, lpcstr szimportmodule)
{
// 检测参数
_assert(szimportmodule);
_assert(hmodule);
if ((szimportmodule == null) || (hmodule == null))
{
_assert(false);
setlasterrorex(error_invalid_parameter, sle_error);
return null;
}
// 得到dos文件头
pimage_dos_header pdosheader = (pimage_dos_header) hmodule;
// 检测是否mz文件头
if (isbadreadptr(pdosheader, sizeof(image_dos_header)) ||
(pdosheader->e_magic != image_dos_signature))
{
_assert(false);
setlasterrorex(error_invalid_parameter, sle_error);
return null;
}
// 取得pe文件头
pimage_nt_headers pntheader = makeptr(pimage_nt_headers, pdosheader, pdosheader->e_lfanew);
// 检测是否pe映像文件
if (isbadreadptr(pntheader, sizeof(image_nt_headers)) ||
(pntheader->signature != image_nt_signature))
{
_assert(false);
setlasterrorex(error_invalid_parameter, sle_error);
return null;
}
// 检查pe文件的引入段(即 .idata section)
if (pntheader->optionalheader.datadirectory[image_directory_entry_import].virtualaddress == 0)
return null;
// 得到引入段(即 .idata section)的指针
pimage_import_descriptor pimportdesc = makeptr(pimage_import_descriptor, pdosheader,
pntheader->optionalheader.datadirectory[image_directory_entry_import].virtualaddress);
// 穷举pimage_import_descriptor数组寻找我们需要截获的函数所在的模块
while (pimportdesc->name)
{
pstr szcurrmod = makeptr(pstr, pdosheader, pimportdesc->name);
if (stricmp(szcurrmod, szimportmodule) == 0)
break; // 找到!中断循环
// 下一个元素
pimportdesc++;
}
// 如果没有找到,说明我们寻找的模块没有被当前的进程所引入!
if (pimportdesc->name == null)
return null;
// 返回函数所找到的模块描述符(import descriptor)
return pimportdesc;
}
// isnt()函数的实现
bool isnt()
{
osversioninfo stosvi;
memset(&stosvi, null, sizeof(osversioninfo));
stosvi.dwosversioninfosize = sizeof(osversioninfo);
bool bret = getversionex(&stosvi);
_assert(true == bret);
if (false == bret) return false;
return (ver_platform_win32_nt == stosvi.dwplatformid);
}
/////////////////////////////////////////////// end //////////////////////////////////////////////////////////////////////
不 知道在这篇文章问世之前,有多少朋友尝试过去实现"鼠标屏幕取词"这项充满了挑战的技术,也只有尝试过的朋友才能体会到其间的不易,尤其在探索api函数 的截获时,手头的几篇资料没有一篇是涉及到关键代码的,重要的地方都是一笔代过,msdn更是显得苍白而无力,也不知道除了 image_import_descriptor和image_thunk_data,微软还隐藏了多少秘密,好在硬着头皮还是把它给攻克了,希望这篇文 章对大家能有所帮助。