2008年9月12日星期五

Gloomy对Windows内核的分析(系统调用接口)

系统调用接口
===========================
Я смотрел на снег весь день.
.. Падающий...
Всегда вниз. Падающий весь д
ень.
И тогда я закричал "Это жизн
ь?"
(c) by My Dying Bride

系统调用是线程由用户模式转向内核模式的接口。自然,如果讲到系统的安全性和可靠性,
研究系统调用的实现机制是非常有益的。系统服务实现上的错误就是系统安全上的漏洞,因
为任何用户模式下的线程都能利用这个错误来访问内核模式。

因此,用户模式下的线程需要调用系统服务并转入内核模式。系统服务的调用是通过中断2E
h进行的。用户模式模块NTDLL.DLL将调用转向内核中的许多函数。例如,导出函数NtQueryS
ection的代码形式如下:

7F67CDC public _NtQuerySection@20
7F67CDC _NtQuerySection@20 proc near
7F67CDC
7F67CDC arg_0 = byte ptr 4
7F67CDC
7F67CDC mov eax, 77h ; NtQuerySection
7F67CE1 lea edx, [esp+arg_0]
7F67CE5 int 2Eh
7F67CE7 retn 14h
7F67CE7 _NtQuerySection@20 endp

实际上,NTDLL.DLL中其它所有的对内核服务的调用都是这个样子。从代码中可以看到,调用
中断2Eh时,EAX寄存器为服务的功能号,EDX寄存器为堆栈中参数的地址。现在我们来看NTO
SKRNL.EXE中_KiSystemService(中断2Eh的处理程序)的部分代码。有意思的是下面这一段


[skipped]

8013CB20 _KiEndUnexpectedRange proc near
8013CB20 cmp ecx, 10h ; if call to win32k.sys
8013CB23 jnz short Kss_LimitError
8013CB25 push edx
8013CB26 push ebx
8013CB27 call _PsConvertToGuiThread@0
8013CB2C or eax, eax
8013CB2E pop eax
8013CB2F pop edx

[skipped]

8013CBD0 ; S u b r o u t i n e
8013CBD0
8013CBD0 public _KiSystemService
8013CBD0 _KiSystemService proc near ; DATA XREF: INIT:801C7A50 o

[skipped]

8013CBD8 mov ebx, 30h
8013CBDD db 66h
8013CBDD mov fs, bx ; set fs to 30 (processor contol region)
8013CBE0 push dword ptr ds:0FFDFF000h
8013CBE6 mov dword ptr ds:0FFDFF000h, 0FFFFFFFFh
8013CBF0 mov esi, ds:0FFDFF124h ; Current Kernel Thread Pointer
8013CBF6 push dword ptr [esi+137h] ; previous mode: Kernel/user

[skipped]

8013CC29 _KiSystemServiceRepeat:
8013CC29 mov edi, eax ; function number
8013CC2B shr edi, 8
8013CC2E and edi, 30h
8013CC31 mov ecx, edi
8013CC33 add edi, [esi+0DCh] ; got service tables address
8013CC39 mov ebx, eax
8013CC3B and eax, 0FFFh
8013CC40 cmp eax, [edi+8] ; num of services
8013CC43 jnb _KiEndYnexpectedRange

[skipped]

8013CC6E mov esi, edx ; parameters addres
8013CC70 mov ebx, [edi+0Ch] ; table with sizes
8013CC73 xor ecx, ecx
8013CC75 mov cl, [eax+ebx] ; size of parameters
8013CC78 mov edi, [edi] ; handler's table
8013CC7A mov ebx, [edi+eax*4] ; got function address
8013CC7D sub esp, ecx ; clear stack
8013CC7F shr ecx, 2
8013CC82 mov edi, esp
8013CC84 cmp esi, ds:_MmUserProbeAddress ; 7fff0000
8013CC8A jnb kss80
8013CC90 KiSystemServiceCopyArguments:
8013CC90 repe movsd ; copy to ring0 stack
8013CC92 kssdoit:
8013CC92 call ebx
8013CC94 kss60:
8013CC94 mov esp, ebp
8013CC96 kss70:
8013CC96 mov ecx, ds:0FFDFF124h
8013CC9C mov edx, [ebp+3Ch]
8013CC9F mov [ecx+128h], edx
8013CC9F _KiSystemService endp
8013CC9F
8013CCA5 _KiServiceExit proc near

[skipped]

8013CE34 kss80:
8013CE34 test byte ptr [ebp+6Ch], 1 ; kernel/user
8013CE38 jz KiSystemServiceCopyArguments
8013CE3E mov eax, 0C0000005h ; STATUS_ACCESS_VIOLATION
8013CE43 jmp kss60

