跟踪Native API函数调用

序言

我们来研究一样非常有用的东西。我甚至要说,在某些情况下,离了它是寸步难行的,而且它能让我们对Windows的内部机制有个很好的认识。是的,本文的题目已经是不新鲜的已经被讲滥了的东西了。在这方面有众多的文章甚至是专著,作者也都是著名的专家,像Jeferry Ritcher, Matt Petriek, Sven Schreiber, Mark Russinovich等等。

是的,值得注意的是这些内容都是针对Windows NT产品线的,对9x来说就不成了。

为了更有效地掌握文章的内容,您应该具有以下知识:

    * C语言
    * 使用Windows NT  2K/XP/2K3操作系统
    * 了解进程,线程和Windows NT系统的基本结构。
    * 知道x86处理器如何在保护模式下工作
    * DDK(我们这里用的是DDK 2K Build 2195)
    * SoftIce. 我用的是DriverStudio v2.7。一定要安装完整的Studio版,而不要用那种分离出来的Ice,否则会有兼容性问题。然而这个问题在论坛上讨论了不是一两次了。


我们有什么?

就我从Ritcher写的书和文章(我为什么要把Ritcher搬出来说事儿呢?对,因为它的书和文章都翻译成了俄语且广泛流行,更重要的是他的方法是比较标准的)里学到的来看,作者从事于直接在用户模式下拦截API函数的处理与研究。对我们的意义何在呢?

这里有个对比:

方法1:

直接修改进程的import/export table,将原始函数的指针改为指向我们的stub函数。

优点:

1. 实现起来非常简单(因此在Jeferry Ritcher的“Programming Applications for Microsoft Windows”一书中对其进行了详尽的讲解)。此方法保证了进程地址空间不会受到破坏。

2. 出现问题的时候只会宕掉试验用的进程,不会使整个系统崩溃。

缺点:

1. 必须用某些方法向其它进程的地址空间中注入自己的代码(设置全局HOOK (SetWindowsHookEx()),通过注册标加载DLL,直接向进程地址空间注入代码 (WriteProcessMemory(), CreateRemoteThread()…))。

2. 不能对任意进程进行此种操作,因为Win NT为系统中所有的对象都赋予了一个security descriptor,这个security descripter定义了一个对象能否拥有另一对象以及拥有的程度。

3. 如果被拦截的系统函数A本身基于另一个系统函数B而进程突然直接调用了B,这时该怎么办?这时又得找到函数B并对其进行拦截。

4. 所跟踪到的函数调用情况只是针对于“被处理过”的进程,而不是所有的进程。

方法2:

修改包含所要跟踪的函数的那部分库代码(splicing法,例如在函数起始部分注入指向我们处理程序的机器指令Jmp 0xXXXXXXXX)。
    
优点:

不要需要修改进程模块的import表!在Win9X中此法可以跟踪到所有进程对系统库函数的调用。

缺点:

1. 有让系统宕掉的危险,因为在修改库代码的时候可能会发生上下文的切换,如果修改尚未完成而被修改的函数被另一个进程或线程调用的话,则发生异常的可能性非常大,而可能更坏的是,系统会挂起或者是发生BSOD。如果此函数的拦截代码位于非调用进程的进程上下文中,也会有同样的问题。
   
2. 要实现这种方法必须修改库代码的segments,而这些segments却有着“ReadExecute”属性,但修改还是可以的。

最后还有一种办法——特别是用在Windows的调试机制中(Debug API)。讲到这里,我想大家都能明白。顺便说一句,在wasm.ru有相当多的文章是讲前面这些拦截方法的。特别值得注意的是90210/HI-TECH的文章,所有这些方法在那篇文章里都有。

现在我们假设我们需要监视所有的进程来取得某些资源使用情况和对这些资源的操作(创建/打开/读取/写入某个文件、注册表、修改某个进程页的保护属性等等)。准备好了么?现在请再看一下前面的内容并告诉我,借助于前面讲的方法这可能实现吗?当然,在读者当中一定会有乐观主义者认为是可以的,但这说起来容易做起来难。但是有一种办法更为简单,最主要的是它更为有效。

                                                           NATIVE API

我们知道,Windows 2000的设计理念是它不止能运行Win32应用程序,还能运行旧的Win16, MsDos, Os/2v 1.x程序和POSIX程序。简单的说,系统里内建了三个子系统,每个子系统执行自己的程序。实际上,没人能影响我们向系统中添加对其它操作系统的支持,所要做的只是编写程序并将他绑定到相应的子系统模块上就行了。然而,程序是怎么工作的呢?为了回答这个问题我们来看下面这幅图。

