如今,软件安全已经成为了开发软件项目的必备组成部分,反调试则是其中关键的一环,然而,正如矛与盾的对立一样,反反调试与反调试必将永久的并立共存。为了防止软件被调试,现今的软件大多都利用了驱动来检测制止,对于关键的系统函数进行hook(包括各种SSDT hook、inline hook、iat hook等)能有效地遏制进程被打开和读写等,然而,hook是很容易定位和被恢复的,基于没有任何验校检测的hook保护技术就像一面纸墙一般不堪一击。因此,验校检查成为越来越多的反调试代码中不可或缺的一个部分,特别是对于商业性的网络游戏客户端,一旦反调试代码检测到自身的hook地址被修改或者自身的代码验校不一致时,便立刻选择结束游戏进程,甚至蓝屏或重启,以此强硬的对待那些有调试企图的人。
检测代码往往无处不在,你很难全部的定位和找到它们,而且它们相互交织检测和代码验校。另一方面,为了对抗硬件断点,在检测调试之前,往往对DR调试寄存器做了相关清除和手脚,并在之后予以恢复,检测代码往往加了VM保护,使人很难弄清程序的流程。
    我们知道,在线程切换时会根据是否是同一进程而决定知否切换cr3寄存器,即使切换了cr3,所有进程的内核空间视图是一致的(除了某些特殊页),因此当某一进程通过驱动hook内核函数后,系统所有进程都将改变执行路径,同样当我们恢复了hook之后亦是如此。要是有什么办法能打破这样的规则就好了,当我们的调试器进程运行时执行原始的函数路径,当hook进程运行时执行它自己的hook之后的路径。我们知道,hook技术通常只是修改内核函数的开头几个字节jmp到自己的函数,或者内联修改函数的内部call地址等,不论通过什么形式的hook,一般就是修改一个dword或者几个字节,在已知hook地址和原始字节内容的情况下,恢复hook只需一个mov指令即可,虽然进程切换时并不影响内核地址空间,但是我们也可以在切换时临时修改一些字节。我们的反反调试思想是:在系统从反调试进程切换到其他进程时,恢复原始的hook地址内容,在要切换到反调试进程时,再修改为hook地址。
windows的线程切换散布在内核的各个点上,而且调用形式各不相同,主要函数包括KiSwapThread、KiSwapContext、SwapContext。在线程抢占的情景中,KiDispatchInterrupt直接调用SwapContext完成线程切换;在线程时限用完时,KiQuantumEnd调用KiSwapContext进行切换(KiSwapContext再调用SwapContext完成真正的切换);在线程自愿放弃执行时,则调用KiSwapThread,该函数又调用KiSwapContext完成执行权的转移。在此,我们看到实际完成切换的是核心汇编函数SwapContext。SwapContext也是我们需要处理的函数,在系统线程切换时,我们判断2个线程的进程之一是否含有反调试进程,有的话则进行相关动作,具体是:如果老线程是反调试进程则恢复还原原始hook地址处的内容,如果新线程是反调试进程则还原它自己的原来的hook地址。这里有一个问题,我们是直接在SwapContext函数的开头跳到我们的函数进行以上的判断和恢复吗?我们知道线程切换是系统最频繁调用的函数了,SwapContext本身就是用汇编来写的(为了保证性能),我们的处理是否得当也将直接影响到系统的整体速度,刚才提到的在函数开头进行判断显然不够优雅~在线程切换时,SwapContext会根据是否是同一进程而决定切换cr3寄存器的内容,看一下相关代码:
(代码截自XP sp3)
lkd> x nt!*SwapContext
80546a90 nt!SwapContext = <no type information>
8054696c nt!KiSwapContext = <no type information>
805fcd34 nt!VdmSwapContexts = <no type information>

lkd>uf nt!SwapContext
.
.
.
nt!SwapContext+0x8c:
80546b1c 8b4b40          mov     ecx,dword ptr [ebx+40h]
80546b1f 894104          mov     dword ptr [ecx+4],eax
80546b22 8b6628          mov     esp,dword ptr [esi+28h]
80546b25 8b4620          mov     eax,dword ptr [esi+20h]
80546b28 894318          mov     dword ptr [ebx+18h],eax
80546b2b fb              sti
80546b2c 8b4744          mov     eax,dword ptr [edi+44h]
80546b2f 3b4644          cmp     eax,dword ptr [esi+44h]         比较是否是同一进程
80546b32 c6475000        mov     byte ptr [edi+50h],0
80546b36 7440            je      nt!SwapContext+0xe8 (80546b78)  是同一进程无需切换,直接跳过

