第六章 在用户模式下调用内核API函数
翻译:Kendiv( fcczj@263.net )
更新:Tuesday, May 03, 2005
声明:转载请注明出处,并保证文章的完整性,本人保留译文的所有权利。
在第二章,我解释了Windows 2000是如何通过中断门机制,来允许用户模式下的程序调用其内核API函数的一个子集----Native API的。第四、五章中提到的程序依赖于设备I/O控制(IOCTL)来执行无法在用户模式下进行的附加任务。Native API和IOCTL都很强大,但是如果可以像调用普通用户模式DLL中的函数一样来调用任何内核模式下的函数,我们将得到更强大的力量。一般看来这似乎不可能。不过,在本章中,我将演示使用一些疯狂的编程技巧,这将变得可能。IOCTL将再次登场来解决我们首先碰到的看似无法解决的问题。本章将是革命性的,因为它在内核模式和用户模式间架设了一座桥梁,这样Win32应用程序就可以像调用Win32 API一样调用内核API函数了。更进一步,在随同Windows 2000调试工具一起提供符号文件的帮助下,应用程序可以调用对于内核驱动程序来说都无法使用的内核函数。这个“内核调用接口”可以完美的在后台运行,而几乎不被调用程序所察觉。
一个通用的内核调用接口
在第四章,我们使用内核模式的驱动程序来调用用户模式的程序所选择的内核API函数。例如,由Spy驱动程序w2k_spy.sys提供的SPY_IO_PHYSICAL函数只是内存管理函数---MmGetPhysicalAddress()的外包函数。另一个例子是SPY_IO_HANDLE_INFO,它基于对象管理函数----ObReferenceObjectByHandl()和ObDereferenceObject()。尽管这种技术可以很好的工作,但是为每个内核API设计这样一个自定义的IOCTL函数是枯燥和低效的。因此,我为Spy设备增加了一个通用的IOCTL函数,只需提供一个函数名或函数的入口点以及相应的参数列表,就可以调用任意的内核函数。这听起来似乎需要很多工作,但你一定会对实际所需的代码的简洁程度而感到惊讶。唯一的难点是,我们需要再次处理嵌入式汇编代码(Inline ASM)。
设计通向内核的门
如果运行于用户模式的程序想调用内核模式的函数,它就必须解决两个问题。首先,它必须以某种方式跳过用户态和内核态之间的障碍。其次,它必须将数据传入和传出。对于由Native API组成的子集,ntdll.dll组件替我们完成了这一任务,它使用一个中断门来实现模式的切换,并使用CPU寄存器来传入指向调用者参数堆栈的指针,函数的运行结果也是通过CPU寄存器返回给调用者的。对于非Native API的内核函数,操作系统没有提供这种门机制。因此,我们必须自己来实现一个这样的门。这一问题的一部分很容易解决:第四章介绍的w2k_spy.sys驱动程序在第五章经过扩展之后,在IOCTL事务中,可多次来回穿越内核模式和用户模式的边界。因为IOCTL允许双向的传输任意的数据块,数据传输问题就可以得到解决了。最后,整个问题可浓缩为如下几个简单的顺序步骤:
1. 用户模式的应用程序提交一个IOCTL请求,并传入调用该函数所需的信息,如,指向函数参数堆栈的指针。
2. 内核模式的驱动程序处理这一请求,并将参数复制到自己的堆栈中,然后调用制定函数,最后通过IOCTL的输出缓冲区将函数的执行结果返回给调用者。
3. 调用者取出IOCTL操作的结果,并像对普通的DLL函数调用一样进行处理。
这一模式的主要问题是:内核模式的模块必须处理多种数据格式和调用约定(calling convention)。下面是驱动程序必须准备的:
l 参数堆栈的大小依赖于目标函数。因为给驱动程序提供所有可能被调用的函数的细节信息是不切实际的,函数调用者必须提供参数堆栈的大小。
l Windows 2000内核API函数使用三种调用约定:__stdcall、__cdecl和__fastcall,它们处理参数的方式有很大的不同。__stdcall和__cdecl要求所有的参数都应传入到堆栈中,但是,__fastcall为了使搜索参数堆栈的开销最小化,会将前两个参数传入CPU寄存器ECX和EDX。从另一方面来看,__stdcall和__fastcall将参数从堆栈中移除的方式是一致的,这两种调用约定均强制被调用代码来完成这一工作。不过,对于__cdecl来说,这一工作将由调用者完成。尽管通过保存堆栈指针(在调用和将堆栈指针恢复到其原始位置之前,来完成堆栈指针的保存)可以很容易的解决清除堆栈的问题,但不论采用什么样的调用约定,驱动程序对于__fastcall约定都显得无能为力。因此,调用者必须针对每个调用来明确地指出其是否采用了__fastcall调用约定,这样驱动程序才能确定是否需要ECX和EDX寄存器(如果必须的话)。
l Windows 2000内核函数的返回值可以大小不同,其范围从0---64bit不等。返回值由EDX:EAX构成的64位寄存器对,来返回给调用者。Data is filled in from the least-significant end toward the most-significant end.例如,如果函数返回了一个SHORT类型的16位数据,则此时仅有AX寄存器(由AL和AH构成)是有意义的。EAX的高16位和整个EDX中的内容都是未定义的。因为驱动程序将忽略被调用函数的I/O数据,驱动程序必须假定最坏的情况,即数据大小为64位。否则,结果将可能被截断。
l 应用程序应能处理参数无效的情况。在用户模式下,这样做通常会使程序显得比较“友善”。在最坏的情况下,应用程序将被终止,并且弹出一个错误对话框。有时,这种错误需要通过重起计算机才能恢复。在内核模式下,在编程中最常见的错误是:“坏指针”,这样的错误将会立即导致系统蓝屏死机,这种情况下用户数据可能会丢失。通过使用操作系统提供的结构化异常处理机制(SEH)可以在很大的范围内找到这种错误。
这样看来,我们需要检查Spy驱动程序是如何处理函数的属性、参数和返回值的。列表6-1给出了IOCTL中涉及到的输入/输出结构----SPY_CALL_INPUT和SPY_CALL_OUTPUT。SPY_CALL_OUTPUT结构非常简单,它包含一个ULARGE_INTEGER结构,Windows 2000 使用ULARGE_INTEGER结构来表示一个64位的值(即可以是一个单一的64位整型数据,也可以使一对32位的值)。请参考第二章中的列表2-3,来了解这一结构的布局。
typedef struct _SPY_CALL_INPUT
{
BOOL fFastCall;
DWORD dArgumentBytes;
PVOID pArguments;
PBYTE pbSymbol;
PVOID pEntryPoint;
}
SPY_CALL_INPUT, *PSPY_CALL_INPUT, **PPSPY_CALL_INPUT;
#define SPY_CALL_INPUT_ sizeof (SPY_CALL_INPUT)
// -----------------------------------------------------------------
typedef struct _SPY_CALL_OUTPUT
{
ULARGE_INTEGER uliResult;
}
SPY_CALL_OUTPUT, *PSPY_CALL_OUTPUT, **PPSPY_CALL_OUTPUT;
#define SPY_CALL_OUTPUT_ sizeof (SPY_CALL_OUTPUT)
列表6-1. SPY_CALL_INPUT和SPY_CALL_OUTPUT结构的定义
SPY_CALL_INPUT需要稍微说明一下。其fFastCall成员的含义是显而易见的。它通知Spy Driver调用函数时将遵守__fastcall调用约定。因此,调用函数时所需的前两个参数(如果有的话)不能通过堆栈来传入,而是应该通过CPU寄存器来进行传递。dArgumnetBytes记录了压入参数堆栈中的字节数,pArguments指向参数堆栈的顶部。剩下的两个成员---pbSymbol和pEntryPoint是互斥的,它们用于告诉驱动程序应该执行那个函数。你可以指定一个函数名或者一个无格式的入口地址。其余的成员应该总是被设置为NULL。如果pbSymbol和pEntryPoint都不为NULL,那么pbSymbol将优先于pEntryPoint被采用。通过函数名进行调用要比通过入口地址多出一步,多出的这一步用于确定函数名的入口地址。如果该地址可以获取,则将通过此地址调用该函数。直接传入一个函数入口点则将绕过名称解析步骤。
找到内核模块所导出的符号(symbol)的入口地址其实只是听其来比较容易而已。Win32函数GetModuleHandle()和GetProcAddress()可以很好的与Win32子系统中的所有组件一起工作,但它们不能识别内核模式下的系统模块和驱动程序。实现这一部分的示例代码将很困难,其实现细节将涉及到本章的下一节。现在,让我们假定可以得到一个有效的入口点指针,先不要去关心它是如何得到的。列表6-2给出了SpyCall()函数是我的内核调用接口中的核心部分。正如你所见,它几乎100%都是汇编语言。在C程序中借助于汇编总不是一件很容易的事,但是有些任务如果用纯粹的C语言将很难简单的完成。在这里,我们的问题是SpyCall()需要完全控制堆栈和CPU寄存器,因此它必须绕过C编译器和优化器,因为它们会按照自己的方式来使用堆栈和寄存器。
在深入研究SpyCall()函数的细节之前,让我先描述一下SpyCall()函数的另一个特性,这一特性使得代码看起来更加晦涩。就像在第二章解释的那样,Windows 2000系统模块按名称导出了其内部的一些变量。典型的例子是NtBuildNumber和KeServiceDescriptorTable。Windows 2000/NT/9x使用的PE文件提供了一种通用的机制来将符号名和地址关联起来,而不管地址指针是指向代码还是数据。因此,一个Windows 2000模块自由的将它的导出符号和它的任意一个全局变量关联起来。客户端模块可以和它们进行动态链接,就像链接到函数的符号名上一样,然后客户端就可以使用这些变量,就好像这些变量位于自己的全局数据段中一样。当然,我的内核调用接口也可以很好的处理此种类型的符号,因此,我决定如果SPY_CALL_INPUT结构中的dArgumentBytes成员为负值,则表示从入口地址复制数据而不是调用该入口地址。有效值的范围从-1到-9,这里,-1意味着将入口地址自身复制到SPY_CALL_OUTPUT缓冲区中。对于剩下的值,它们的补码将用于表示应该从入口地址复制的字节数,这意味着,-2表示复制一个字节;-3表示一个16位WORD或SHORT;-5表示一个32位DWORD或LONG;-9表示一个64位DWORDLONG或LONGLONG。你可能会很困惑:为什么必须复制入口地址自身?因为,有些内核符号,如KeServiceDescriptorTable指向的结构体大于64位,而返回值的大小是不能超过64位的,所以,最佳的办法是返回一个无格式的指针而不是将返回值截断为64位。
void SpyCall (PSPY_CALL_INPUT psci,
PSPY_CALL_OUTPUT psco)
{
PVOID pStack;
__asm
{
pushfd
pushad
xor eax, eax
mov ebx, psco ; get output parameter block
lea edi, [ebx.uliResult] ; get result buffer
mov [edi ], eax ; clear result buffer (lo)
mov [edi+4], eax ; clear result buffer (hi)
mov ebx, psci ; get input parameter block
mov ecx, [ebx.dArgumentBytes]
cmp ecx, -9 ; call or store/copy?
jb SpyCall2
mov esi, [ebx.pEntryPoint] ; get entry point
not ecx ; get number of bytes
jecxz SpyCall1 ; 0 -> store entry point
rep movsb ; copy data from entry point
jmp SpyCall5
SpyCall1:
mov [edi], esi ; store entry point
jmp SpyCall5
SpyCall2:
mov esi, [ebx.pArguments]
cmp [ebx.fFastCall], eax ; __fastcall convention?
jz SpyCall3
cmp ecx, 4 ; 1st argument available?
jb SpyCall3
mov eax, [esi] ; eax = 1st argument
add esi, 4 ; remove argument from list
sub ecx, 4
cmp ecx, 4 ; 2nd argument available?
jb SpyCall3
mov edx, [esi] ; edx = 2nd argument
add esi, 4 ; remove argument from list
sub ecx, 4
SpyCall3:
mov pStack, esp ; save stack pointer
jecxz SpyCall4 ; no (more) arguments
sub esp, ecx ; copy argument stack
mov edi, esp
shr ecx, 2
rep movsd
SpyCall4:
mov ecx, eax ; load 1st __fastcall arg
call [ebx.pEntryPoint] ; call entry point
mov esp, pStack ; restore stack pointer
mov ebx, psco ; get output parameter block
mov [ebx.uliResult.LowPart ], eax ; store result (lo)
mov [ebx.uliResult.HighPart], edx ; store result (hi)
SpyCall5:
popad
popfd
}
return;
}
列表6-2. 内核调用接口的核心函数
对于访问导出变量的这一特殊情况必须要牢记。这样列表6-2中的代码将不再是特别难以理解的了。首先,64位的结果缓冲区将被清空,以保证未使用的位总是零。接下来,输入数据的dArgumentBytes成员将和-9比较,以确定客户端是请求一个函数调用还是一个数据复制操作。函数调用处理代码从SpyCall2标签处开始。通过计算pArgumnets成员的值,将ESI寄存器指向参数堆栈的顶部,接下来,检查调用约定。如果需要__fastcall并且在堆栈中至少有一个32位值,则SpyCall()将该值从堆栈中移除,并将其临时保存在EAX寄存器中。如果堆栈中还有另一个32位值,它也将被移除并保存到EDX寄存器中。剩余的参数则全部保留在堆栈中。此时,将到达SpyCall3标签处。现在堆栈的当前栈顶地址将被保存到局部变量pStack中,然后使用i386的REP MOVSD指令将参数堆栈(不包括在__fastcall情况下已经移除的参数)复制到Spy driver自己的堆栈中。注意,方向标志用于确定MOVSD在内存中是向上移动还是向下移动,可以假定Spy driver堆栈默认已被清空。这意味着,ESI和EDI寄存器在复制的每个阶段之后都会被累加。现在,在执行CALL指令之前,唯一剩下需要做的就是将__fastcall的第一个参数从它的临时位置EAX复制到其最终位置---ECX。SpyCall()将直接复制EAX到EXC,因为即使调用约定是__stdcall或__cdecl,直接复制也不会产生严重错误。MOV ECX, EAX执行速度很快,直接执行这一指令将比先测试fFastCall成员然后再进行处理会更快一些。
在对函数入口点的调用返回之后,SpyCall()将堆栈指针恢复到pStack所指的位置。需要注意的是,__stdcall和__fastcall采用与__cdecl不同的堆栈清理策略。一个__cdecl调用在返回时,会将ESP寄存器指向参数堆栈的栈顶,而__stdcall和__fastcall则将ESP恢复到其调用前的位置。强制将ESP恢复到其先前位置,总是可以很优雅的清除参数堆栈,而且这样做也不需要关心采用的是那种调用约定。SpyCall()中最后几行汇编代码用于将函数执行结果保存到EDX:EAX,以返回给调用者的SPY_CALL_OUTPUT结构。这里不需要知道结果的确切大小。这并不是必须的,因为调用者明确的知道它所期望的有效位的个数。复制过多的位不会产生Bug,多出的位将会被调用者忽略。
对于列表6-2中代码的,有件事也应该注意,即这些代码不会阻止无效的参数。它甚至不检查堆栈指针本身是否有效。在内核模式下,这等同于玩火。不过,Spy driver该如何验证所有参数的有效性呢?堆栈中的一个32位值也许是一个计数器、一个位域数组或者可能是一个指针。只有调用者和被调用的目标函数知道参数的确切含义。SpyCall()函数只是一个简单的通过层,它并不知道它传递的数据的类型。向函数中增加对上下文敏感的参数的检查等同于重写了操作系统的一大部分。幸运的是,Windows 2000提供一中简单的方法来完成这一任务:结构化异常处理(Structured Exception Handling, SEH)。
SHE是一个非常易于使用的框架,它允许程序捕获可能会引起系统崩溃的异常。一个异常是指一个非正常的状态,它会强制CPU停止工作,而不管CPU正在做什么。产生异常的典型操作是:从一个无效线性地址读取或写入数据(这里的无效线性地址指的是没有映射到物理内存或页面文件的线性地址)、向代码段中写入数据、试图在数据段中执行指令或者除数为零。有些个别异常是良性的,例如,如果要访问的内存已经被置换到页面文件中,则也会产生一个异常,操作系统通过将目标页再次调入内存来解决这一异常。不过,大多数异常都是致命的,因为操作系统不知道如何从异常中恢复过来,因此系统就简单的将自己shutdown来表示自己的不满。这种反应似乎有些过于激进,不过,在事情变得更严重之前,将系统挂起还是一个比较好的选择。通过使用SEH,产生异常的程序将获得一个机会来处理此异常。使用微软专用的__try/__except,可以监控一段任意代码中可能产生的异常,如果一个异常将系统引入了临界状态,那么一个自定义的处理例程(位于用户自己的程序中)将被调用,这样就允许程序员提供一个比蓝屏更好的处理方法。
显然,SHE也可以完成我的Spy device所需的参数有效性验证问题。列表6-3给出了一个将SpyCall()放入SHE帧中的外包函数。被保护的代码位于__try语句之后。当然,保护的不仅是SpyCall();所有在调用的上下文环境中被执行的代码都会得到保护。如果抛出了一个异常,则将执行__except语句后面的代码,当然这一异常要满足过滤表达式EXCEPTION_EXECUTE_HANDLER。列表6-3中的异常处理例程没有太大价值。它仅会使SpyCallEx()返回状态代码:STATUS_ACCESS_VIOLATION,而不是正常的STATUS_SUCCESS,这一状态代码仅会使在用户模式下对DeviceIoControl()的调用失败,而不是出现蓝屏。在异常发生之后,唯一存在的问题是:被调用函数的返回值并未定义。不过这是调用者应该处理得事情。
NTSTATUS SpyCallEx (PSPY_CALL_INPUT psci,
PSPY_CALL_OUTPUT psco)
{
NTSTATUS ns = STATUS_SUCCESS;
__try
{
SpyCall (psci, psco);
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
ns = STATUS_ACCESS_VIOLATION;
}
return ns;
}
列表6-3. 在内核调用接口中使用结构化异常
尽管SEH可以捕获大多数常见的参数错误,但是,你不能指望它可以阻止客户端程序可能传递给内核API函数的所有“垃圾”。某些糟糕的函数参数虽然并不会使系统崩溃,但却仍在悄无声息的破坏着系统。例如,一个函数在复制一个字符串时,如果指定了错误的目标地址指针,则可以很容易的覆盖掉系统内存的关键部分。可能很长时间都无发发现这种Bug,直到执行到被覆盖的内存区域后,系统突然意外的玩完了,我们才可能发觉。在测试Spy driver的过程中,有时,我使测试程序发出的IOCTL调用(针对spy device)处于挂起状态。测试程序没有任何反应,甚至拒绝从内存中移除。更糟的是,系统会变得无法关闭。这和蓝屏一样让人厌烦。
Next:
接下来我们将讨论,如何在运行时链接到系统模块。
………….待续…………