(图欠奉)

如果所启动的程序不是Win32应用程序,而是其它操作系统的程序,此时Win2k会寻找与其相对应的能识别并启动此程序的子系统。如果没找到,则会返回错误。我们来详细讲一下。任何调用,比如说OS/2应用程序的,会由此应用程序的支持模块转换为Win32子系统库中的相应的函数,这个Win32子系统库就是KERNEL32.DLL。KERNEL32.DLL会将调用转到NTDLL.DLL中的一个函数。

(斜体加粗)尽管如此,Win32应用程序可以直接调用ntdll.dll模块中的函数,甚至连这一步都可以省掉,直接调用int 0x2e。

(斜体加粗小号字体)只是像这样做的应该都是些特殊的应用程序,显然它们应该知道作些什么。然而,在WinNT中这样的程序并不少见,而且它们都被成为服务,确切讲叫服务应用程序。其中的一个例子就是众所周知的Svchost.exe。这样的应用程序只能存在于WinNT中,在Win9x中则不能工作。

现在我们开始讲最有意思的东西。实际上ntdll.dll这个库中的所有函数都是某种stub,看上去是下面这个样子:

.text:77F84F40                 public ZwWriteFile                  ; NtDll!ZwWriteFile
.text:77F84F40 ZwWriteFile     proc near
.text:77F84F40
.text:77F84F40 arg_0       = byte ptr  4
.text:77F84F40
.text:77F84F40                 mov    eax, 0EDh           ; NtWriteFile
.text:77F84F45                 lea        edx, [esp+arg_0]
.text:77F84F49                 int        2Eh            
.text:77F84F4B                 retn    24h
.text:77F84F4B ZwWriteFile     endp

我们来分析一下这段代码。函数ZwWriteFile()在堆栈中接收9个参数,每个都是4个字节长。

(斜体加粗)几乎所有关于Native Api函数的信息都能在Garry Nebetta的《Windows NT/2000 Native API参考》一书中找到。但是要强调的是,本书是在Win2K时代写成的,所以在书中只讲了Win2K中存在的函数,而在新版的5.1(WinXP)和5.2(Win.Net)内核中添加了数量未知的新函数,遗憾的是对这些函数的描述我还没有找到。

(斜体加粗)还有一个地方值得一提——函数的名称。正如您看到的,前缀分别为Ntxxx和Zwxxx的函数是一样的。如果看一下NTDLL.DLL模块的反汇编代码的话,就会发现除了前缀不一样外这两种函数没有区别——Nt与相应的Zw函数指向的是同一处代码。在模块NTOSKRNL.EXE中情形又有些不同。前缀为Zw的函数,实际上是由此模块导出的函数,并且是直接调用的。而前缀为Nt的函数则需要在相应的Zw函数取得控制之前做一系列的安全验证。然而,我建议不要为这个问题太费脑筋。

我们将看到,这里直接使用到两个寄存器。在EAX寄存器中放的是函数的系统调用号(index,我们后面会讲到)。在EDX寄存器中放的是函数参数堆栈的指针,这个指针的值减去4个字节就是函数返回点的地址。之后就是使用中断调用指令INT 0x2E进行处理器模式的切换,这个中断被保留用作系统调用handlers的入口点。这正是我们所需要的。实际上,所有用户模式(User Mode)应用程序发出的系统函数调用最终都要发向内核并通过INT 0x2E,而通过这个INT 0x2E处理器从ring3切换到了ring0,即Kernel Mode。好,到这里系统调用门儿外的情况我们已经分析了,现在我们看一下门儿里面的东西。

KiSystemService & ServiceDescriptorTable

; _KiSystemService:
.text:00464FCD    push    0    ; 被M$称为ENTER_SYSCALL macro的代码块,不只在这个函数的prolog中能见到。
                                ; 最有意思的是从3.51版的Windows NT开始,这个函数就没再发生过变化。在堆栈中保存主要的寄存器。

.text:00464FCF    push    ebp   
.text:00464FD0    push    ebx
.text:00464FD1    push    esi
.text:00464FD2    push    edi
.text:00464FD3     push    fs

; 向FS中加载PCR指针
.text:00464FD5    mov    ebx, 30h
.text:00464FDA    db    66h
.text:00464FDA    mov    fs, bx

; 在堆栈中保存前exception handler
.text:00464FDD    push    dword ptr ds:0FFDFF000h

; 初始化新的exception handler链表,就是叫做SEH frame的东西。
.text:00464FE3    mov    dword ptr ds:0FFDFF000h, 0FFFFFFFFh

; 取得当前线程结构体的地址
.text:00464FED    mov    esi, ds:0FFDFF124h

; 在堆栈中保存前User/Kernel模式以及相关的地址
.text:00464FF3    push    dword ptr [esi+134h]
.text:00464FF9    sub    esp, 48h
.text:00464FFC    mov    ebx, [esp+6Ch]
.text:00465000    and    ebx, 1
.text:00465003    mov    [esi+134h], bl

;  修正当前的stack frame
.text:00465009    mov    ebp, esp   

; 保存当前的stack frame
.text:0046500B    mov    ebx, [esi+128h]

; 建立新的stack frame
.text:00465011    mov    [ebp+3Ch], ebx
.text:00465014    mov    [esi+128h], ebp
.text:0046501A    cld

; 到这儿就有意思了,验证当前线程是否正被调试,如果是的话,可以自己通过地址看。
.text:0046501B    test    byte ptr [esi+2Ch], 0FFh
.text:0046501F     jnz    loc_464F49
.text:00465025
.text:00465025 loc_465025:                            
.text:00465025                                        
.text:00465025    sti
.text:00465026
.text:00465026 loc_465026:                            
.text:00465026    

_KiSystemServiceRepeat:    

;向edi寄存器拷贝服务号以进行下面的操作
.text:00465026    mov    edi, eax
.text:00465028    shr    edi, 8
.text:0046502B    and    edi, 30h
.text:0046502E    mov    ecx, edi

; 取得线程descriptor table指针(每个线程都有一个自己的SERVICE_DESCRIPTOR_TABLE结构体,因为_KTHREAD中就保存有指向它的指针)
.text:00465030    add    edi, [esi+0DCh]
.text:00465036    mov    ebx, eax

; 还有一项验证,突然此调用进入了驱动程序win32k.sys,抑或者这个服务根本就不存在?
.text:00465038    and    eax, 0FFFh
.text:0046503D    cmp    eax, [edi+8]
.text:00465040    jnb    loc_464E02
.text:00465046    cmp    ecx, 10h
.text:00465049    jnz    short loc_465065

; 取得当前TEB的地址,之后又有一个问题,这个调用是GDI类型的吗?
.text:0046504B    mov    ecx, ds:0FFDFF018h
.text:00465051    xor    ebx, ebx
.text:00465053    or    ebx, [ecx+0F70h]
.text:00465059    jz    short loc_465065
.text:0046505B    push    edx
.text:0046505C    push    eax
.text:0046505D    call    dword_482220
.text:00465063    pop    eax
.text:00465064    pop    edx
.text:00465065
.text:00465065 loc_465065:                            
.text:00465065  

; 增加系统调用计数器。这个计数器是性能计数器中的一个,供Performance Monitor之类的程序使用
.text:00465065    inc    dword ptr ds:0FFDFF5DCh

; 在esi中放入用户参数堆栈的指针。还记着ntdll.dll库中的ZwWriteFile函数的stub中的edx寄存器吗?我希望您还记着为什么这里使用堆栈
; 指针的参数传递机制,而不是通常的堆栈方式。因为在调用了0x2e并进行了模式切换之后,处理器向SS中加载了一个值。这个值之前保存在
; TSS段(0x28)里,现在它将指向GDT表中的另一个descriptor。因为这个结构体所有线程都有一个(对于Double Fault (_KiTrap08)的处理
; 用的是自己的TSS段,不用考虑得太周全),这样在模式切换后会对当前线程堆栈进行调整。线程的用户模式下的堆栈和内核模式下的是不一
; 样的,因为如果一样的话,则我们只会在屏幕上看见一样东西——BSOD。除此之外,具体线程间的堆栈也是不一样的。对此您可一定要有个
; 清晰的认识。我们继续来看系统服务函数的代码。
.text:0046506B    mov    esi, edx

取得服务参数表KiArgumentTable的指针。
.text:0046506D    mov    ebx, [edi+0Ch]

为了文章的完整性,我们来对service table及其参数进行一点研究。来看下图:

(图欠奉)

这就是我们的descriptor table。请注意灰白色的区域。实际上,我只着重区分出了我们真正感兴趣的域。剩下的域一般都是NULL指针或是零值。现在我们来看这些东西用C语言如何描述。