nt!SwapContext+0xa8:
80546b38 8b7e44          mov     edi,dword ptr [esi+44h]         取EPROCESS
80546b3b 8b4b48          mov     ecx,dword ptr [ebx+48h]     
80546b3e 314834          xor     dword ptr [eax+34h],ecx
80546b41 314f34          xor     dword ptr [edi+34h],ecx
80546b44 66f74720ffff    test    word ptr [edi+20h],0FFFFh
80546b4a 7571            jne     nt!SwapContext+0x12d (80546bbd) 

nt!SwapContext+0xbc:
80546b4c 33c0            xor     eax,eax

nt!SwapContext+0xbe:
80546b4e 0f00d0          lldt    ax
80546b51 8d8b40050000    lea     ecx,[ebx+540h]
80546b57 e850afffff      call    nt!KeReleaseQueuedSpinLockFromDpcLevel (80541aac)
80546b5c 33c0            xor     eax,eax
80546b5e 8ee8            mov     gs,ax
80546b60 8b4718          mov     eax,dword ptr [edi+18h]         取cr3也即EPROCESS->DirectoryTableBase

80546b63 8b6b40          mov     ebp,dword ptr [ebx+40h]
80546b66 8b4f30          mov     ecx,dword ptr [edi+30h]
80546b69 89451c          mov     dword ptr [ebp+1Ch],eax
80546b6c 0f22d8          mov     cr3,eax                         完成切换
80546b6f 66894d66        mov     word ptr [ebp+66h],cx
80546b73 eb0e            jmp     nt!SwapContext+0xf3 (80546b83)
.
.
.

    为了不影响性能,我们所要做的只是在不同进程切换时做判断,若是同一进程则无需做任何处理,SwapContext函数内部本身就会做相应的判断,我们为什么不直接利用呢?地址80546b36处的je跳转是同一进程的分支,否则接下来的语句便是不同进程,我们修改80546b38处为跳到我们的函数里并进行判断:
(edi老线程,esi新线程)

cmp  dword ptr [edi+44h] , 反调试进程_EPROCESS                 
jmp  _恢复hook分支
cmp  dword ptr [esi+44h] , 反调试进程_EPROCESS  
jmp  _hook分支
mov     edi,dword ptr [esi+44h]      SwapContext函数内部地址80546b38的原指令   
jmp     80546b3b 


_恢复hook分支:
cr0去保护位
mov   [_hook地址1], 原始内容1 
mov   [_hook地址2], 原始内容2
'
'
'
cr0恢复
mov     edi,dword ptr [esi+44h]     
jmp     80546b3b

_hook分支
cr0去保护位
mov   [_hook地址1], 反调试进程hook函数地址1
mov   [_hook地址2], 反调试进程hook函数地址2
'
'
'
cr0恢复
mov     edi,dword ptr [esi+44h]     
jmp     80546b3b   

其中hook地址和值是在驱动中定位并收集好的

    如果你觉得上面的方法还是不够优雅的话,下面我就再来介绍一种相对而言稍微优雅的方法。
我们知道,windows的内存寻址是通过三级的页目录,页表来映射的,每个进程都有独立的页表,且进程的系统空间视图是共享相同的页目录的。这一次,我们就来对反调试进程的页表做相应的手脚~我们的思想方法是:修改反调试进程的页表项,让其hook代码的页面为一私有页,这样,反调试进程与其他进程将拥有不同内核代码页,其检测机制便荡然无存了。然后,再在我们的进程里恢复hook地址(当然也可以在反调试进程创建后,加载驱动前修改,这样就不用恢复了~)。
windows内核映象是如何映射的?我们来看一下:
lkd> lm
start    end        module name
804d8000 806e5000   nt         (pdb symbols)          c:\symbols\ntkrpamp.pdb\7D6290E03E32455BB0E035E38816124F1\ntkrpamp.pdb
806e5000 80705d00   hal        (pdb symbols)          c:\symbols\halmacpi.pdb\9875FD697ECA4BBB8A475825F6BF885E1\halmacpi.pdb
a32db000 a331ba80   HTTP       (pdb symbols)          c:\symbols\http.pdb\B5A46191250E412D80E9D9E9DDA2F4DA1\http.pdb
a3610000 a3613d80   vstor2_ws60   (no symbols)           
a3614000 a3665c00   srv        (pdb symbols)          c:\symbols\srv.pdb\069184FEBE104BFDA9E51021B9B472D92\srv.pdb
a368e000 a375ce00   vmx86      (no symbols)           
a3785000 a37b1180   mrxdav     (pdb symbols)          c:\symbols\mrxdav.pdb\EDD7D9E6E63B43DBA5059A72CE89286E1\mrxdav.pdb
a3a46000 a3a5a480   wdmaud     (pdb symbols)          c:\symbols\wdmaud.pdb\D3271BFD135D4C2B9B1EEED4E26003E22\wdmaud.pdb
a3ae3000 a3af2a00   vmci       (export symbols)       \??\C:\WINDOWS\system32\Drivers\vmci.sys
a3b27000 a3b2ae80   DbgMsg     (no symbols)           
a3cb3000 a3cc8880   irda       (no symbols)   
'
'
'
lkd> !pte 804d8000
               VA 804d8000
