花了一点精力,做了如下 QQ2010 版本的登录过程演示工具,主要是用于演示如何截取用户登录时的输入。此工具的原理比较基本,主要是利用全局钩子截获用户的输入。主要是演示作用,因此我又做了一个很直观的界面,可以看到截获输入的过程。其用户界面如下所示:

    此图中左侧是我做的工具(由于我对腾讯灌输的价值观一向鄙视,所以QQ的LOGO被我替换成了“狗日的腾讯”图标),右侧是真正的QQ2010 SP1(1760)的登录对话框。当用户在QQ的登录对话框进行登录时,其输入将会从我的工具上直观的反应出来。

    同样的,为了做到这个步骤,首先必须要找到QQ的登录窗口,因此必须对 QQ 的登录窗口在 UI 上进行分析。使用我用C#开发的“桌面窗口查看器”工具(这个工具相比SPY++的主要优点是,在列出线程创建的窗口时,可以看出窗口的父子关系),可以看到 QQ SP1 的登陆对话框的窗口关系和信息如下图所示:

    在上图中,高亮显示的节点是QQ登录对话框窗口,下面两个子窗口分别是QQ号码和QQ密码。除此以外没有任何其他子窗口(因为QQ采取了DirectUI技术)。

    (一)使用方面的说明:

    (1)QQ版本:由于我修改了代码,对 UI 特征的校验标准放宽。所以可以适合所有较新版本的QQ,TM(例如2009版本以上)    

    (2)要截取用户的登录,第一个问题是必须定位到用户登录过程,即及时并且准确的找到 QQ 的登录对话框。

    这通常是通过研究 UI 特征来做到,UI 特征又是依赖QQ版本的。UI 特征是不固定的,会随着程序进化而发生变化,开发者通常不去注意这些问题,但某些人基于一些特别目的会去观察它,QQ不会为了这些人的方便而保持它的UI稳定。

    寻找QQ登录窗口的逻辑是一个很重要的部分,要把它写的高效(以免降低系统性能),又要准确可靠(套用继电保护的专业术语,即对非QQ登录窗口不误动,对QQ登录窗口不拒动)。最简单的方法当然是以一个较高频率调用 FindWindow 按照类名和窗口标题去搜索登录框,但这样显得比较盲目,而且对 UI 特征的依赖性太强,必须跟随QQ的官方发布版本更新,也必须区分是登录窗口还是已经登录后的QQ主窗口(两者类名和窗口名称是一样的)。这里我为了提高检测 QQ 登录窗口的效率,没有安装一个间隔较短的定时器去定期扫描,我使用一个 CBT 钩子去完成。同时通过对多个版本的UI特征的分析,对代码进行了进一步调整,使该程序不会严重依赖 UI 特征,这是通过总结多个版本的UI特征的规律来实现的。。

    由于采用 CBT 钩子,因此潜在要求是工具先于 QQ 启动,然后可以发现在之后创建的 QQ 登录窗口,如果用 FindWindow 则不会受此限制。

     

    (3)关于该工具的界面,如下图所示。

    下面的图片是1.0版本的,我已经升级到了1.1版本,主要变化是在界面上增加了一个文本框显示发现的QQ版本,因为我已经支持了更多 QQ 版本(所以这篇文章的标题也已经改了好几次)。我还一直在考虑将在密码框的下方绘制一个模拟的“caret”(字符插入位置标记),意义不大,只是让工具效果更漂亮一点,现在还没做。

    当发现QQ登录窗口以后,该对话框就开始监视QQ在号码框和密码框的按键操作,在外观上可以看到QQ上的输入和工具左上角的显示是同步性的。下方显示了工具收到的最近的按键事件(WM_KEYUP / WM_KEYDOWN),这个区域只对输入到QQ号码和密码框的按键感兴趣,对其他按键操作不关心。virtualCode显示的是16进制的虚拟键码,另外还有一个和硬件相关的键扫描码(scanCode),MSDN指出应用程序不应该在逻辑中依赖扫描码,所以这里没有展示它的值。printChar是指如果该按键是可打印字符(isprint),则显示出字符(例如按下A键,中间的框则显示'A')。VK_NAME是指该 virtualCode 在 winuser.h 中被定义的宏的字符串表示,例如按下向左的方向键,会显示出“VK_LEFT”。

    Shift,Ctrl 指示灯属于最近按键信息,表示最近一次按键发生时,这两个键是否被同时按下,如果有,则相应指示灯被点亮。比如用户输入了一个大写字母,使 Shift 指示灯被点亮,则它会一直保持着点亮状态(因为该指示灯属于最近按键的信息,而不是Shift 键的当前按下/抬起状态),直到此后的按键改变它的状态为止,因此这两个指示灯你可以把它理解成可以打钩的 CheckBox 控件。注意,其他两个指示灯 NumLock 和 CapsLock 则不同,它们属于实时指示灯,反应的是当前键盘状态,即在QQ登录窗口被发现期间,这两个指示灯看起来就好像是你的键盘上的实际指示灯一样,例如当你反复按 NumLock 按键,窗口上相应的指示灯将和键盘上的实际指示灯一起闪烁。 

    注意:按键信息区和相关指示灯只有在发现QQ登录窗口和该窗口存在的期间有效。

    (4)对于记住密码,自动登录的登录过程,本工具无效(只能记录到QQ号码)。原因很简单,因为这种登录用户无须使用键盘输入密码。

  

    (二)技术方面的说明:

    由于涉及的内容关系,因此我不打算贴出代码和讲解细节(虽然我很希望像往常的文章那样,贴出核心部分的代码,展示所有重要和有趣的技术细节)。在这里简要讲一点点技术方面的内容。首先了解一下我们位于什么位置,下图是MSDN中讲解键盘输入的章节(参见参考资料)中的一个键盘输入模型,我在原图片的基础上少许添加了一些内容(图中的彩色部分),以显示工具所在的位置。如下图所示:

    演示工具即图中标记为“HookThread”的蓝色方框部分,因为根据MSDN的描述,底层钩子并不注入目标线程所在的进程,而是先切换到HOOK所在线程,然后再切换回目标线程。假设把按键事件的通信流向看作一个河流,则“上游”是硬件和驱动程序(此处应该是硬件抽象层 HAL 的范围所在),然后是系统RIT(Raw Input Thread,原始输入线程),我们则位于 Hook Chain 所在的中游位置,最后是传达至目标线程,即 QQ UI Thread,在这里,QQ 线程被唤醒,其消息循环翻译并分发消息,引发对密码框的窗口过程的调用,该事件/消息处理结束。在这个处理过程中,注意以下两点结论:

    (a)在用户级上,应用程序(QQ)无法感知上游 Hook 的存在,这是本文所演示技术可行性的理论基础。

    (b)在 Hook 位置,Hook 无法区分消息是来源于硬件输入(硬件驱动程序),还是模拟输入。(注:可能是QQ干扰消息的理论基础)

    应用程序用 keybd_event,SendInput 可以产生模拟的硬件输入,按照MSDN文档的描述,模拟输入在图上应位于和键盘驱动相同位置,即产生的消息在 Hook Chain 的“上游位置”直接注入到系统消息队列,这样 Hook Chain 就无法区分消息的来源。如果我没有猜错,QQ的自我保护手段之一应该是用模拟输入的方法去生成对 Hook Chain 的干扰消息。登记 [ PostMessage ] 和 发送 [SendMessage] 消息不能影响输入消息(三种消息被添加到目标线程的三个不同的 message list,细节请参考《Windows 编程器启示录》中介绍消息派送和收取的章节),所以应用程序无法用 Send /Post Message 方法干扰针对输入的HOOK。

    (1)两个钩子,CBT是长期安装的(等于该演示工具的生命周期),键盘钩子是短暂的(等于QQ的登录框寿命)。

    当QQ登录框被发现以后,工具上会显示出QQ登录框的主要窗口的句柄,同时我也在此时安装一个底层(LOW LEVEL)键盘钩子,当且仅当键盘钩子装好之后,工具上的 CapsLock 和 NumLock 指示灯才能实时的反应用户的按键动作(否则这两个指示灯只能停留在工具刚启动时获得的状态,不能跟踪键盘的实时状态)。当QQ登录框销毁后,我们就可以把数据记录作为结果保存到日志里(如果勾选了记录日志选项),这时并没有去判断登录结果,即登录是成功还是失败的,还是仅仅点击了标题栏上的关闭按钮。

    为了避免输入法切换的热键失效,CBT从长期连接改为断续性的连接(每个成功登陆的QQ将使CBT被短暂卸载),参见后面的第(5)点。

    (2)必须先过滤QQ产生的干扰。

    如果我们装好键盘钩子,并且也准确的“对准了目标”以后,但实际上并不能得到用户输入(比如输入密码时你没有得到任何通知,或者你收到了一些错误的按键消息)。因为QQ具有干扰机制,而且QQ还会在用户的合法按键后再立即对自己产生另一个不相关的按键输入。总之在把用户输入传回到我的工具窗口(hwnd_owner) 之前,必须区分它是真正的按键消息,还是QQ产生的虚假干扰消息,然后所有干扰过滤掉,这样被通知的窗口拿到的就是纯净的真正的用户输入。

    此处的技术细节不做解释,但是可以从逻辑角度考虑这件事,QQ希望别人不能拦截他的密码,因此主动产生干扰,但它自己也要对收到的干扰进行过滤!这样就进入一个很奇妙的近乎悖论:如果干扰和真正的输入完全无法区分,那么其他程序就完全没有能力截取密码,但是这样QQ自己也无法接收到用户输入的内容!如果QQ自己要能够区分,它就必须指定一个区分的标准,这样其他程序就能够 研究/分析/学习/跟随 QQ的做法,导致QQ的干扰变得没有意义,仅剩下浪费系统资源的作用。

    得到过滤了QQ干扰的信息以后,由于拿到的是底层键盘事件,所以这里我又做了和 TranslateMessage 类似的事情,即把原始键盘事件翻译成对字符串的操作。由于底层键盘事件比较繁杂,一般在编程时我们都工作在系统翻译后的结果层上,而这里我是直接处理更接近硬件的事件(比如不同国家文化,不同硬件厂商的键盘都有所不同)。在工具里可以直观看到底层产生的键盘事件(即键盘上的哪一个按键被按下等等),在工具密码框里则是翻译后的处理结果。

    (3)创建窗口的顺序:

    在CBT钩子里,如果我们直接发现了对 QQ 登录框的创建,注意这时其子控件尚未创建!所以如果试图用 FindWindowEx 去获取其子控件将什么也得不到。这是使用CBT钩子的一个非常不方便的地方,这和常规的处理思维很不同,例如通常你得到主窗口句柄时窗口都是处于一种完好待命的状态,而在CBT发送通知时,它还什么都没有准备好!也就是说,当系统通知你某个窗口已被创建时,你不能去访问此窗口的任何子控件!为此你不得不在代码上花费更多精力才能处理好这个问题。根据MSDN的描述,你可以在窗口“出生”的时刻修改窗口的位置,大小等信息(不过对本文涉及的需求,这些功能意义不大)。

    QQ登录框创建窗口的顺序体现为:QQ登录对话框主窗口 -> QQ密码框 -> QQ号码框。

    TM登录框创建窗口的顺序体现为:TM登录对话框主窗口 -> TM号码框 -> TM密码框。(我仅观测了TM2009的某个版本)

    顺便说一句,由于检测逻辑的关系,QQ 的窗口创建顺序对我的检测来说非常简便,代码也很简洁。而 TM 仅仅将子控件创建顺序稍微颠倒了下,我就不得不在代码里做了更多的工作以适应它。由于我兼顾了TM,所以现在无论密码框和号码框的创建顺序如何,工具代码都能很好的将其发现。

    (4)如何把信息发送回我们的演示工具主窗口。

    我们可以把所有事情放在DLL中无声无息的去做,还可以把DLL注入到另一个进程中。但这里为了演示,我希望把一些事件以消息的方式发送回我的演示工具,为此我需要把我的工具对话框的窗口句柄通知给DLL。看起来这应该很简单,然而这一步有些让我感到意外。因为我在安装钩子时把自己的工具窗口句柄设置给DLL里的一个全局变量,但是当钩子收到感兴趣的事件通知时,工具窗口的句柄居然不知道什么时候已经变为NULL。这一点着实让我费解了半天,后来我想也许是因为在安装全局钩子时,系统把DLL重新映射(加载)了,导致原来的全局数据全部被清零。或许我应该在 DllMain 里再写一些代码,跟踪一下DLL的事件看看是不是这回事。

    为了能够把窗口句柄完善的传达到DLL,我考虑了几种方案,例如用 ini 配置文件或者临时创建一个注册表键用于传递信息,在工具退出时,再清理掉。但这样感觉不够绿色,所以我最终采用的方法是使用共享内存,在DLL和宿主之间相互传递信息。这里DLL不能稳定的持有数据,但是宿主进程则可以稳定的持有数据,因此我在宿主进程中创建共享内存,在DLL中使用内存映射,这样尽管DLL可能重映射,但共享内存对DLL来说始终都是有效的。

    (5)已登录QQ的 Ctrl + Space 热键失效?

    这也是一个比较奇怪的问题,如果某个QQ是在工具“监视”下登录成功的,那么我发现在这个QQ里打开聊天窗口时,按 Ctrl + Space 只能输出空格,而不是切换输入法。这让我感到很郁闷,具体原因我还不知道到底是CBT钩子问题呢还是密码框有什么问题呢。因为我发现在QQ的密码输入框中,这个热键也是失效的,在登录成功后,QQ可能做了一些处理,由于工具和QQ框两者互为干扰,且都试图排除对方的干扰,因此也可能是因为我们对登录框的一些动作无形中禁用了QQ登录中的一些有用动作。我发现只要把工具关闭(卸载掉CBT钩子)则热键就恢复正常。因此我修改了演示工具的代码,使一个QQ成功登录以后就卸载掉CBT钩子,然后等待一会(2~3秒钟)再重新安装CBT钩子,这样已登录的QQ就不会感觉到有任何异常。

    通过断续性的安装解决了这个问题(这和我在工作中遇到的某个故障很类似,也是从长连接改为断续性连接去解决的),但是引发热键失效这个问题的具体原因还不甚清楚。这个问题可能是我无意中影响了QQ的登录过程中的必要动作导致的。

    (6)鼠标点击时可能更改插入符的位置。

    鼠标在登录过程中的作用分析如下:

    (6.1)对于密码框,鼠标能通过点击去改变光标插入符的位置,这样如果用户用鼠标改变插入符位置,如果没有去监视这一行为,这会导致密码框中记录的文本可能产生错误。

    (6.2)对于软键盘,监视鼠标的另一个需求是QQ的软键盘,如果用户使用软键盘输入密码,由于不通过按键事件,所以这时键盘钩子是得不到信息的,因此这时我们需要检测鼠标行为。比较简单的方法是,如果用户点击软键盘,我们就对软键盘窗口截图,如果我们能够用图像数据分析提取出每个位置的字符,那样是最好的,如果为了简单,只要把软键盘图片和用户点击的坐标数据一起打包回来再分析即可。

    对于软键盘,另一种方法类似游戏外挂的方法,从编程角度考虑,QQ的软键盘需要初始化(给出一个不标准排列的键盘),在QQ内部可能会维护一个软件键盘布局的数据结构,这样通过调试手段在QQ进程中找到这个软键盘布局的数据,就能够把软键盘布局提取出来,然后我们就能够和QQ一样,对收到的鼠标按键,对数据结构进行HitTest 得到用户输入的字符。但这种方法建立在对远程进程访问的基础上,如果开发者的保护意识很好,他能够有意识的阻止其他进程用某些权限访问自己,使这种方法失去作用。

    现在我添加了鼠标在密码框上点击的监视,主要用于重新定位光标插入符。定位方法可以参考下面这个我绘制的图片,这个图片是基于对QQ2009的登录窗口观察得来。每个密码字符在密码输入框中占据的尺寸是 8 * 8 像素(定位插入符仅关心横坐标即可)。为了看清楚,下图是把密码框放大到一定倍数后的显示效果,白色的部分就是密码框窗口(其边界以外的部分是浅灰色)。

    比如当我们发现鼠标在密码框上按下鼠标左键时,可以按照下面的公式计算新的字符插入位置:

    (a)把鼠标点击的屏幕坐标 (x, y) 根据密码框的屏幕位置,转换成密码框中的客户区坐标 (x', y')。

    (b)新的插入符位置(索引):iNewPos = min ( strlen ( password ) ,  (x' - 2) / 8 ) ;  (公式1)

    基于整数除法会舍弃小数点后的部分,这样当用户在密码框上点击时,不完整字符的部分会被舍弃,即光标落到到前面的那个字符,我觉得这样是不够人性化的,更好的方法是用“四舍五入”去处理除法结果中的小数部分。即我认为如果腾讯能采用下面的公式逻辑是更合理的:

    iNewPos = min ( strlen ( password ),  ( x' - 2 + 4 ) / 8 );  (公式2)

    设想插入符是一个可在密码框上滚动的小球,鼠标点击的坐标(x')是投放小球的位置,则公式1中,密码框是一个一些列直角三角形组成的锯齿状的轨道槽,而公式2中密码框是一个三角函数曲线状起伏的轨道槽(在 8 * 8 像素的方框的中间达到波峰,在方框的边界达到波谷),这样结果将更符合用户的操作意图,即插入符应落入最近的谷底位置,而不是一律舍弃不够一个完整字符的多余距离。

    (7)准确跟踪 QQ 的 UI 处理逻辑。

    QQ登录窗口有两个文本框:QQ号码框和密码框,UI处理逻辑可能因为版本不同而略有区别。例如两个文本框之间切换焦点时处理逻辑,QQ 2010 SP1中,有时用户切换到号码框去按键时,密码框的文本没有被清空。而QQ 2010 SP2中,无论何时在号码框中按键,则密码框中的文本一定会被清空。在工具中我把处理方式设定为QQ 2010最近的版本 SP2。再比如,密码框是否响应鼠标右键的点击等。如果我们要更精确的跟踪的UI处理逻辑,就要区分QQ的版本分别对待。

    此外,QQ密码框的最大字符数量是16个字符。

    //=========================================================

    // 补充说明:

    //=========================================================

    360的某些工具装有 hook API 函数的驱动,把某些 API 调用跳转到360的代理函数 ,从而有能力拦截一些敏感的 API 调用,例如读写文件,注册表,安装系统服务等。不幸的是,安装系统钩子这样的 API 函数显然不可能被 360 放过,所以此演示工具的行为会被360的工具检测到。但从普通用户角度来看,这是一个对用户安全有利的好消息。(本工具仅具有演示作用对系统无害,因此遇此情况请选择允许。)

    //=========================================================

    最后是该范例的 Demo 可执行文件的下载连接:

     http://files.cnblogs.com/hoodlum1980/QQLogon_bin.rar

      

    参考文献:

    (1)分析QQ2010密码输入的网络文章(过滤干扰的实现受此文章启发),WinLockDLL范例代码等(主要参考其 hook 相关代码)。 

    (2)About Keyboard Input, MSDN。(我主要引用其插图)

    ms-help://MS.VSCC.v80/MS.MSDN.v80/MS.WIN32COM.v10.en/winui/winui/windowsuserinterface/

         userinput/keyboardinput/aboutkeyboardinput.htm#_win32_Keyboard_Input_Model

    (3)Windows 核心编程,第27章,硬件输入模型和局部输入状态。

    作者声明:

    (1)本文提供的演示工具开发的目的仅用于演示用途,即验证一种技术的可行性和可以达到何种程度。因此此工具绝不作任何对系统有害和用户不知情的事情,尽管这个工具演示的功能非常敏感,但它是无害的,也是基于此原因不能像在其他文章中那样提供源码。很遗憾!我因此没有办法贴出代码中那些我自认为非常优雅和奇妙的部分。

    此工具的实现代码 不含有 以下任何行为:如读写注册表,在用户不知情情况下 读写/创建 其他不属于本文所提到的“演示功能的一部分(例如用户指定的记录日志行为)”的文件(就目前而言),安装服务或驱动,添加启动项,添加全局DLL,访问网络,自我复制 / 自我隐藏 / 自我保护,在用户不知情的条件下运行等,以及其他任何可能引发用户反感的行为。它是完全的绿色的小工具,可以任意移动,可以随时删除。你可认为这个工具是友善的无恶意的。作者对本声明负责。

    (2)警告!: 请勿将本工具用于任何目的不当的用途和使用场合,反向工程等。

    代码更新记录 (H - 非常重要;M - 中等; L - 无关紧要;):

    (1)[M] 增加鼠标监视。2010-10-13。

    (2)[H] 修复如果打开一个QQ并停留在登录框界面,这时再打开另外一个QQ登录框,则两个QQ登录框上的事件都会把消息发送回来的BUG,这会把监视过程搞乱。因此如果工具已经正在注视着一个QQ登录窗口,则此时打开的其他登录窗口将被忽略。即工具同一时刻只关注一个QQ登录过程。2010-10-13。

    (3)[M] 优化发现QQ登录窗口的代码,提高了代码效率,去除了部分 QQ UI 特征依赖。2010-10-14。

    (4)[L] 添加对 TM 演示的支持(但TM的版本号可能不详)。2010-10-14。

    (5)[L] 修改了对话框的背景色(界面美化),增加了"VK_0” ~ “VK_Z" 的按键名称定义(因为它们的值就是普通字符,SDK没有必要定义它们)。2010-10-14。

    (6)[L] 增加 QQ2010 Beta2 的 UI 特征。2010-10-15。

    (7)[M] 重要:调整了代码,去除了对QQ客户端具体版本的强依赖,这使得该工具可以适应几乎目前所有市场主流的 QQ,TM 客户端。2010-12-6。

    (8)[L] 中等:增加对 QQ2011 版本的检测逻辑(QQ2011版相比2010,2009等历史版本在 UI 上有一些新的变动)。2011-3-23。

    ----By Hoodlum1980