typedef struct _SERVICE_DESCRIPTOR_TABLE
{
SYSTEM_SERVICE_TABLE NtoskrnlTable;    // ntoskrnl.exe (native api)
SYSTEM_SERVICE_TABLE Table2;            // 空闲
SYSTEM_SERVICE_TABLE Table3;            // 用于Internet Information Services
SYSTEM_SERVICE_TABLE Table4;            // 空闲
}
        SERVICE_DESCRIPTOR_TABLE,
     * PSERVICE_DESCRIPTOR_TABLE,
    **PPSERVICE_DESCRIPTOR_TABLE;

typedef struct _SYSTEM_SERVICE_TABLE
     {
 PNTPROC ServiceTable;                // 指向handler入口点数组的指针
 PDWORD  CounterTable;                // 服务调用计数器(未使用)
 DWORD   ServiceLimit;                // 所支持的服务的数目
 PBYTE   ArgumentTable;                // 服务参数数组指针
}
        SYSTEM_SERVICE_TABLE,
     * PSYSTEM_SERVICE_TABLE,
    **PPSYSTEM_SERVICE_TABLE;

(加粗)除了这个由Ntoskrnl.exe公开导出的结构体外,在WinNT中还有一个这样的结构体,名为ServiceDescriptorTableShadow,这个结构体未被导出,只在模块内部使用。

(斜体加粗)如果内部的函数名没有被导出,则就意味着这个函数我们无需了解。我们有非常简单的办法可以对它们进行访问。总之一定要使用一种调试器,或是DDK中的Kernel Debugger(i386kd.exe)、WinDbg(windbg.exe),或是我的最爱——SoftIce,但是使用前一定要设置好调试符号,这些调试符号可以从微软的站点上下载。除此之外,如果您已经弄好了调试符号(参见http://www.microsoft.com/whdc/devtools/debugging/symbolpkg.mspx),对于二进制文件的反汇编与分析,请使用IDA,IDA通过加载相应的插件(菜单Edit->Plugins->Load PDB File或是Ctrl+F12)可以轻松地搞定相关的.PDB文件。

(加粗)在Windows 2000里它就在在ServiceDescriptorTable后面,可以很快地找到。但是并非在所有版本的Windows NT中都是这样。这个结构体的有趣之处在于,除了NtoskrnlTable域,第二个域的值并不为零,而是一个SystemServiceTable结构体指针,指向GDI函数入口点的数组,而这些GDI函数都位于驱动程序Win32k.sys中。我们知道,在WinNT中图形部分被封装进了内核,这是为了相应的进程能运行得更快。但是,大多数情况下,人们对这一问题都不太在意,因为很少有人对拦截图形函数感兴趣。

现在我们来更详细地讲一下这个结构体。我们将看到,KeServiceDescriptorTable结构体中有四个完全相同的SystemServiceTable结构体。其中只有第一个结构体被赋值,我把这个结构体叫做NtoskrnlTable。SystemServiceTable结构体由四个域构成。我们来逐一讲解。

ServiceTable – 指向系统服务handler入口点数组的指针。让我们感兴趣的是,有一些入口点指向的函数是存在的,并且被Ntoskrnl.exe导出了,而另一些则不是这样。

(斜体加粗)例如,函数NtCreateProcess并未被导出,然而入口点却指向了某段代码,这段代码实现了相应的功能。所以在内部,比如说在驱动程序里,实现创建进程这样的功能的时候工作就变得复杂,因为我们不可能使用已经准备好的导出函数,而且除了KiSystemService函数之外,没有一个通用的接口。但是NtCreateProcess只是冰山的一角,与进程创建有关的大部分准备工作都封装在了Kernel32.dll和Ntdll.dll之中。这样的函数可不止这一个。如果您急切地想在驱动中实现类似的操作,请先坐下来,然后分析Win NT的实现方法。这个活儿可不是那么轻松的,而且要求要有大量的相关知识和相当的耐性,除此之外更为复杂的是,这段代码可能广泛分布于不同的模块中,而不止是局限在某个函数的内部。您得好好想想您到底要干什么,但是在Windows 9x中在内核模式下创建进程是可以轻松完成的,这靠的是驱动程序Shell.vxd的“导出”函数_ShellExecute。对于为什么Microsoft在Windows NT中采取了这样的措施,我脑子里只有一个答案,那就是为了系统的安全。

CounterTable – 一个内核变量的指针,这个内核变量被用作KiSystemService使用次数的计数器,只在checked build版本里才有值。在普通版本里其值为NULL。

ServiceLimit – 此版本Windows NT实现的服务函数的数目。例如在Windows 2000 build 2195,此值为248(0xF8),而在Windows XP build 2600里则为284(0x11C)。显然这个值增大了。

ArgumentTable – 指向参数数目字节数组的指针。这些参数都是以字节为单位传给函数的。也就是说,如果函数NtAcceptConnectPort在堆栈中接收0x18个字节,那就是6个双字。双字在ServiceTable数组中的序号对应着ArgumentTable表中有此相同序号的成员。

除了上面所讲的,还有一个有意思的细节值得我们注意。您可能已经注意到,在结构体ServiceDescriptorTable中最好的位置上只占据了两个SystemServiceTable结构体(除了Ntoskrnl.exe外,第二个可能会被IIS服务占用)。自然还剩下了两个,用户可以用其来想系统中添加自己的服务。为此需要调用KeAddSystemServiceTable函数,而这个函数在DDK中有说明。

我想,到这里已经全都讲清了。我们继续拆解_KiSystemService的代码。

; 向CL加载相应的ArgumentTable数组成员
; 来确切地知道要向内核堆栈传递多少个字节
.text:00465070    xor    ecx, ecx
.text:00465072    mov    cl, [eax+ebx]
.text:00465075    mov    edi, [edi]

; 向EBX寄存器中加载相应服务函数的入口点
.text:00465077    mov    ebx, [edi+eax*4]
.text:0046507A    sub    esp, ecx

; 传递双字
.text:0046507C    shr    ecx, 2
.text:0046507F    mov    edi, esp

; 检验用户堆栈是否位于用户空间
.text:00465081    cmp    esi, MmUserProbeAddress         ; = 0x7FFFFFFF
.text:00465087    jnb    loc_465271
.text:0046508D
.text:0046508D loc_46508D:                            
.text:0046508D       

; 将堆栈中的参数传递给要调用的函数                                
.text:0046508D    repe    movsd

; 所有都准备好并经过验证后,调用服务
.text:0046508F    call    ebx

; 请更仔细地看一边给出的代码。还记着函数NtDll!ZwWriteFile里的EDX寄存器吗?这个寄存器的内容我们还会用到。



What's what?

喏,现在,讲最主要的,我们这片文章的精华。我希望,大多数读者还记得可爱的MsDos。确切的讲,希望大家还记得中断及对中断的拦截。当时用的方法是向中断向量表中填入我们的handler的指针,可能在执行完之后还要再将控制权交还给原来的函数。对此,我建议您去看
Зубков或是Финогенов的书。但这不是重要的精华部分。Dos已经逝世很久了,而方法却没变。所以,如果好好地端详一下上面的列表的话,可以想到方法“number one”:修改IDT表中相应的0x2e的descriptor,让它指向我们的替换用的handler。但是在这里这个方法不是很好,因为需要处理异常以及验证传递给自己的参数的正确性。最好使用方法“number two”。另外,这种方法M. Russinovich和B. Cogswell已经写文章讲过了,而之后Свен Шрайбер在自己的书里也写到了这种方法。这本书里的一些地方我并不喜欢,比如说,拦截驱动的实现过于复杂,没有具体的目标,而且直到结尾也没有专门给出某些有趣细节的实现,而这些细节却在驱动中扮演着关键的角色。当然,这只是我个人的观点,总体来说这本书还是很informative和interesting的。只是在本文中我决定将复杂的工作简化,只讲主要的,着重讲那些必需的和重要的细节,就像是一个小总结,其内容纯粹就是如何实际实现对native api函数的拦截。

这种方法的本质就是,要将KiServiceTable表中的原始函数指针替换为我们拦截函数的指针。此时我们将首先得到控制权并执行我们的任务,再将控制权交给系统函数(或者如果合适的话,可以让系统先做完自己的事情,然后我们做我们的)。在这种情况下,我们将能够访问到被拦截函数的参数并用它们做任何我们想做的事情。此外,我再强调一遍,几乎所有进程的线程都要调用我们的函数(除了内核线程,包括Iddle。这些线程通常倾向于跳过_KiSystemService直接进行调用)。但是,还有地方需要讲清楚。为了访问ServiceDescriptorTable,我们将使用Ntoskrnl.exe的导出变量KeServiceDescriptorTable,这个变量就是ServiceDescriptorTable的指针。但是请注意,_KiSystemService函数的代码表现为另一种形式:ServiceDescriptorTable指针是它从_KTHREAD结构体的一个域中提取的,但是,对它的初始化则用的是这个变量的值。这样做有其优点——可以建立自己的SystemServiceTable结构体,并用相应的值对它进行初始化,可以向线程结构体相应的域中加载前面的地址,并这样从driver monitor的视野中消失。但是因为这只在内核模式下才有意义,所以相应地,应该只用内核模式驱动来实现。然而,driver monitor可以从进程的角度预见到类似的情况。这就全依赖于barricade两侧的处理程序了。


                                                         

                                                                  实现

现在我们直接切入主题。在我们的驱动程序中我们实现了对NtOpenFile和NtCreateFile这两个有趣函数的拦截。他们的有趣之处在于,我们的工作是基于抽象对象语言的,也就是说,我们的方法不只能应用于某些函数,还能用于大多数的系统对象。这样,用于这些系统对象的方法能够执行这些对象。而这些对象就是文件、设备、管道等等。为了编写驱动,我使用了Sven Schreiber配书光盘中的框架生成器(使用方法书中有介绍)。非常方便易懂。然后,我们指定路径,将工程命名为mfilemon,这样就生成了框架。顺便说一句,请不要忘了正确地初始化w2k_wiz.ini文件中的某些域,否则,编译上会有问题(然而,更详细的信息您可以现在就直接看本文附带archive里的源代码)。源代码由几个模块构成,最有意思的就是fmonitor.c。我就不在文章中给出代码了,而只讲解驱动里最重要和最有意思的地方。

模块“mfilemon.c”

驱动程序的算法如下:首先检查内核的版本,看是否能在这个版本下工作:

          if (*NtBuildNumber == 2195);  else    //2k   
         if (*NtBuildNumber == 2600);  else    //XP
           if (*NtBuildNumber == 3790);  else    //2003

在初始化过程中(DriverInitialize())调用IoRegisterShutdownNotification(gpDeviceObject),这是为了注册callback函数(DriverShutdown),这个callback函数由系统在reload之前调用。

IoRegisterShutdownNotification(gpDeviceObject);  // 注册系统工作完成这个事件
if (!SetFMonHandler())                         // 创建我们的HOOK
{
    IoDeleteDevice (pDeviceObject);
    return ns;
}                 
SystemGetLocalTime();                          // 查询时间
}
else IoDeleteDevice (pDeviceObject);

if ((ns = IoInitializeTimer(pDeviceObject,&TimerProc,NULL)) == STATUS_SUCCESS) IoStartTimer(pDeviceObject);
  else IoDeleteDevice (pDeviceObject);
 }
   return ns;
 }   

函数SetFMonHandler()的作用是创建并打开文件,来向其中写入HOOK protocol并同时创建HOOK。

现在我们必须回忆起点什么,准确的说就是——EDX寄存器。我已经提到过,通过这个寄存器_KiSysemService取得了用户堆栈指针。因为寄存器的内容在直到CALL EBX之前这段时间里是不变的,所以我们可以把它拿来用于相同的目的。除此之外,还有一个地方一定要考虑到——SST表中函数的index。我们已经知道,在不同版本的内核中它们是不一样的。然而,如果一定要找到其中的对应关系的话,那就只有靠调试器或者是反汇编器了。

模块“fmonitor.c”

函数SetFMonHandler()。如果前面驱动启动时在磁盘上留下了日志文件,我们就调用SystemDeleteFile()函数把它删除掉。接着我们建立干净的protocol文件,打开它,并向其中保存descriptor。请注意文件的descriptor应该由内核来写,因为这样一来它就不会关联到具体的进程上。然后根据内核的版本来向SDT表中写入我们拦截函数的地址。还有一个有趣的地方:

_asm mov eax,CR0
_asm mov CR0Reg,eax
_asm and eax,0xFFFEFFFF        // 关WP bit
_asm mov cr0, eax

这就是叫做内核页修改保护的东东。在关掉WP位后,在内核模式下就可以修改用户模式下的只读页和系统内存页而不会引发#GP。如果设置了这个位,则在写入有写入保护的系统内存页时就会引发#GP异常,接着就是BSOD了。之所以这样,是因为这种尝试被认为是会破坏系统数据的。