PDE at 00000000C0602010    PTE at 00000000C04026C0
contains 00000000004009E3  contains 0000000000000000
pfn 400        -GLDA--KWEV    LARGE PAGE pfn 4d8       

    其中L是指使用大页面来映射的,这表明内核的代码和数据是在一页(4m或pae下2m的大页)中,而我们要修改的只是代码页,数据页必须映射到相同物理页以维持系统的一致性。因此,我们在反调试进程中,为内核映象对应的PDE申请相应的页表,在页表中,我们将原内核映象的数据页对应的pte设置为相同的pfn,而代码页设置为我们私有页,事实上,代码页中也无需全部私有,只需要把hook函数所在的页面改为私有pfn即可,其他页面可仍为原始pfn,从而避免不必要的内存浪费。然后我们恢复hook,结果反调试进程和其他进程会拥有不同内核函数的执行路径了,反调试保护也随之为我们突破~
仔细看看,经过上面的处理真的就可以了吗?答案当然是否定的。看看上面的 !pte 804d8000命令的结果-GLDA--KWEV,其中G表示全局页,全局页标志是为了提升系统性能,因为内核地址空间是共用的,所以cpu在冲刷内部TLB时,只是冲走了没有G标志的TLB项,当然,这并不是说全局页就永远不会消失,TLB缓存项是有限的,cpu会以FIFO规则替换所有的TLB项。可能有人感到奇怪,在SwapContext函数中并没有显示的冲刷TLB的指令,这是因为:如果是同一进程中,则无需冲刷;如果是不同进程,那么在更改cr3的同时,已经隐式的执行了冲刷命令。我们的目的是在切换到反调试进程时,冲刷掉全局页,使其使用自己的私有页。那么如何做到呢?cpu内部的cr4寄存器中位7是PGE(Page Global Enable)位,为1时启用全局页功能,为0是禁止。当全局页禁用时,冲刷TLB的话则全部TLB项都会无效。所以我们上面说的修改反调试进程的pde及pte中都不得含有G标志,我们在SwapContext非同一进程的分支做如下处理:
cmp     dword ptr [esi+44h] , 反调试进程_EPROCESS
mov     edi,dword ptr [esi+44h]                     执行80546b38原始指令
jne     80546b3b                                      不是,直接跳回

mov     eax , cr4                                     eax内容无需保存,见代码即知
push    eax                                            保存cr4内容
and     eax , ~(1 << CR4_PGE)                       去PGE位
mov     cr4 , eax    
mov     cr3 , _反调试进程cr3值                       冲刷所有TLB项 
pop     cr4                                            恢复cr4
jmp     80546b3b 

这样,在反调试进程自身上下文中任何检测都将无效,因为我们根本不会碰它的任何代码逻辑,当然,上面的代码无法突破一些在任意上下文中运行代码的检测机制,比如dpctimer,workitem,Watchdog Timers以及System Threads,然而这些机制其实很可以很容易的突破,比如枚举查找系统的dpc定时器并删除是很简单的,系统线程也很容易被停掉。

再将思维发散一下,驱动在改变一个内核函数的路径时必定先要获得该函数的地址,或者一个相对的基准函数,无论其是通过静态IAT导入函数,还是手工IAT搜索,还是动态MmGetSystemRoutineAddress,还是read内核文件,我们在之前做相关手脚,在其获取函数时提供给他一个虚假地址,当然,这是原函数的一个副本,以便他能找到内部相应的hook地址。好的,让他hook修改然后检测去吧~~

   以上只是本人的拙劣想法和见解而已,希望它对你有用~

特别感谢:CUBIE(酷毙哥) De_l_ta   hopy  坚持到底  keenjoy95  老衲 盟主  紫色秋枫  (无先后字母顺序)  曾经给予我的帮助!

  待业求职中...实习、打杂都行,只要管饭。