这样,如果调用产生于ring0,则处理程序检查参数是否位于用户地址区域中(见8013CC84)
。之后,处理程序检查传递给它的参数(向ring0堆栈中拷贝参数起始于标号KiSystemServi
ceCopyArguments)。如果没有错误,则按照预先从服务地址表中选出的地址进行CALL EBX。
接着,注意到两个有趣的地方。第一个是,所有的核心线程都能够取得服务地址表的地址(
参照8013CBF0和8013CC33处的代码)。第二个有趣的地方是,服务表可以有四个(对于每个
线程来说)。标号_KiSystemServiceRepeat处代码的调用依赖于位0x3000的值,从四个描述
服务表地址的描述符中选择一个。描述符占据16字节并连续排列。这四个描述符总称服务描
述符表。对于每一个线程,在内核线程结构体中都有其自己的指向服务描述符表的指针。这
个指针可以从线程结构体的0DCh偏移处取得(Windows NT 4.0下)。线程结构体的地址可以
在内核模式下从PCRB的偏移124h处取得。(MOV EAX, FS:[124h])。每个线程都有自己指向
服务描述符表的指针,实际上,所有线程中的指针都指向两个描述符表中的一个。这两个表
位于NTOSKERNEL.EXE,分别叫做KeServiceDescriptorTable和KeServiceDescriptorTableSh
adow。表中的描述符的格式如下:

typedef struct _ServiceDescriptor{
DWORD* ServiceTable; /* 指向服务地址表的指针 */
DWORD Reserved; /* 在checked build下使用 */
DWORD ServiceLimit; /* 表中服务的数目 */
BYTE* ArgumentTable; /* 指向服务堆栈中参数表大小的指针 */
/* 实际上等于 (4*参数数目) */
}ServiceDescriptor;

在系统初始化(KiInitSystem)时,表KeServiceDescriptorTable和KeServiceDescriptorS
hadow的描述符0被初始化为以下这个样子(伪代码):

KeServiceDescriptorTable [0].ServiceTable = KiServiceTable;
KeServiceDescriptorTable [0].ServiceLimit = KiServiceLimit;
KeServiceDescriptorTable [0].ArgumentTable = KiArgumentTable;
memcpy (&KeServiceDescriptorTableShadow[0], &KeServiceDescriptorTable[0],0x10);

其余的描述符都为0。KiServiceTable是NTOSKRNL.EXE中函数的偏移表。KiArgumentTable为
服务参数数目乘以4(参数堆栈frame的大小)。KiServiceLimit为KiServiceTable表中服务
的数目。KeServiceDescriptorTableShadow表的描述符0,为创建的描述符的副本。因此,描
述符0是在内核初始化时填充的,并描述了内核的基本服务。这个描述符对所有线程都是相同
的。那剩下的描述符是做什么的?在驱动WIN32K.SYS初始化的时候会调用内核函数KeAddSys
temServiceTable。其伪代码如下:

BOOL KeAddSystemServiceTable (
PVOID* ServiceTable,
ULONG Reserved,
ULONG Limit,
BYTE* Arguments,
ULONG NumOfDesc)
{
if (NumOfDesc>3) return 0;
Descriptor= &KeServiceDescriptorTable [NumOfDesc*16];
if (Descriptor->ServiceTable)return 0;
ShadowDescriptor= &KeServiceDescriptorTableShadow[NumOfDesc*16];
if (ShadowDescriptor->ServiceTable) return 0;

ShadowDescriptor->ServiceTable=ServiceTable;
ShadowDescriptor->Reserved=Reserved;
ShadowDescriptor->ServiceLimit=Limit;
ShadowDescriptor->ArgumentTable=Arguments;

if (NumOfDesc!=1){
Descriptor->ServiceTable=ServiceTable;
Descriptor->Reserved=Reserved;
Descriptor->ServiceLimit=Limit;
Descriptor->ArgumentTable=Arguments;
}
return 1;
}

函数很简单,但可从中获取不少信息。这个函数填充四个描述符中的一个,一般来说,可能
填充shadow table,也有可能填充主表(若描述符为0,则未使用)。但有一个特殊之处很有
意思——如果添加描述符1,则该描述符只会被添加到shadow table中。初始化WIN32K.SYS时
,恰好添加的就是描述符1。此时,其余的描述符并未使用。我们知道,为了提高效率,在W
indows NT v4.0下,Win32子系统的USER和GDI函数都是在内核中实现的。Win32k是内核模式
驱动程序,它实现了Win32函数,描述符1描述了这些服务。现在,我们来看一下,这些表都
为线程提供了什么。函数KeInitializeThread有两行:

80119344 mov esi, [ebp+lpThread]
[skipped]
80119394 mov dword ptr [esi+0DCh], offset _KeServiceDescriptorTable

下面又有两行,但是在PsConvertToGuiThread函数中的:

80192919 mov ecx, [ebp+lpServiceDescriptorTable] ; thread struct + 0dch
[skipped]
80192926 mov dword ptr [ecx], offset _KeServiceDescriptorTableShadow