我们来解释一下。我不清楚windows nt4下是怎样的,在windows 2000+里,内核既可以使用4K的页,也可以使用4M的页。所有这些都依赖于系统可直接使用的可用内存的大小。在windows 2000里如果有128MB或更多的内存时,这些页的大小就是4MB。在Windows XP和Windows 2003 Server里这个门槛提高到了256MB。驱动程序(.sys)和Windows NT所有的可执行模块都是PE格式。而我们知道,这种文件内部的section是以4KB为界限的。如果系统内存页的大小是4MB的话,保护就会被关闭(关掉CR0寄存器中的WP位),因为,在这一页中一定既有代码又有数据。而修改数据的情况极少发生(除了这种情况,如果页大小是4MB,则这样就有很大的优势。这个问题与TLB有关。我们知道TLB为代码页、数据页以及不同大小的页保存着独立的记录。如果页是4MB的,TLB空间消耗就会变小,因为记录的数目变少了,虽然不很显著,却已经提高了处理器的性能)。因为我们修改了SST表的数据,所以相应地,我们背离了保护原则并破坏了系统,增大了危险性。所以,我们使用某种“barbarous”(我不知道为什么这种方法“barbarous”,某些系统函数用的就是这种方法,Mark Russinovich在“FileMon”中用的也是它,也没什么问题)的方法来去掉保护并修改某些内核内存。我想这些问题我们已经理解了。

接下来我们来看在我看来更为重要的地方。

函数WriteToLogFile(PVOID Stack)。函数的参数是用户堆栈指针,就是EDX中的指针。我们注意到,HOOK程序对NtCreateFile和NtOpenFile两个函数用的是同一段代码,因为它们堆栈的结构几乎完全相同。函数的代码被包含在了__try __except块中。我希望您理解这是为了什么。因为我们要用到的函数(RtlUnicodeStringToAnsiString())都与内存操作有关,或早或晚都要引起异常。至少,当我在Windows 2000下调试代码时,什么问题也没有。只要一在WinXP下尝试加载驱动,很快就会出现BSOD。理论上,我并没有长时间考虑what's what,并最终决定将函数置于SEH之下。我得救了。对于为什么WinXP内核把自己搞成这个样子,我只有一个猜测——内核代码被削减了,去掉了各种的SEH frame handlers,而相应的任务就落到了驱动程序设计者的肩上。下面我们来看函数NtCreateFile和NtOpenFile的堆栈。

NTSYSAPI
NTSTATUS
NTAPI
NtCreateFile(
  OUT PHANDLE                     FileHandle,
  IN ACCESS_MASK                  DesiredAccess,
  IN POBJECT_ATTRIBUTES       ObjectAttributes,
  OUT PIO_STATUS_BLOCK        IoStatusBlock,
  IN PLARGE_INTEGER           AllocationSize OPTIONAL,
  IN ULONG                        FileAttributes,
  IN ULONG                        ShareAccess,
  IN ULONG                        CreateDisposition,
  IN ULONG                        CreateOptions,
  IN PVOID                        EaBuffer     OPTIONAL,
  IN ULONG                        EaLength );

NTSYSAPI
NTSTATUS
NTAPI
NtOpenFile(
  OUT PHANDLE             FileHandle,
  IN ACCESS_MASK          DesiredAccess,
  IN POBJECT_ATTRIBUTES   ObjectAttributes,
  OUT PIO_STATUS_BLOCK   IoStatusBlock,
  IN ULONG                    ShareAccess,
  IN ULONG                    OpenOptions );

我们看到,从某种意义上说这两个函数是一样的。因为我们只对OBJECT_ATTRIBUTES结构体感兴趣,所以它们之间的差异我们并不在意。这个结构体的指针在堆栈中是第三个。因此有代码:

(PBYTE)Stack = (PBYTE)Stack+8;
pObjectAttributes = *(PDWORD)Stack;

这样,我们就取出了OBJECT_ATTRIBUTES结构体的指针,您可以认为我们已经取得了对象名,然后用它来打开对象。现在我们需要知道哪个进程想要打开或创建对象。我们还希望知道可执行文件的完整路径,命令行参数,进程ID以及当前线程。对于解决问题的第一部分,我们要用到进程的PEB,PEB在nt系统中位于用户地址空间的0x7FFDF000。但是,新的问题又来了:如果进程只位于内核模式下(比如system),则用这个地址我们什么也找不到,除此之外,这一地址所在的页可能根本就没被映射,这样的话我们就有BSOD看了,但是,从前面看,我们还有SEH frame护体。只是真要遇上这种情况,还是会让人很不爽的。所以,我们函数显式地验证这个地址处的内存是否有效,方法是调用MmGetPhysicalAddress(),向函数传递的参数就是PEB的地址。 如果函数返回非零的值(当然,这个问题还可以用更civilized的办法解决,比如说,检验EPROCESS结构体的*Peb域,但是,我想我的办法更简单),则说明我们将可以得到进程的命令行和参数。接下来,我想代码就好懂了。我们把所得的信息放在缓冲区中,为了美观我们还加上了时间,我们把这些捆到一起扔到文件的缓冲区里。现在看下面的代码:

CurrentThreadPriority = KeQueryPriorityThread(KeGetCurrentThread());
        // 我们来提高
KeSetPriorityThread(KeGetCurrentThread(),HIGH_PRIORITY);
        //线程的优先级

这么做又是为了什么呢?我希望您能理解,与大多数内核函数相同,函数_KiSystemService是完全可重入的。这样,线程在任何时刻都有可能被中断并进行上下文的切换。对于我们来说这可是危险的,因为我们调用函数进行内存操作,初始化某些结构体,所有这些工作都是应该由始至终不被打断的,这样才能得到我们想要的结果。如果我们申请到了系统内存,则后面应该释放。否则最终会耗尽资源并招来BSOD。我们知道,拥有realtime优先级的线程在内核中是不能被中断的。所以,我们暂时将线程优先级升至realtime并操控内存保证执行线程不会被上下文切换所中断。

最终,在C盘的根目录会有个名为KiSystemService.log的文件。使用通常的文本编辑器就可以查看这个文件,其形式如下:

0    16:38:51   3a4   40c   C:/WINNT/Explorer.EXE   /Device/{56A954E7-28ED-471A-B406-2936BB2363B3}
1    16:38:52   3a4   40c   C:/WINNT/Explorer.EXE   /Device/{56A954E7-28ED-471A-B406-2936BB2363B3}
2    16:38:53   3a4   2dc   C:/WINNT/Explorer.EXE   /??/C:/
3    16:38:53   3a4   2dc   C:/WINNT/Explorer.EXE   /??/C:/Program Files/desktop.ini
4    16:38:53   3a4   2dc   C:/WINNT/Explorer.EXE   /??/C:/Program Files/desktop.ini
5    16:38:53   3a4   2dc   C:/WINNT/Explorer.EXE   /??/C:/Recycled/desktop.ini
6    16:38:53   3a4   2dc   C:/WINNT/Explorer.EXE   /??/C:/Recycled/desktop.ini
7    16:38:53   3a4   40c   C:/WINNT/Explorer.EXE   /Device/{56A954E7-28ED-471A-B406-2936BB2363B3}
8    16:38:54   3a4   42c   C:/WINNT/Explorer.EXE   /??/C:/
9    16:38:54   3a4   42c   C:/WINNT/Explorer.EXE   /??/C:/Documents and Settings/
10   16:38:54   3a4   42c   C:/WINNT/Explorer.EXE   /??/C:/Documents and Settings/Администратор/
11   16:38:54   3a4   42c   C:/WINNT/Explorer.EXE   /??/C:/Documents and Settings/Администратор/Избранное/desktop.ini
12   16:38:54   3a4   42c   C:/WINNT/Explorer.EXE   /??/C:/Documents and Settings/Администратор/Избранное/
13   16:38:54   3a4   42c   C:/WINNT/Explorer.EXE   /??/C:/
14   16:38:54   3a4   42c   C:/WINNT/Explorer.EXE   /??/PIPE/srvsvc
15   16:38:54   3a4   42c   C:/WINNT/Explorer.EXE   /??/C:/Documents and Settings/Администратор/Избранное/Ссылки
16   16:38:54   3a4   42c   C:/WINNT/Explorer.EXE   /??/C:/WINNT/Web/folder.htt
17   16:38:54   3a4   42c   C:/WINNT/Explorer.EXE   /??/C:/WINNT/Web/
18   16:38:54   3a4   42c   C:/WINNT/Explorer.EXE   /??/C:/WINNT/Web/folder.htt
19   16:38:54   3a4   42c   C:/WINNT/Explorer.EXE   /??/C:/WINNT/Web/

是的,顺便说一句,为了加载驱动程序,请使用Four-F的KmdKit包中的KmdManager程序。

喏,重要的地方我们已经研究并阐明了。剩下的就是您的事情了。意见与反馈请发至[email protected]

特别感谢FOUR-F先生的文章及答复,感谢Victor Kudlak帮助解决了几个有关Windows 2000 J设备的重要问题,同时还要感谢这个卓越站点的缔造者们。

源代码:http://www.wasm.ru/pub/21/files/tracknapi.zip

[C] Cardinal
http://www.wasm.ru/print.php?article=tracknapi

你可能感兴趣的:(windows,api,service,table,Descriptor,attributes)