如果调用的是WIN32K.SYS的服务,但用于当前线程的描述符1并未初始化,则在2e中断处理程
序中会调用函数PsConvertToGuiThread。服务描述符表有两个——主表和shadow表。在主表
中只有一个偏移为0的非零描述符,其描述了基本的系统服务。在shadow表中除描述符0之外
,还有WIN32K.SYS初始化的描述符1,其描述了GDI和USER服务。对于GUI线程,在线程结构体
的偏移0DCh处是shadow表的地址,对于其它线程,该处为主表的地址。如果线程请求WIN32K
.SYS的服务,则它要成为GUI线程。在研究了服务表的结构以及描述符1的用途之后,可以看
到Win32子系统与内核非常紧密的结合在一起。描述符1的特殊性在于其嵌入到了内核代码中
。KeAddSystemServiceTable函数是未公开的函数,非常简单并可以在添加新服务的驱动程序
中静态的调用。我们注意到,IIS使用了两个描述符。所以最好在第三个描述符中添加自己的
服务。

Windows NT调用的特殊之处在于用户模式下的大量指针。几乎每一个内核函数都是以检查指
针区域参数正确性这一繁琐工作开始的。因此所有的用户地址空间都与内核空间重合,并且
,在用户模式下工作的同时,内核由于页保护被隔绝开,而在内核模式下,不正确的用户指
针可以寻址到内核区域。如果看一下选择子10和23的界限,则可以看到它们是一样的(0xFF
FFFFFF)。选择子23(用户模式的选择子)的界限应该等于内核空间起始地址减1(0x7FFFF
FFF)。例如,在LINUX下就是这样的。如果试图强行在调试器中修改这个界限值,则Window
s NT会出BSOD。为什么会这样?答案令人不可思议:在内核中执行线程时竟使用选择子23。
一方面,这样是很方便——驱动使用用户指针就像使用普通的指针。而另一方面,这又会引
发潜在的错误。我已经讲过,在LINUX下,用户和内核空间并不重合,在内核中使用用户指针
时需调用copy_from_user()之类的函数(对于i386,这些函数仅仅是一些从不同段中进行拷
贝的常规代码)。这种不方便性迫使内核程序控制并最小化了对用户指针的使用。

Windows NT的内核与用户重叠的空间导致了系统最初版本代码中的许多错误。这些错误都十
分隐蔽——要知道Win32经常要用到服务,这需要向内核中传递正确的参数。


Windows 2K内核中系统调用接口的变化
===========================================================

// 这里的主要内容是我看到的一篇文章里的。:( 我并不想剽窃别人的著作,但是我实在是
忘了这是谁的文章以及是在哪里看见的了。

Windows 2K的内核除了通过中断2Eh的系统调用接口之外,还可以通过SYSENTER/SYSEXIT指令
转入内核模式。这些指令是Pentium II+处理器里才有的。SYSENTER的处理程序位于内核中
的KiSystemService里并调用KiFastCallEntry。KiFastCallEntry开始部分的样子如下:

MOV ESP, SS:[0xFFDFF040]
MOV ESP, [ESP+4] ;set ring-0 stack
PUSH 0x23 ;模拟ring3堆栈
PUSH EDX ;指向ring3堆栈参数的指针
SUB DWORD PTR [ESP], 4 ;在ring3的堆栈中
PUSHFD
OR DWORD PTR [ESP], 0x200 ;模拟ring3的标志寄存器
PUSH 0x1B ;ring3的CS选择子
PUSH ECX ;ring3的EIP
;..fill in KeTrapFrame
;..后面部分同系统调用的处理相同

显然,对于与系统调用相同的部分,处理程序完全透明的实现了系统调用——上述的代码模
拟了调用中断时的堆栈。除此之外,现在可以使用Fast System Call机制来进行系统调用。

MOV EAX, NtCallCode ; 系统调用号
LEA EDX, [ESP+4] ; 堆栈中的参数
LEA ECX, SYSEXIT_POINT ; 返回点
SYSENTER
SYSEXIT_POINT:

所有这些都好象是从NTDLL.DLL通过中断2E调用的。其它的接口与此类似。系统调用最后部分
形式如下:

TEST KeFeatureBits, 0x1000 ;支持 fast system call
JZ ReturnFromInterrupt ;非 - iret
TEST DWORD PTR [ESP+4], 1 ;从ring3调用?
JZ ReturnFromInterrupt ; 非 - iret
TEST DWORD PTR [ESP+8], 0x20000 ;从v86调用?
JNZ ReturnFromInterrupt ; 是 - iret
POP EDX ;返回的eip
ADD ESP, 8 ;回收模拟的中断堆栈
POP ECX ;ring3的esp 3
STI
SYSEXIT
ReturnFromInterrupt:
IRETD

如此——内核支持两种系统调用接口。但是NTDLL.DLL与Windows NT 4.0下的相同,包含着对
系统调用的封装。这样,Windows NT就不能使用fast system call形式的接口。看来,下一
版的NTDLL.DLL将包含两种接口。或者对PII之前和之后的处理器提供两种不同的NTDLL.DLL。

没有评论: